codebase-onboarder 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -10
- package/cli/commands.js +117 -8
- package/cli/main.js +8 -2
- package/cli/wizard.js +27 -9
- package/package.json +1 -1
- package/public/index.html +10 -2
- package/public/js/api.js +21 -56
- package/public/js/serverSettings.js +34 -13
- package/public/login.html +34 -0
- package/server/apiAuth.js +32 -0
- package/server/apiSettings.js +14 -6
- package/server/auth.js +72 -0
- package/server/config.js +32 -8
- package/server/http.js +2 -2
- package/server/httpGuards.js +5 -0
- package/server/https.js +142 -0
- package/server/index.js +16 -6
- package/server/router.js +47 -18
- package/server/tunnel.js +5 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// The Server drawer: the web face of the same config file the CLI wizard
|
|
2
2
|
// writes. Reads come back with the key masked; saves send only the changed
|
|
3
|
-
// keys; rotation reveals the new key once and
|
|
3
|
+
// keys; rotation reveals the new key once and signs this browser in.
|
|
4
4
|
//
|
|
5
5
|
// The module owns its own DOM and its own round-trips. What it borrows from
|
|
6
6
|
// app.js is the drawer choreography — one scrim at a time, Escape to close —
|
|
7
7
|
// which is why it returns open/close/isOpen instead of wiring the topbar
|
|
8
8
|
// button itself.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { fetchServerSettings, updateServerSettings, rotateServerAccessKey, logoutRemoteSession } from '/js/api.js';
|
|
11
11
|
|
|
12
12
|
// A function declaration, not a const arrow: the import-time DOM check in
|
|
13
13
|
// tests/frontend.test.js blanks top-level function bodies but reads const
|
|
@@ -29,7 +29,7 @@ export function createServerDrawer(options = {}) {
|
|
|
29
29
|
const dom = {};
|
|
30
30
|
[
|
|
31
31
|
'serverDrawer', 'serverScrim', 'serverClose', 'serverModeNote', 'srvMode', 'srvHost', 'srvPort',
|
|
32
|
-
'srvDomain', 'srvDomainField', 'srvAutoOpen', 'srvKeySection', 'srvKeyMasked', 'srvRotate',
|
|
32
|
+
'srvDomain', 'srvDomainField', 'srvHttps', 'srvHttpsField', 'srvHttpsStatus', 'srvAutoOpen', 'srvKeySection', 'srvKeyMasked', 'srvRotate', 'srvLogout',
|
|
33
33
|
'srvFreshKeyRow', 'srvFreshKey', 'srvCopyKey', 'srvKeyHint', 'srvName', 'srvEmail',
|
|
34
34
|
'srvTunnelCloudflare', 'srvTunnelCloudflareStatus', 'srvTunnelTailscale', 'srvTunnelTailscaleStatus',
|
|
35
35
|
'srvSave', 'srvStatus', 'srvConfigPath',
|
|
@@ -61,6 +61,10 @@ export function createServerDrawer(options = {}) {
|
|
|
61
61
|
dom.srvHost.value = s.host;
|
|
62
62
|
dom.srvPort.value = s.port;
|
|
63
63
|
dom.srvDomain.value = s.domain || '';
|
|
64
|
+
dom.srvHttps.checked = Boolean(s.https);
|
|
65
|
+
dom.srvHttpsStatus.textContent = data.https?.caddyInstalled
|
|
66
|
+
? `Caddy ${data.https.caddyVersion || 'installed'}. ${s.https ? 'Run `onboarder https setup` after saving.' : 'Enable HTTPS above, save, then run the command.'}`
|
|
67
|
+
: 'Caddy is not installed. Ubuntu: sudo apt install caddy; macOS: brew install caddy.';
|
|
64
68
|
dom.srvAutoOpen.checked = Boolean(s.autoOpen);
|
|
65
69
|
dom.srvName.value = s.account?.name || '';
|
|
66
70
|
dom.srvEmail.value = s.account?.email || '';
|
|
@@ -70,9 +74,9 @@ export function createServerDrawer(options = {}) {
|
|
|
70
74
|
tunnelLine(dom.srvTunnelTailscaleStatus, data.tunnels?.tailscale);
|
|
71
75
|
dom.srvConfigPath.textContent = `Config file: ${data.configFile}`;
|
|
72
76
|
dom.srvKeyMasked.value = s.accessKeyMasked || (s.hasAccessKey ? '' : '(none set — the API refuses every call)');
|
|
73
|
-
dom.srvKeyHint.textContent =
|
|
74
|
-
? '
|
|
75
|
-
: '
|
|
77
|
+
dom.srvKeyHint.textContent = s.hasAccessKey
|
|
78
|
+
? 'Remote browsers sign in once; the key is exchanged for a secure HTTP-only session.'
|
|
79
|
+
: 'No key is set. Generate one before remote access is possible.';
|
|
76
80
|
applyModeVisibility();
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -82,6 +86,7 @@ export function createServerDrawer(options = {}) {
|
|
|
82
86
|
const remote = dom.srvMode.value === 'self-hosted';
|
|
83
87
|
dom.srvKeySection.hidden = !remote;
|
|
84
88
|
dom.srvDomainField.hidden = !remote;
|
|
89
|
+
dom.srvHttpsField.hidden = !remote || !dom.srvDomain.value.trim();
|
|
85
90
|
dom.serverModeNote.textContent = MODE_NOTES[dom.srvMode.value] || '';
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -113,16 +118,22 @@ export function createServerDrawer(options = {}) {
|
|
|
113
118
|
account: { name: dom.srvName.value.trim(), email: dom.srvEmail.value.trim() },
|
|
114
119
|
tunnel: { cloudflare: dom.srvTunnelCloudflare.checked, tailscale: dom.srvTunnelTailscale.checked },
|
|
115
120
|
};
|
|
116
|
-
if (patch.mode === 'self-hosted')
|
|
121
|
+
if (patch.mode === 'self-hosted') {
|
|
122
|
+
patch.domain = dom.srvDomain.value.trim();
|
|
123
|
+
patch.https = dom.srvHttps.checked;
|
|
124
|
+
}
|
|
117
125
|
status('Saving…');
|
|
118
126
|
try {
|
|
119
127
|
const data = await updateServerSettings(patch);
|
|
120
128
|
render({ ...current, settings: data.settings });
|
|
121
129
|
const restart = (data.restartRequired || []).join(' and ');
|
|
130
|
+
const httpsChanged = (data.restartRequired || []).some((key) => key === 'domain' || key === 'https');
|
|
122
131
|
status(
|
|
123
|
-
|
|
124
|
-
?
|
|
125
|
-
:
|
|
132
|
+
httpsChanged
|
|
133
|
+
? 'Saved. Run `onboarder https setup` to validate the Caddyfile and reload TLS.'
|
|
134
|
+
: restart
|
|
135
|
+
? `Saved. The ${restart} change takes effect after a restart — the running socket cannot move.`
|
|
136
|
+
: 'Saved — live already.',
|
|
126
137
|
'ok',
|
|
127
138
|
);
|
|
128
139
|
} catch (err) {
|
|
@@ -134,9 +145,8 @@ export function createServerDrawer(options = {}) {
|
|
|
134
145
|
status('Rotating…');
|
|
135
146
|
try {
|
|
136
147
|
const data = await rotateServerAccessKey();
|
|
137
|
-
// The
|
|
138
|
-
//
|
|
139
|
-
setAccessKey(data.accessKey);
|
|
148
|
+
// The server refreshes this browser's signed session as part of rotation.
|
|
149
|
+
// The raw key is shown once for copying elsewhere, never stored locally.
|
|
140
150
|
render({ ...current, settings: data.settings });
|
|
141
151
|
dom.srvFreshKey.value = data.accessKey;
|
|
142
152
|
dom.srvFreshKeyRow.hidden = false; // render() does not know about the reveal
|
|
@@ -146,9 +156,20 @@ export function createServerDrawer(options = {}) {
|
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
|
|
159
|
+
async function signOut() {
|
|
160
|
+
try {
|
|
161
|
+
await logoutRemoteSession();
|
|
162
|
+
location.replace('/');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
status(err.message, 'err');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
dom.srvMode.addEventListener('change', applyModeVisibility);
|
|
169
|
+
dom.srvDomain.addEventListener('input', applyModeVisibility);
|
|
150
170
|
dom.srvSave.addEventListener('click', save);
|
|
151
171
|
dom.srvRotate.addEventListener('click', rotate);
|
|
172
|
+
dom.srvLogout.addEventListener('click', signOut);
|
|
152
173
|
dom.serverClose.addEventListener('click', close);
|
|
153
174
|
dom.serverScrim.addEventListener('click', close);
|
|
154
175
|
dom.srvCopyKey.addEventListener('click', async () => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow">
|
|
7
|
+
<title>Sign in · Onboarder</title>
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23f7f4ed'/%3E%3Ccircle cx='9' cy='16' r='4' fill='none' stroke='%232a2721' stroke-width='2'/%3E%3Ccircle cx='24' cy='8' r='3.2' fill='none' stroke='%232e5d43' stroke-width='2'/%3E%3Ccircle cx='24' cy='24' r='3.2' fill='none' stroke='%232a2721' stroke-width='2'/%3E%3Cpath d='M13 14 L21 9 M13 18 L21 23' stroke='%232a2721' stroke-width='1.6'/%3E%3C/svg%3E">
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:light dark;--paper:#f7f4ed;--paper2:#efe9dc;--card:#fffdf7;--ink:#2a2721;--ink2:#57503f;--faint:#8a8577;--line:rgba(42,39,33,.22);--accent:#2e5d43;--soft:#dce8d8;--warn:#a4442a;--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;--shadow:3px 3px 0 rgba(42,39,33,.9)}
|
|
11
|
+
@media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#211e19;--paper2:#292520;--card:#2b2721;--ink:#ece7db;--ink2:#c9c2b2;--faint:#9b9484;--line:rgba(236,231,219,.22);--accent:#8fbd9f;--soft:#243d2c;--warn:#d97b5a;--shadow:3px 3px 0 rgba(0,0,0,.6)}}
|
|
12
|
+
*{box-sizing:border-box}.username-field{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}html,body{min-height:100%}body{margin:0;display:grid;place-items:center;padding:28px;overflow-x:hidden;background-color:var(--paper);background-image:radial-gradient(var(--line) .7px,transparent .7px);background-size:18px 18px;color:var(--ink);font-family:var(--sans)}.shell{width:min(100%,430px);position:relative}.shell:before{content:"";position:fixed;inset:14px;border:1px solid var(--line);pointer-events:none}.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px}.mark{width:42px;height:42px;display:grid;place-items:center;background:var(--card);border:1.5px solid var(--ink);box-shadow:var(--shadow)}.mark svg{width:28px}.wordmark{font:600 25px/1 var(--serif)}.tagline{margin:5px 0 0;color:var(--faint);font-size:12px}.card{padding:clamp(25px,6vw,38px);background:var(--card);border:1.5px solid var(--ink);border-radius:10px;box-shadow:var(--shadow)}.eyebrow{display:flex;align-items:center;gap:7px;margin:0 0 13px;color:var(--accent);font:600 11px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase}.eyebrow:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}h1{margin:0;font:600 clamp(30px,8vw,40px)/1.03 var(--serif);letter-spacing:-.025em}.intro{margin:14px 0 24px;color:var(--ink2);font-size:14px;line-height:1.6}label{display:block;font:600 12px var(--mono)}.input-wrap{position:relative;margin-top:8px}input{width:100%;height:48px;padding:0 47px 0 13px;border:1.5px solid var(--ink);border-radius:5px;outline:0;background:var(--paper);color:var(--ink);font:14px var(--mono)}input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--soft)}.toggle{position:absolute;top:5px;right:5px;width:38px;height:38px;border:0;border-radius:4px;background:transparent;color:var(--ink2);cursor:pointer}.toggle:hover,.toggle:focus-visible{background:var(--paper2);color:var(--ink);outline:0}.submit{width:100%;height:46px;margin-top:13px;border:1.5px solid var(--ink);border-radius:5px;background:var(--ink);color:var(--paper);font:600 12px var(--mono);cursor:pointer;transition:.08s}.submit:hover{transform:translate(-1px,-1px);box-shadow:2px 2px 0 var(--accent)}.submit:disabled{cursor:wait;opacity:.65}.message{min-height:21px;margin:13px 0 0;color:var(--warn);font-size:12.5px}.message[data-ok=true]{color:var(--accent)}.privacy{display:flex;gap:9px;margin:23px -4px 0;padding-top:17px;border-top:1px solid var(--line);color:var(--faint);font-size:11.5px;line-height:1.5}.privacy svg{flex:none;width:15px;margin-top:1px}.foot{margin:18px 0 0;text-align:center;color:var(--faint);font:10.5px var(--mono)}@media(max-width:480px){body{padding:20px;place-items:start center}.shell{margin:5vh 0}.shell:before{inset:8px}.card{padding:24px 20px}}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main class="shell">
|
|
17
|
+
<div class="brand"><div class="mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="9" cy="16" r="4"/><circle cx="24" cy="8" r="3.2" stroke="#2e5d43"/><circle cx="24" cy="24" r="3.2"/><path d="M13 14 21 9M13 18 21 23"/></svg></div><div><div class="wordmark">onboarder</div><p class="tagline">a map for any codebase</p></div></div>
|
|
18
|
+
<section class="card" aria-labelledby="loginTitle">
|
|
19
|
+
<p class="eyebrow">Private server</p><h1 id="loginTitle">Welcome back.</h1>
|
|
20
|
+
<p class="intro">This Onboarder instance is self-hosted. Enter its access key to open the codebase map.</p>
|
|
21
|
+
<form id="loginForm"><label for="accessKey">Access key</label><input id="username" name="username" type="text" value="onboarder" autocomplete="username" tabindex="-1" aria-hidden="true" class="username-field"><div class="input-wrap">
|
|
22
|
+
<input id="accessKey" name="accessKey" type="password" placeholder="ob_••••••••••••" autocomplete="current-password" autocapitalize="off" spellcheck="false" required autofocus>
|
|
23
|
+
<button class="toggle" id="toggleKey" type="button" aria-label="Show access key" aria-pressed="false"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.5"/></svg></button>
|
|
24
|
+
</div><button class="submit" type="submit">Unlock Onboarder →</button><p class="message" id="message" role="status" aria-live="polite"></p></form>
|
|
25
|
+
<div class="privacy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span>Your key is exchanged for a secure, HTTP-only session. It is not stored in this page or added to the address bar.</span></div>
|
|
26
|
+
</section><p class="foot">Everything runs on your server.</p>
|
|
27
|
+
</main>
|
|
28
|
+
<script type="module">
|
|
29
|
+
const form=document.getElementById('loginForm'),input=document.getElementById('accessKey'),message=document.getElementById('message'),toggle=document.getElementById('toggleKey'),submit=form.querySelector('.submit');
|
|
30
|
+
toggle.addEventListener('click',()=>{const showing=input.type==='text';input.type=showing?'password':'text';toggle.setAttribute('aria-pressed',String(!showing));toggle.setAttribute('aria-label',showing?'Show access key':'Hide access key');input.focus()});
|
|
31
|
+
form.addEventListener('submit',async event=>{event.preventDefault();const accessKey=input.value.trim();if(!accessKey)return;submit.disabled=true;message.textContent='Checking…';message.dataset.ok='false';try{const res=await fetch('/api/auth/login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessKey})}),data=await res.json().catch(()=>({}));if(!res.ok)throw new Error(data.error||'That access key is not correct.');message.textContent='Key accepted. Opening Onboarder…';message.dataset.ok='true';window.location.replace('/')}catch(error){message.textContent=error.message;message.dataset.ok='false';input.select()}finally{submit.disabled=false}});
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The access-key form. Kept tiny and independent from the main app: an
|
|
2
|
+
// unauthenticated visitor must be able to load this page without loading any
|
|
3
|
+
// analyzer, settings drawer, or other application surface.
|
|
4
|
+
|
|
5
|
+
import { accessKeysMatch, authReason } from './config.js';
|
|
6
|
+
import { hasValidSession, sessionCookie } from './auth.js';
|
|
7
|
+
import { sendError, sendJSON } from './http.js';
|
|
8
|
+
|
|
9
|
+
export function handleAuthStatus(req, res, settings) {
|
|
10
|
+
sendJSON(res, 200, {
|
|
11
|
+
authenticated: !authReason(req, settings) || hasValidSession(req, settings),
|
|
12
|
+
configured: Boolean(settings.accessKey),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function handleLogin(req, res, body, settings) {
|
|
17
|
+
if (!settings.accessKey) return sendError(res, 503, 'This server has no access key configured. Run `onboarder setup` on the server.');
|
|
18
|
+
if (!accessKeysMatch(settings.accessKey, body?.accessKey)) {
|
|
19
|
+
return sendError(res, 401, 'That access key is not correct.');
|
|
20
|
+
}
|
|
21
|
+
sendJSON(res, 200, { ok: true }, {
|
|
22
|
+
'cache-control': 'no-store',
|
|
23
|
+
'set-cookie': sessionCookie(req, settings),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function handleLogout(req, res) {
|
|
28
|
+
sendJSON(res, 200, { ok: true }, {
|
|
29
|
+
'cache-control': 'no-store',
|
|
30
|
+
'set-cookie': sessionCookie(req, {}, { clear: true }),
|
|
31
|
+
});
|
|
32
|
+
}
|
package/server/apiSettings.js
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
readSettings, updateSettings, writeSettings, configPath,
|
|
13
13
|
} from './config.js';
|
|
14
14
|
import { sendError, sendJSON } from './http.js';
|
|
15
|
+
import { sessionCookie } from './auth.js';
|
|
15
16
|
import { tunnelStatus } from './tunnel.js';
|
|
17
|
+
import { httpsStatus } from './https.js';
|
|
16
18
|
|
|
17
19
|
// Where this request's settings live. The router's config object carries the
|
|
18
20
|
// path when the server was booted with one; tests and a bare `createServer()`
|
|
@@ -37,6 +39,7 @@ async function publicBody(config) {
|
|
|
37
39
|
configFile: settingsFile(config) || configPath(),
|
|
38
40
|
node: process.version,
|
|
39
41
|
tunnels: tunnelStatus(settings),
|
|
42
|
+
https: httpsStatus(settings, settingsFile(config) || configPath()),
|
|
40
43
|
};
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -48,15 +51,17 @@ export async function handleGetSettings(req, res, config) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
// Which changes the running server cannot absorb.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
+
// Which changes the running server cannot absorb. Access-key and tunnel flags
|
|
55
|
+
// are read live per request. Host/port are bound sockets. Domain/HTTPS change
|
|
56
|
+
// the managed Caddyfile and certificate, so Caddy must validate/reload too.
|
|
54
57
|
function restartRequired(config, next) {
|
|
55
58
|
const boot = config?.boot;
|
|
56
59
|
if (!boot) return [];
|
|
57
60
|
const changed = [];
|
|
58
61
|
if (next.host !== boot.host) changed.push('host');
|
|
59
62
|
if (next.port !== boot.port) changed.push('port');
|
|
63
|
+
if (next.domain !== boot.domain) changed.push('domain');
|
|
64
|
+
if (next.https !== boot.https) changed.push('https');
|
|
60
65
|
return changed;
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -70,7 +75,7 @@ export async function handleUpdateSettings(res, body, config) {
|
|
|
70
75
|
}
|
|
71
76
|
// Only known keys may be patched — a typo like "ports" must be a loud 400,
|
|
72
77
|
// not a silently ignored write that the UI then claims it saved.
|
|
73
|
-
const allowed = new Set(['mode', 'host', 'port', 'domain', 'autoOpen', 'account', 'tunnel']);
|
|
78
|
+
const allowed = new Set(['mode', 'host', 'port', 'domain', 'https', 'autoOpen', 'account', 'tunnel']);
|
|
74
79
|
const unknown = Object.keys(body).filter((k) => k !== 'accessKey' && !allowed.has(k));
|
|
75
80
|
if (unknown.length) {
|
|
76
81
|
return sendError(res, 400, `Unknown setting${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`);
|
|
@@ -92,7 +97,7 @@ export async function handleUpdateSettings(res, body, config) {
|
|
|
92
97
|
// Rotation returns the new key in the clear, once. It is never in a GET after
|
|
93
98
|
// that — `hasAccessKey`/`accessKeyMasked` are all the readback there is. The
|
|
94
99
|
// person copies it from this response into the devices that need it.
|
|
95
|
-
export async function handleRotateAccessKey(res, config) {
|
|
100
|
+
export async function handleRotateAccessKey(req, res, config) {
|
|
96
101
|
const file = settingsFile(config);
|
|
97
102
|
if (!file) {
|
|
98
103
|
return sendError(res, 400, 'This server was started without a config file, so there is nothing to save to.');
|
|
@@ -104,7 +109,10 @@ export async function handleRotateAccessKey(res, config) {
|
|
|
104
109
|
sendJSON(res, 200, {
|
|
105
110
|
accessKey,
|
|
106
111
|
settings: publicSettings(next),
|
|
107
|
-
note: 'Shown once.
|
|
112
|
+
note: 'Shown once. This browser is signed in with the new key; update every other device.',
|
|
113
|
+
}, {
|
|
114
|
+
'cache-control': 'no-store',
|
|
115
|
+
'set-cookie': sessionCookie(req, next),
|
|
108
116
|
});
|
|
109
117
|
} catch (err) {
|
|
110
118
|
sendError(res, 400, err.message || 'The key could not be rotated.');
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Browser sessions for the self-hosted access-key gate.
|
|
2
|
+
//
|
|
3
|
+
// The access key itself never becomes a cookie. A successful login mints an
|
|
4
|
+
// opaque timestamp + HMAC using the configured key as the signing secret. The
|
|
5
|
+
// browser gets an HttpOnly cookie, JavaScript cannot read it, and rotating the
|
|
6
|
+
// access key invalidates every old session immediately.
|
|
7
|
+
|
|
8
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export const SESSION_COOKIE = 'onboarder_session';
|
|
11
|
+
export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
12
|
+
|
|
13
|
+
function signature(expires, accessKey) {
|
|
14
|
+
return createHmac('sha256', accessKey)
|
|
15
|
+
.update(`onboarder-session-v1:${expires}`)
|
|
16
|
+
.digest('base64url');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createSession(settings, now = Date.now()) {
|
|
20
|
+
const key = String(settings?.accessKey || '');
|
|
21
|
+
if (!key) return '';
|
|
22
|
+
const expires = Math.floor(now / 1000) + SESSION_TTL_SECONDS;
|
|
23
|
+
return `${expires}.${signature(expires, key)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cookieValue(req, name) {
|
|
27
|
+
const header = String(req.headers?.cookie || '');
|
|
28
|
+
for (const part of header.split(';')) {
|
|
29
|
+
const at = part.indexOf('=');
|
|
30
|
+
if (at === -1) continue;
|
|
31
|
+
if (part.slice(0, at).trim() !== name) continue;
|
|
32
|
+
try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return ''; }
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hasValidSession(req, settings, now = Date.now()) {
|
|
38
|
+
const key = String(settings?.accessKey || '');
|
|
39
|
+
const token = cookieValue(req, SESSION_COOKIE);
|
|
40
|
+
const dot = token.indexOf('.');
|
|
41
|
+
if (!key || dot < 1) return false;
|
|
42
|
+
|
|
43
|
+
const expires = Number(token.slice(0, dot));
|
|
44
|
+
const supplied = token.slice(dot + 1);
|
|
45
|
+
if (!Number.isSafeInteger(expires) || expires <= Math.floor(now / 1000) || !supplied) return false;
|
|
46
|
+
|
|
47
|
+
const expected = signature(expires, key);
|
|
48
|
+
const a = Buffer.from(supplied);
|
|
49
|
+
const b = Buffer.from(expected);
|
|
50
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requestIsSecure(req) {
|
|
54
|
+
if (req.socket?.encrypted) return true;
|
|
55
|
+
const forwarded = String(req.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
56
|
+
return forwarded === 'https';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sessionCookie(req, settings, { clear = false } = {}) {
|
|
60
|
+
const parts = [
|
|
61
|
+
`${SESSION_COOKIE}=${clear ? '' : encodeURIComponent(createSession(settings))}`,
|
|
62
|
+
'Path=/',
|
|
63
|
+
'HttpOnly',
|
|
64
|
+
'SameSite=Strict',
|
|
65
|
+
`Max-Age=${clear ? 0 : SESSION_TTL_SECONDS}`,
|
|
66
|
+
];
|
|
67
|
+
// A direct bare-IP deployment may still be plain HTTP, where Secure would make
|
|
68
|
+
// the cookie unusable. Caddy forwards the original scheme, so domain HTTPS
|
|
69
|
+
// gets the production-safe flag automatically.
|
|
70
|
+
if (requestIsSecure(req)) parts.push('Secure');
|
|
71
|
+
return parts.join('; ');
|
|
72
|
+
}
|
package/server/config.js
CHANGED
|
@@ -7,7 +7,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
-
export const CONFIG_VERSION =
|
|
10
|
+
export const CONFIG_VERSION = 2;
|
|
11
11
|
export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
12
12
|
export const DEFAULT_SETTINGS = Object.freeze({
|
|
13
13
|
version: CONFIG_VERSION,
|
|
@@ -15,6 +15,10 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
15
15
|
host: '127.0.0.1',
|
|
16
16
|
port: 4310,
|
|
17
17
|
domain: '',
|
|
18
|
+
// When a domain is configured, ask Caddy to obtain and renew a trusted TLS
|
|
19
|
+
// certificate and reverse-proxy this app. The certificate itself belongs to
|
|
20
|
+
// Caddy; the server still speaks plain HTTP to its local upstream.
|
|
21
|
+
https: false,
|
|
18
22
|
accessKey: '',
|
|
19
23
|
// Whether `onboarder start` opens the app in a browser once it is listening.
|
|
20
24
|
// Off in the schema so `npm start` from a checkout stays quiet; the setup
|
|
@@ -82,6 +86,7 @@ export function normalizeSettings(value = {}) {
|
|
|
82
86
|
host,
|
|
83
87
|
port: normalizePort(value.port ?? DEFAULT_SETTINGS.port),
|
|
84
88
|
domain: normalizeDomain(value.domain),
|
|
89
|
+
https: Boolean(value.https),
|
|
85
90
|
accessKey: cleanString(value.accessKey, 256),
|
|
86
91
|
autoOpen: value.autoOpen === undefined ? DEFAULT_SETTINGS.autoOpen : Boolean(value.autoOpen),
|
|
87
92
|
account: normalizeAccount(value.account),
|
|
@@ -91,10 +96,11 @@ export function normalizeSettings(value = {}) {
|
|
|
91
96
|
if (settings.mode === 'local' && !isLoopbackHost(settings.host)) {
|
|
92
97
|
throw new Error('Local mode only binds to 127.0.0.1, localhost, or ::1. Choose self-hosted mode for a network bind.');
|
|
93
98
|
}
|
|
99
|
+
if (settings.https && !settings.domain) {
|
|
100
|
+
throw new Error('HTTPS needs a domain such as map.example.com. A public IP alone cannot be used for a normal trusted certificate.');
|
|
101
|
+
}
|
|
94
102
|
// Self-hosted needs no domain: a bare-IP bind (a VPS with no DNS name) is a
|
|
95
|
-
// legitimate layout.
|
|
96
|
-
// configured domain or this machine's own interface addresses (see
|
|
97
|
-
// allowedHosts), and every API call needs the access key either way.
|
|
103
|
+
// legitimate layout. Every API call needs the access key either way.
|
|
98
104
|
return settings;
|
|
99
105
|
}
|
|
100
106
|
|
|
@@ -113,7 +119,7 @@ export function serverUrls(value = DEFAULT_SETTINGS) {
|
|
|
113
119
|
? `http://<this-machine>:${settings.port} (every interface)`
|
|
114
120
|
: `http://${settings.host}:${settings.port}`;
|
|
115
121
|
}
|
|
116
|
-
if (settings.domain) urls.domain = `https://${settings.domain}`;
|
|
122
|
+
if (settings.domain) urls.domain = settings.https ? `https://${settings.domain}` : `http://${settings.domain}:${settings.port}`;
|
|
117
123
|
return urls;
|
|
118
124
|
}
|
|
119
125
|
|
|
@@ -124,6 +130,10 @@ export function maskAccessKey(key) {
|
|
|
124
130
|
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
export function browserUrl(settings) {
|
|
134
|
+
return new URL(serverUrls(settings).local).toString();
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
128
138
|
const settings = normalizeSettings(value);
|
|
129
139
|
return {
|
|
@@ -132,6 +142,7 @@ export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
|
132
142
|
host: settings.host,
|
|
133
143
|
port: settings.port,
|
|
134
144
|
domain: settings.domain,
|
|
145
|
+
https: settings.https,
|
|
135
146
|
autoOpen: settings.autoOpen,
|
|
136
147
|
hasAccessKey: Boolean(settings.accessKey),
|
|
137
148
|
accessKeyMasked: maskAccessKey(settings.accessKey),
|
|
@@ -179,9 +190,11 @@ export function allowedHosts(value = DEFAULT_SETTINGS) {
|
|
|
179
190
|
// A wildcard bind is useful as a literal Host value to diagnostics, even
|
|
180
191
|
// though browsers normally address the machine by one of its real IPs.
|
|
181
192
|
hosts.add(settings.host.toLowerCase());
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
193
|
+
// Any network bind can sit behind NAT: a VPS may be configured as a private
|
|
194
|
+
// interface address while browsers address its public/NAT IP. Accept every IP
|
|
195
|
+
// literal, never arbitrary DNS names; the access key protects remote data.
|
|
196
|
+
hosts.add('ip:*');
|
|
197
|
+
for (const ip of ownInterfaceHosts()) if (!isLoopbackHost(ip)) hosts.add(ip);
|
|
185
198
|
}
|
|
186
199
|
if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
|
|
187
200
|
if (settings.tunnel.tailscale) hosts.add('*.ts.net');
|
|
@@ -200,9 +213,20 @@ export function bearerToken(req) {
|
|
|
200
213
|
return match ? match[1].trim() : '';
|
|
201
214
|
}
|
|
202
215
|
|
|
216
|
+
export function requestIsLoopback(req) {
|
|
217
|
+
const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
|
218
|
+
return remote === '127.0.0.1' || remote === '::1';
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
export function authReason(req, settings = DEFAULT_SETTINGS) {
|
|
204
222
|
const normalized = normalizeSettings(settings);
|
|
205
223
|
if (normalized.mode !== 'self-hosted') return null;
|
|
224
|
+
// A self-hosted server can still be opened on the same machine. Caddy and
|
|
225
|
+
// tunnels arrive from loopback but keep the public Host, so both signals must
|
|
226
|
+
// agree before a login page is skipped.
|
|
227
|
+
const host = String(req.headers?.host || '').toLowerCase();
|
|
228
|
+
const hostIsLoopback = isLoopbackHost(host.replace(/:\d+$/, '')) || /^localhost:\d+$/.test(host);
|
|
229
|
+
if (requestIsLoopback(req) && hostIsLoopback) return null;
|
|
206
230
|
if (!normalized.accessKey) return 'Self-hosted mode has no access key configured.';
|
|
207
231
|
const token = bearerToken(req);
|
|
208
232
|
if (!accessKeysMatch(normalized.accessKey, token)) return 'A valid access key is required.';
|
package/server/http.js
CHANGED
|
@@ -37,8 +37,8 @@ export function readBody(req, limit = MAX_BODY_BYTES) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function sendJSON(res, status, data) {
|
|
41
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
40
|
+
export function sendJSON(res, status, data, headers = {}) {
|
|
41
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers });
|
|
42
42
|
res.end(JSON.stringify(data));
|
|
43
43
|
}
|
|
44
44
|
|
package/server/httpGuards.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// open from driving this server on their behalf. Binding to 127.0.0.1 keeps
|
|
8
8
|
// the network out; it does nothing about the browser, which is already inside.
|
|
9
9
|
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
|
|
10
12
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
11
13
|
|
|
12
14
|
// "localhost:4310" -> "localhost", "[::1]:4310" -> "[::1]"
|
|
@@ -41,6 +43,9 @@ export function rebindingReason(req, extraHosts = []) {
|
|
|
41
43
|
const name = hostnameOf(host);
|
|
42
44
|
if (LOOPBACK_HOSTS.has(name)) return null;
|
|
43
45
|
for (const extra of extraHosts) {
|
|
46
|
+
if (extra === 'ip:*' && net.isIP(name.replace(/^\[|\]$/g, '')) > 0) return null;
|
|
47
|
+
if (extra === 'ipv4:*' && net.isIP(name) === 4) return null;
|
|
48
|
+
if (extra === 'ipv6:*' && net.isIP(name.replace(/^\[|\]$/g, '')) === 6) return null;
|
|
44
49
|
if (extra.startsWith('*.')) {
|
|
45
50
|
// "*.trycloudflare.com" matches "abc.trycloudflare.com" but not the bare
|
|
46
51
|
// suffix itself and not "evil-trycloudflare.com".
|
package/server/https.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { promises as dns } from 'node:dns';
|
|
2
|
+
import { promises as fs, statSync } from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
import { configPath, normalizeSettings } from './config.js';
|
|
9
|
+
import { findOnPath, installHint } from './tunnel.js';
|
|
10
|
+
|
|
11
|
+
export function caddyfilePath(configFile = configPath()) {
|
|
12
|
+
return path.join(path.dirname(configFile), 'Caddyfile');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderCaddyfile(settings) {
|
|
16
|
+
const s = normalizeSettings(settings);
|
|
17
|
+
if (!s.domain) throw new Error('HTTPS needs a domain. Run `onboarder setup` and enter one first.');
|
|
18
|
+
return `# Managed by Onboarder. Edits are replaced by \`onboarder https setup\`.\n${s.domain} {\n reverse_proxy 127.0.0.1:${s.port}\n}\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function portIsFree(port, host = '0.0.0.0') {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const probe = net.createServer();
|
|
24
|
+
probe.once('error', () => resolve(false));
|
|
25
|
+
probe.listen(port, host, () => probe.close(() => resolve(true)));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function resolveDomain(domain) {
|
|
30
|
+
const [v4, v6] = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
|
|
31
|
+
const addresses = [
|
|
32
|
+
...(v4.status === 'fulfilled' ? v4.value : []),
|
|
33
|
+
...(v6.status === 'fulfilled' ? v6.value : []),
|
|
34
|
+
];
|
|
35
|
+
return [...new Set(addresses)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function localAddresses() {
|
|
39
|
+
const found = [];
|
|
40
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
41
|
+
for (const address of list || []) if (!address.internal) found.push(address.address);
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function caddyIsRunning(probe = portIsFree) {
|
|
47
|
+
// If 80 and 443 are both occupied, probe Caddy's local admin endpoint. This
|
|
48
|
+
// distinguishes the Ubuntu service from an unknown listener without sudo.
|
|
49
|
+
if (await probe(80)) return false;
|
|
50
|
+
if (await probe(443)) return false;
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const socket = net.connect(2019, '127.0.0.1');
|
|
53
|
+
const finish = (value) => { socket.destroy(); resolve(value); };
|
|
54
|
+
socket.setTimeout(800, () => finish(false));
|
|
55
|
+
socket.once('connect', () => finish(true));
|
|
56
|
+
socket.once('error', () => finish(false));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function httpsReadiness(settings, { configFile = configPath(), resolve = resolveDomain, probe = portIsFree, tool = findOnPath, running = caddyIsRunning } = {}) {
|
|
61
|
+
const s = normalizeSettings(settings);
|
|
62
|
+
const caddy = tool('caddy');
|
|
63
|
+
const checks = [{ id: 'domain', ok: Boolean(s.domain), required: true, detail: s.domain || 'not configured' }];
|
|
64
|
+
if (!s.domain) return { ok: false, checks, caddy, addresses: [], local: [] };
|
|
65
|
+
|
|
66
|
+
let addresses = [];
|
|
67
|
+
let dnsError = '';
|
|
68
|
+
try { addresses = await resolve(s.domain); } catch (error) { dnsError = error.code || error.message; }
|
|
69
|
+
checks.push({ id: 'dns', ok: addresses.length > 0, required: true, detail: addresses.length ? addresses.join(', ') : `does not resolve${dnsError ? ` (${dnsError})` : ''}` });
|
|
70
|
+
const local = localAddresses();
|
|
71
|
+
const matches = addresses.some((address) => local.includes(address));
|
|
72
|
+
checks.push({ id: 'dns-target', ok: matches, required: false, detail: matches ? 'resolves to this machine' : 'does not match a local interface address (NAT/public-IP and proxy setups can still be valid)' });
|
|
73
|
+
|
|
74
|
+
const serviceRunning = await running(probe);
|
|
75
|
+
for (const port of [80, 443]) {
|
|
76
|
+
const free = await probe(port);
|
|
77
|
+
const ownedByCaddy = !free && serviceRunning;
|
|
78
|
+
checks.push({
|
|
79
|
+
id: `port-${port}`,
|
|
80
|
+
ok: free || ownedByCaddy,
|
|
81
|
+
required: true,
|
|
82
|
+
detail: free ? 'available for Caddy' : ownedByCaddy ? 'in use by the running Caddy service' : 'already in use — stop the existing web server or proxy',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
checks.push({ id: 'caddy', ok: caddy.installed, required: true, detail: caddy.installed ? caddy.version : 'not installed — ' + installHint('caddy') });
|
|
86
|
+
return { ok: checks.every((check) => check.ok || !check.required), checks, caddy, addresses, local };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function httpsStatus(settings, configFile = configPath()) {
|
|
90
|
+
const s = normalizeSettings(settings);
|
|
91
|
+
const file = caddyfilePath(configFile);
|
|
92
|
+
const caddy = findOnPath('caddy');
|
|
93
|
+
let configured = false;
|
|
94
|
+
try { configured = Boolean(statSync(file)); } catch { configured = false; }
|
|
95
|
+
return {
|
|
96
|
+
enabled: Boolean(s.domain && s.https),
|
|
97
|
+
domain: s.domain,
|
|
98
|
+
url: s.domain && s.https ? `https://${s.domain}` : '',
|
|
99
|
+
caddyfile: file,
|
|
100
|
+
caddyInstalled: caddy.installed,
|
|
101
|
+
caddyVersion: caddy.version || '',
|
|
102
|
+
configured,
|
|
103
|
+
command: s.domain && s.https ? `caddy start --config ${file}` : '',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function writeCaddyfile(settings, configFile = configPath()) {
|
|
108
|
+
const file = caddyfilePath(configFile);
|
|
109
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
110
|
+
await fs.writeFile(file, renderCaddyfile(settings), { encoding: 'utf8', mode: 0o600 });
|
|
111
|
+
return file;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function caddyValidate(settings, configFile = configPath()) {
|
|
115
|
+
const s = normalizeSettings(settings);
|
|
116
|
+
if (!s.domain) throw new Error('HTTPS needs a domain. Run `onboarder setup` and enter one first.');
|
|
117
|
+
const caddy = findOnPath('caddy');
|
|
118
|
+
if (!caddy.installed) throw new Error('Caddy is not installed. ' + installHint('caddy'));
|
|
119
|
+
const file = caddyfilePath(configFile);
|
|
120
|
+
const result = spawnSync('caddy', ['validate', '--config', file], { encoding: 'utf8' });
|
|
121
|
+
if (result.error) throw result.error;
|
|
122
|
+
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'Caddy configuration is invalid.').trim());
|
|
123
|
+
return { file, output: String(result.stdout || result.stderr || '').trim() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function caddyRun(settings, configFile = configPath(), action = 'start') {
|
|
127
|
+
const s = normalizeSettings(settings);
|
|
128
|
+
if (!s.domain || !s.https) throw new Error('Enable HTTPS with a domain first: `onboarder setup`.');
|
|
129
|
+
const caddy = findOnPath('caddy');
|
|
130
|
+
if (!caddy.installed) throw new Error('Caddy is not installed. ' + installHint('caddy'));
|
|
131
|
+
const file = caddyfilePath(configFile);
|
|
132
|
+
const result = spawnSync('caddy', [action, '--config', file], { encoding: 'utf8' });
|
|
133
|
+
if (result.error) throw result.error;
|
|
134
|
+
if (result.status !== 0) {
|
|
135
|
+
const message = String(result.stderr || result.stdout || `caddy ${action} failed`).trim();
|
|
136
|
+
if (/permission denied|address already in use/i.test(message) && process.platform !== 'win32') {
|
|
137
|
+
throw new Error(`${message}\nStart the packaged service with: sudo systemctl enable --now caddy`);
|
|
138
|
+
}
|
|
139
|
+
throw new Error(message);
|
|
140
|
+
}
|
|
141
|
+
return { action, file, output: String(result.stdout || result.stderr || '').trim() };
|
|
142
|
+
}
|