From ab1f479ffd701f240e32c04c2666fb6516c69037 Mon Sep 17 00:00:00 2001 From: Unheard0840 Date: Sat, 29 Aug 2026 09:15:14 +0200 Subject: [PATCH] Add password authentication --- server/auth.js | 86 ++++++++++++++++++++++++ server/status.js | 79 +++++++++++++++++----- startos/file-models/store.json.ts | 2 +- startos/interfaces.ts | 2 +- startos/main.ts | 22 ++++-- web/index.html | 107 ++++++++++++++++++++++++++++-- 6 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 server/auth.js diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..658dd97 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,86 @@ +const crypto = require('crypto') + +const adminPassword = process.env.MUNIN_ADMIN_PASSWORD || '' +const sessions = new Map() + +const SESSION_TTL_MS = 24 * 60 * 60 * 1000 + +function createSession() { + const token = crypto.randomBytes(32).toString('hex') + + sessions.set(token, { + expiresAt: Date.now() + SESSION_TTL_MS, + }) + + return token +} + +function parseCookies(cookieHeader) { + const cookies = {} + + for (const part of (cookieHeader || '').split(';')) { + const index = part.indexOf('=') + if (index === -1) continue + + const name = part.slice(0, index).trim() + const value = part.slice(index + 1).trim() + + if (name) { + cookies[name] = decodeURIComponent(value) + } + } + + return cookies +} + +function isValidSession(req) { + const token = parseCookies(req.headers.cookie).munin_session + + if (!token) return false + + const session = sessions.get(token) + + if (!session) return false + + if (session.expiresAt <= Date.now()) { + sessions.delete(token) + return false + } + + return true +} + +function passwordsEqual(a, b) { + const left = Buffer.from(a) + const right = Buffer.from(b) + + if (left.length !== right.length) return false + + return crypto.timingSafeEqual(left, right) +} + +function checkPassword(password) { + if (!adminPassword || typeof password !== 'string') { + return false + } + + return passwordsEqual(password, adminPassword) +} + +function cleanupSessions() { + const now = Date.now() + + for (const [token, session] of sessions) { + if (session.expiresAt <= now) { + sessions.delete(token) + } + } +} + +setInterval(cleanupSessions, 60 * 60 * 1000).unref() + +module.exports = { + checkPassword, + createSession, + isValidSession, +} diff --git a/server/status.js b/server/status.js index 8a41ba5..f8bf4de 100644 --- a/server/status.js +++ b/server/status.js @@ -1,5 +1,10 @@ const http = require('http'); const net = require('net'); +const { + checkPassword, + createSession, + isValidSession, +} = require('./auth'); const port = 8080; const fulcrumTimeoutMs = 3000; @@ -222,36 +227,80 @@ async function getStatus() { } const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/api/login') { + let body = '' + + req.on('data', (chunk) => { + body += chunk + }) + + req.on('end', () => { + try { + const input = JSON.parse(body) + + if (!checkPassword(input.password)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + }) + res.end(JSON.stringify({ error: 'Invalid password' })) + return + } + + const token = createSession() + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Set-Cookie': 'munin_session=' + encodeURIComponent(token) + + '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400', + }) + res.end(JSON.stringify({ ok: true })) + } catch { + res.writeHead(400, { + 'Content-Type': 'application/json', + }) + res.end(JSON.stringify({ error: 'Invalid request' })) + } + }) + + return + } + if (req.url === '/api/status') { + if (!isValidSession(req)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + }) + res.end(JSON.stringify({ error: 'Authentication required' })) + return + } + try { - const status = await getStatus(); + const status = await getStatus() res.writeHead(200, { 'Content-Type': 'application/json', - }); + }) - res.end(JSON.stringify(status)); + res.end(JSON.stringify(status)) } catch (error) { - console.error('Failed to get service status:', error); + console.error('Failed to get service status:', error) res.writeHead(500, { 'Content-Type': 'application/json', - }); + }) - res.end( - JSON.stringify({ - service: 'running', - error: 'Failed to determine service status', - }), - ); + res.end(JSON.stringify({ + service: 'running', + error: 'Failed to determine service status', + })) } - return; + return } - res.writeHead(404); - res.end('Not found'); -}); + res.writeHead(404) + res.end('Not found') +}) server.listen(port, () => { console.log(`Status API listening on port ${port}`); diff --git a/startos/file-models/store.json.ts b/startos/file-models/store.json.ts index e8f177f..58eb6ef 100644 --- a/startos/file-models/store.json.ts +++ b/startos/file-models/store.json.ts @@ -5,4 +5,4 @@ export const store = FileHelper.json( z.object({ adminPassword: z.string().optional(), }), -) \ No newline at end of file +) diff --git a/startos/interfaces.ts b/startos/interfaces.ts index 51f5399..0a368dc 100644 --- a/startos/interfaces.ts +++ b/startos/interfaces.ts @@ -12,7 +12,7 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { const ui = sdk.createInterface(effects, { name: i18n('Web Interface'), id: 'ui', - description: i18n('The main web interface'), + description: i18n('The web interface of Munin Bitcoin'), type: 'ui', masked: false, schemeOverride: null, diff --git a/startos/main.ts b/startos/main.ts index 78ff641..0fdbeac 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -2,6 +2,7 @@ import { i18n } from './i18n' import { sdk } from './sdk' import { uiPort } from './utils' import { electrumPort, mainHostId } from 'fulcrum-startos/startos/utils' +import { store } from './file-models/store.json' export const main = sdk.setupMain(async ({ effects }) => { console.info(i18n('Starting Munin Bitcoin!')) @@ -15,6 +16,8 @@ export const main = sdk.setupMain(async ({ effects }) => { }) .const() + const credentials = await store.read().const(effects) + return sdk.Daemons.of(effects).addDaemon('primary', { subcontainer: sdk.SubContainer.of( effects, @@ -29,11 +32,18 @@ export const main = sdk.setupMain(async ({ effects }) => { ), exec: { command: ['munin-bitcoin'], - env: fulcrumAddress - ? { - MUNIN_FULCRUM_URL: `tcp://${fulcrumAddress}`, - } - : {}, + env: { + ...(fulcrumAddress + ? { + MUNIN_FULCRUM_URL: `tcp://${fulcrumAddress}`, + } + : {}), + ...(credentials?.adminPassword + ? { + MUNIN_ADMIN_PASSWORD: credentials.adminPassword, + } + : {}), + }, }, ready: { display: i18n('Web Interface'), @@ -45,4 +55,4 @@ export const main = sdk.setupMain(async ({ effects }) => { }, requires: [], }) -}) \ No newline at end of file +}) diff --git a/web/index.html b/web/index.html index 8117a3c..0230298 100644 --- a/web/index.html +++ b/web/index.html @@ -100,6 +100,28 @@ color: #fca5a5; } + .login { + max-width: 420px; + margin: 80px auto; + } + + .login input { + box-sizing: border-box; + width: 100%; + padding: 12px 14px; + border: 1px solid #4b5563; + border-radius: 8px; + background: #111827; + color: #f9fafb; + font: inherit; + margin-top: 8px; + } + + .login button { + width: 100%; + margin-top: 16px; + } + button { margin-top: 16px; padding: 10px 16px; @@ -118,10 +140,31 @@
-

Munin Bitcoin

-

Bitcoin watch-only wallet monitoring and label management

+
@@ -177,17 +221,55 @@ element.querySelector('span:last-child').textContent = text; } + async function showLogin() { + document.getElementById('login').hidden = false; + document.getElementById('dashboard').hidden = true; + document.getElementById('password').focus(); + } + + async function showDashboard() { + document.getElementById('login').hidden = true; + document.getElementById('dashboard').hidden = false; + } + + async function login(password) { + const response = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ password }), + }); + + if (!response.ok) { + throw new Error('Invalid password'); + } + + await showDashboard(); + await loadStatus(); + } + async function loadStatus() { const errorElement = document.getElementById('error'); errorElement.textContent = ''; try { - const response = await fetch('/api/status'); + const response = await fetch('/api/status', { + credentials: 'same-origin', + }); + + if (response.status === 401) { + await showLogin(); + return; + } if (!response.ok) { throw new Error(`HTTP ${response.status}`); } + await showDashboard(); + const status = await response.json(); setStatus( @@ -250,6 +332,23 @@ } } + document.getElementById('login-form').addEventListener('submit', async (event) => { + event.preventDefault(); + + const passwordInput = document.getElementById('password'); + const loginError = document.getElementById('login-error'); + + loginError.textContent = ''; + + try { + await login(passwordInput.value); + passwordInput.value = ''; + } catch { + loginError.textContent = 'Invalid password.'; + passwordInput.select(); + } + }); + loadStatus(); setInterval(loadStatus, 10000);