diff --git a/Makefile b/Makefile index 39e6a5d8..ed0212f2 100644 --- a/Makefile +++ b/Makefile @@ -132,7 +132,8 @@ LIBFUSE := libfuse/$(BUILDDIR)/libfuse.a LDFLAGS ?= LDLIBS := \ -lrt \ - -pthread + -pthread \ + -lcrypto override LDLIBS += \ $(LIBFUSE) ifeq ($(CXX),g++) diff --git a/index.html b/index.html index 8f4cb57c..e07308ca 100644 --- a/index.html +++ b/index.html @@ -81,6 +81,43 @@ align-items: center; } + .header-left { + display: flex; + flex-direction: column; + } + + .header-right { + display: flex; + align-items: center; + } + + .auth-section { + display: flex; + align-items: center; + gap: 10px; + } + + .auth-password { + min-width: 150px; + } + + .auth-status { + font-size: 12px; + margin-left: 8px; + } + + .auth-status.connected { + color: var(--success-color); + } + + .auth-status.error { + color: var(--danger-color); + } + + .auth-status.pending { + color: var(--warning-color); + } + h1 { margin: 0; font-size: 24px; @@ -253,6 +290,22 @@ background-color: var(--bg-hover); } + .button.auth-success { + background-color: var(--success-color) !important; + } + + .button.auth-success:hover { + background-color: var(--success-hover) !important; + } + + .button.auth-error { + background-color: var(--danger-color) !important; + } + + .button.auth-error:hover { + background-color: var(--danger-hover) !important; + } + .action-buttons { display: flex; gap: 10px; @@ -1006,8 +1059,16 @@
-

mergerfs ui

-
Advanced configuration and management tool
+
+

mergerfs ui

+
Advanced configuration and management tool
+
+
+
+ + +
+
@@ -1211,10 +1272,21 @@ // API interface for mergerfs operations const API = { baseURL: '', + authToken: '', + passwordRequired: false, + authSalt: '', async request(endpoint, options = {}) { try { + if (this.authToken && options.method && options.method !== 'GET') { + options.headers = options.headers || {}; + options.headers['Authorization'] = 'Bearer ' + this.authToken; + } + const response = await fetch(`${this.baseURL}${endpoint}`, options); + if (response.status === 401) { + throw new Error('Authentication required'); + } if (!response.ok) { let errorMsg = `HTTP ${response.status}`; try { @@ -1235,6 +1307,24 @@ } }, + async getAuthSalt() { + const response = await this.request('/auth/salt'); + const data = await response.json(); + this.authSalt = data.salt; + this.passwordRequired = data.password_required; + return data; + }, + + async verifyPassword(password) { + const hashedPassword = await Utils.hashPassword(password, this.authSalt); + const response = await this.request('/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: hashedPassword }) + }); + return await response.json(); + }, + async getMounts() { const response = await this.request('/mounts/mergerfs'); return await response.json(); @@ -1303,7 +1393,9 @@ config: {}, policies: {}, pendingPathInput: null, - branchCounter: 0 + branchCounter: 0, + isAuthenticated: false, + passwordRequired: false }; // Utility functions @@ -1320,6 +1412,18 @@ }; }, + async sha256(message) { + const msgBuffer = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; + }, + + async hashPassword(password, salt) { + return await this.sha256(password + salt); + }, + formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 B'; const k = 1024; @@ -2352,6 +2456,26 @@ } }); + // Auth event listeners + document.getElementById('auth-btn').addEventListener('click', handleAuth); + document.getElementById('password-input').addEventListener('input', () => { + const authBtn = document.getElementById('auth-btn'); + if (AppState.isAuthenticated) { + return; + } + authBtn.textContent = 'Verify'; + authBtn.className = 'button'; + authBtn.disabled = false; + }); + document.getElementById('password-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + handleAuth(); + } + }); + + // Initialize auth state + initAuth(); + } catch (error) { console.error('Failed to initialize application:', error); @@ -2359,6 +2483,98 @@ } } + // Authentication functions + async function initAuth() { + try { + const authInfo = await API.getAuthSalt(); + AppState.passwordRequired = authInfo.password_required; + API.passwordRequired = authInfo.password_required; + + updateAuthStatus(); + + if (!AppState.passwordRequired) { + AppState.isAuthenticated = true; + updateAuthStatus(); + } + } catch (error) { + console.error('Failed to initialize auth:', error); + updateAuthStatus('error', 'Connection failed'); + } + } + + async function handleAuth() { + const passwordInput = document.getElementById('password-input'); + const authBtn = document.getElementById('auth-btn'); + const statusEl = document.getElementById('auth-status'); + + const password = passwordInput.value; + + if (!AppState.passwordRequired) { + AppState.isAuthenticated = true; + updateAuthStatus(); + return; + } + + if (!password) { + updateAuthStatus('error', 'Password required'); + return; + } + + authBtn.disabled = true; + authBtn.innerHTML = '...'; + + try { + const hashedPassword = await Utils.hashPassword(password, API.authSalt); + const result = await API.verifyPassword(password); + + if (result.valid) { + AppState.isAuthenticated = true; + API.authToken = hashedPassword; + updateAuthStatus('success'); + } else { + AppState.isAuthenticated = false; + API.authToken = ''; + updateAuthStatus('error'); + showToast('Invalid password', 'error'); + } + } catch (error) { + AppState.isAuthenticated = false; + API.authToken = ''; + updateAuthStatus('error'); + showToast(error.message, 'error'); + } + } + + function updateAuthStatus(status = '') { + const passwordInput = document.getElementById('password-input'); + const authBtn = document.getElementById('auth-btn'); + + authBtn.className = 'button'; + + if (AppState.passwordRequired) { + if (status === 'success') { + passwordInput.disabled = true; + authBtn.textContent = 'Verified'; + authBtn.classList.add('auth-success'); + authBtn.disabled = true; + } else if (status === 'error') { + passwordInput.disabled = false; + passwordInput.value = ''; + authBtn.textContent = 'Failed'; + authBtn.classList.add('auth-error'); + authBtn.disabled = false; + } else { + passwordInput.disabled = false; + authBtn.textContent = 'Verify'; + authBtn.disabled = false; + } + } else { + passwordInput.disabled = true; + passwordInput.placeholder = 'No password required'; + authBtn.style.display = 'none'; + } + } + // Export/Import functionality function exportConfig() { if (!AppState.currentMount) { diff --git a/src/mergerfs_webui.cpp b/src/mergerfs_webui.cpp index 6c63a2b6..675ea552 100644 --- a/src/mergerfs_webui.cpp +++ b/src/mergerfs_webui.cpp @@ -11,11 +11,101 @@ #include "json.hpp" #include +#include +#include +#include +#include +#include +#include using json = nlohmann::json; using namespace std::string_view_literals; +static std::string g_password_hash = ""; +static std::string g_current_salt = ""; + +static bool _check_auth(const httplib::Request &req_); + +static +std::string +_sha256_hex(const std::string &input) +{ + unsigned char hash[SHA256_DIGEST_LENGTH]; + EVP_MD_CTX *ctx = EVP_MD_CTX_new(); + if (!ctx) return ""; + + EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr); + EVP_DigestUpdate(ctx, input.c_str(), input.length()); + EVP_DigestFinal_ex(ctx, hash, nullptr); + EVP_MD_CTX_free(ctx); + + std::stringstream ss; + for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) + { + ss << std::hex << std::setw(2) << std::setfill('0') << (int)hash[i]; + } + return ss.str(); +} + +static +std::string +_generate_salt(size_t length = 16) +{ + static const char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(0, sizeof(charset) - 2); + + std::string salt; + salt.reserve(length); + for (size_t i = 0; i < length; i++) + { + salt += charset[dis(gen)]; + } + return salt; +} + +static +std::string +_compute_password_hash(const std::string &password, const std::string &salt) +{ + return _sha256_hex(password + salt); +} + +static +bool +_validate_password(const std::string &password_provided) +{ + if (g_password_hash.empty()) + { + return true; + } + + if (password_provided.empty()) + { + return false; + } + + return password_provided == g_password_hash; +} + +static +void +_set_password(const std::string &password) +{ + if (password.empty()) + { + g_password_hash = ""; + g_current_salt = ""; + } + else + { + g_current_salt = _generate_salt(); + g_password_hash = _compute_password_hash(password, g_current_salt); + } +} + static json _generate_error(const fs::path &mount_, @@ -402,6 +492,14 @@ _post_kvs_key(const httplib::Request &req_, return; } + if (!g_password_hash.empty() && !_check_auth(req_)) + { + res_.status = 401; + res_.set_content("{\"error\":\"authentication required\"}", + "application/json"); + return; + } + try { int rv; @@ -440,6 +538,78 @@ _post_kvs_key(const httplib::Request &req_, } } +static +void +_get_auth_salt(const httplib::Request &req_, + httplib::Response &res_) +{ + json j; + + j["salt"] = g_current_salt; + j["password_required"] = !g_password_hash.empty(); + + res_.set_content(j.dump(), "application/json"); +} + +static +void +_post_auth_verify(const httplib::Request &req_, + httplib::Response &res_) +{ + try + { + json j; + json body = json::parse(req_.body); + + std::string password = body.value("password", ""); + + if (_validate_password(password)) + { + j["result"] = "success"; + j["valid"] = true; + } + else + { + res_.status = 401; + j["result"] = "error"; + j["valid"] = false; + j["error"] = "Invalid password"; + } + + res_.set_content(j.dump(), "application/json"); + } + catch(const std::exception &e) + { + fmt::print("{}\n", e.what()); + res_.status = 400; + res_.set_content("invalid json", "text/plain"); + } +} + +static +bool +_check_auth(const httplib::Request &req_) +{ + if (g_password_hash.empty()) + { + return true; + } + + std::string auth_header = req_.get_header_value("Authorization"); + if (auth_header.empty()) + { + return false; + } + + if (auth_header.substr(0, 7) == "Bearer ") + { + std::string token = auth_header.substr(7); + return _validate_password(token); + } + + return false; +} + int mergerfs::webui::main(const int argc_, char **argv_) @@ -471,11 +641,23 @@ mergerfs::webui::main(const int argc_, return app.exit(e); } + if (!password.empty()) + { + _set_password(password); + fmt::print("Password authentication enabled\n"); + } + else + { + fmt::print("No password set. Authentication disabled.\n"); + } + // TODO: Warn if uid of server is not root but mergerfs is. httplib::Server http_server; http_server.Get("/",::_get_root); + http_server.Get("/auth/salt",::_get_auth_salt); + http_server.Post("/auth/verify",::_post_auth_verify); http_server.Get("/mounts",::_get_mounts); http_server.Get("/mounts/mergerfs",::_get_mounts_mergerfs); http_server.Get("/kvs",::_get_kvs);