Compare commits

..
2 Commits
Author SHA1 Message Date
Unheard0840 9b56404e13 Merge remote-tracking branch 'origin/main' 2026-08-29 09:16:59 +02:00
Unheard0840 ab1f479ffd Add password authentication 2026-08-29 09:15:14 +02:00
5 changed files with 270 additions and 26 deletions
+86
View File
@@ -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,
}
+64 -15
View File
@@ -1,5 +1,10 @@
const http = require('http'); const http = require('http');
const net = require('net'); const net = require('net');
const {
checkPassword,
createSession,
isValidSession,
} = require('./auth');
const port = 8080; const port = 8080;
const fulcrumTimeoutMs = 3000; const fulcrumTimeoutMs = 3000;
@@ -222,36 +227,80 @@ async function getStatus() {
} }
const server = http.createServer(async (req, res) => { 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 (req.url === '/api/status') {
if (!isValidSession(req)) {
res.writeHead(401, {
'Content-Type': 'application/json',
})
res.end(JSON.stringify({ error: 'Authentication required' }))
return
}
try { try {
const status = await getStatus(); const status = await getStatus()
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}); })
res.end(JSON.stringify(status)); res.end(JSON.stringify(status))
} catch (error) { } catch (error) {
console.error('Failed to get service status:', error); console.error('Failed to get service status:', error)
res.writeHead(500, { res.writeHead(500, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}); })
res.end( res.end(JSON.stringify({
JSON.stringify({ service: 'running',
service: 'running', error: 'Failed to determine service status',
error: 'Failed to determine service status', }))
}),
);
} }
return; return
} }
res.writeHead(404); res.writeHead(404)
res.end('Not found'); res.end('Not found')
}); })
server.listen(port, () => { server.listen(port, () => {
console.log(`Status API listening on port ${port}`); console.log(`Status API listening on port ${port}`);
+1 -1
View File
@@ -5,4 +5,4 @@ export const store = FileHelper.json(
z.object({ z.object({
adminPassword: z.string().optional(), adminPassword: z.string().optional(),
}), }),
) )
+16 -6
View File
@@ -2,6 +2,7 @@ import { i18n } from './i18n'
import { sdk } from './sdk' import { sdk } from './sdk'
import { uiPort } from './utils' import { uiPort } from './utils'
import { electrumPort, mainHostId } from 'fulcrum-startos/startos/utils' import { electrumPort, mainHostId } from 'fulcrum-startos/startos/utils'
import { store } from './file-models/store.json'
export const main = sdk.setupMain(async ({ effects }) => { export const main = sdk.setupMain(async ({ effects }) => {
console.info(i18n('Starting Munin Bitcoin!')) console.info(i18n('Starting Munin Bitcoin!'))
@@ -15,6 +16,8 @@ export const main = sdk.setupMain(async ({ effects }) => {
}) })
.const() .const()
const credentials = await store.read().const(effects)
return sdk.Daemons.of(effects).addDaemon('primary', { return sdk.Daemons.of(effects).addDaemon('primary', {
subcontainer: sdk.SubContainer.of( subcontainer: sdk.SubContainer.of(
effects, effects,
@@ -29,11 +32,18 @@ export const main = sdk.setupMain(async ({ effects }) => {
), ),
exec: { exec: {
command: ['munin-bitcoin'], command: ['munin-bitcoin'],
env: fulcrumAddress env: {
? { ...(fulcrumAddress
MUNIN_FULCRUM_URL: `tcp://${fulcrumAddress}`, ? {
} MUNIN_FULCRUM_URL: `tcp://${fulcrumAddress}`,
: {}, }
: {}),
...(credentials?.adminPassword
? {
MUNIN_ADMIN_PASSWORD: credentials.adminPassword,
}
: {}),
},
}, },
ready: { ready: {
display: i18n('Web Interface'), display: i18n('Web Interface'),
@@ -45,4 +55,4 @@ export const main = sdk.setupMain(async ({ effects }) => {
}, },
requires: [], requires: [],
}) })
}) })
+103 -4
View File
@@ -100,6 +100,28 @@
color: #fca5a5; 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 { button {
margin-top: 16px; margin-top: 16px;
padding: 10px 16px; padding: 10px 16px;
@@ -118,10 +140,31 @@
</head> </head>
<body> <body>
<main> <main>
<h1>Munin Bitcoin</h1> <section id="login" class="card login">
<p class="subtitle">Bitcoin watch-only wallet monitoring and label management</p> <h1>Munin Bitcoin</h1>
<p class="subtitle">Sign in to access Munin Bitcoin.</p>
<section class="card"> <form id="login-form">
<label for="password">Admin password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
/>
<button type="submit">Sign in</button>
</form>
<p id="login-error" class="error"></p>
</section>
<section id="dashboard" hidden>
<h1>Munin Bitcoin</h1>
<p class="subtitle">Bitcoin watch-only wallet monitoring and label management</p>
<section class="card">
<div class="row"> <div class="row">
<span class="label">Munin service</span> <span class="label">Munin service</span>
<span id="service-status" class="status"> <span id="service-status" class="status">
@@ -168,6 +211,7 @@
<button type="button" onclick="loadStatus()">Refresh status</button> <button type="button" onclick="loadStatus()">Refresh status</button>
<p id="error" class="error"></p> <p id="error" class="error"></p>
</section>
</section> </section>
</main> </main>
@@ -177,17 +221,55 @@
element.querySelector('span:last-child').textContent = text; 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() { async function loadStatus() {
const errorElement = document.getElementById('error'); const errorElement = document.getElementById('error');
errorElement.textContent = ''; errorElement.textContent = '';
try { 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) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
await showDashboard();
const status = await response.json(); const status = await response.json();
setStatus( 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(); loadStatus();
setInterval(loadStatus, 10000); setInterval(loadStatus, 10000);
</script> </script>