Browse Source

checkpoint

webui
Antonio SJ Musumeci 1 day ago
parent
commit
65eea7dda2
  1. 3
      Makefile
  2. 222
      index.html
  3. 182
      src/mergerfs_webui.cpp

3
Makefile

@ -132,7 +132,8 @@ LIBFUSE := libfuse/$(BUILDDIR)/libfuse.a
LDFLAGS ?= LDFLAGS ?=
LDLIBS := \ LDLIBS := \
-lrt \ -lrt \
-pthread
-pthread \
-lcrypto
override LDLIBS += \ override LDLIBS += \
$(LIBFUSE) $(LIBFUSE)
ifeq ($(CXX),g++) ifeq ($(CXX),g++)

222
index.html

@ -81,6 +81,43 @@
align-items: center; 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 { h1 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
@ -253,6 +290,22 @@
background-color: var(--bg-hover); 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 { .action-buttons {
display: flex; display: flex;
gap: 10px; gap: 10px;
@ -1006,8 +1059,16 @@
<body> <body>
<header> <header>
<div class="header-content"> <div class="header-content">
<h1>mergerfs ui</h1>
<div class="subtitle">Advanced configuration and management tool</div>
<div class="header-left">
<h1>mergerfs ui</h1>
<div class="subtitle">Advanced configuration and management tool</div>
</div>
<div class="header-right">
<div class="auth-section">
<input type="password" id="password-input" placeholder="Password (optional)" class="auth-password">
<button class="button" id="auth-btn">Verify</button>
</div>
</div>
</div> </div>
</header> </header>
@ -1211,10 +1272,21 @@
// API interface for mergerfs operations // API interface for mergerfs operations
const API = { const API = {
baseURL: '', baseURL: '',
authToken: '',
passwordRequired: false,
authSalt: '',
async request(endpoint, options = {}) { async request(endpoint, options = {}) {
try { 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); const response = await fetch(`${this.baseURL}${endpoint}`, options);
if (response.status === 401) {
throw new Error('Authentication required');
}
if (!response.ok) { if (!response.ok) {
let errorMsg = `HTTP ${response.status}`; let errorMsg = `HTTP ${response.status}`;
try { 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() { async getMounts() {
const response = await this.request('/mounts/mergerfs'); const response = await this.request('/mounts/mergerfs');
return await response.json(); return await response.json();
@ -1303,7 +1393,9 @@
config: {}, config: {},
policies: {}, policies: {},
pendingPathInput: null, pendingPathInput: null,
branchCounter: 0
branchCounter: 0,
isAuthenticated: false,
passwordRequired: false
}; };
// Utility functions // 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) { formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; 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) { } catch (error) {
console.error('Failed to initialize application:', 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 = '<span class="loading-spinner"></span>...';
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 // Export/Import functionality
function exportConfig() { function exportConfig() {
if (!AppState.currentMount) { if (!AppState.currentMount) {

182
src/mergerfs_webui.cpp

@ -11,11 +11,101 @@
#include "json.hpp" #include "json.hpp"
#include <unistd.h> #include <unistd.h>
#include <random>
#include <sstream>
#include <iomanip>
#include <openssl/evp.h>
#include <openssl/sha.h>
#include <cstring>
using json = nlohmann::json; using json = nlohmann::json;
using namespace std::string_view_literals; 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 static
json json
_generate_error(const fs::path &mount_, _generate_error(const fs::path &mount_,
@ -402,6 +492,14 @@ _post_kvs_key(const httplib::Request &req_,
return; return;
} }
if (!g_password_hash.empty() && !_check_auth(req_))
{
res_.status = 401;
res_.set_content("{\"error\":\"authentication required\"}",
"application/json");
return;
}
try try
{ {
int rv; 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 int
mergerfs::webui::main(const int argc_, mergerfs::webui::main(const int argc_,
char **argv_) char **argv_)
@ -471,11 +641,23 @@ mergerfs::webui::main(const int argc_,
return app.exit(e); 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. // TODO: Warn if uid of server is not root but mergerfs is.
httplib::Server http_server; httplib::Server http_server;
http_server.Get("/",::_get_root); 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",::_get_mounts);
http_server.Get("/mounts/mergerfs",::_get_mounts_mergerfs); http_server.Get("/mounts/mergerfs",::_get_mounts_mergerfs);
http_server.Get("/kvs",::_get_kvs); http_server.Get("/kvs",::_get_kvs);

Loading…
Cancel
Save