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 @@
@@ -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);