Compare commits
2
Commits
7520f56989
...
9b56404e13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b56404e13 | ||
|
|
ab1f479ffd |
@@ -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
@@ -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}`);
|
||||||
|
|||||||
+15
-5
@@ -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'),
|
||||||
|
|||||||
+103
-4
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user