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
package/server/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createRouter } from './router.js';
|
|
|
21
21
|
import { installExitCleanup } from './sessions.js';
|
|
22
22
|
import { createLogger } from './logger.js';
|
|
23
23
|
import { createMcpRunner } from './mcp/runner.js';
|
|
24
|
-
import { configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
|
|
24
|
+
import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from './config.js';
|
|
25
25
|
import { tunnelStatus } from './tunnel.js';
|
|
26
26
|
import { pidIsAlive, readPidFile, removePidFile, writePidFile } from './pidfile.js';
|
|
27
27
|
|
|
@@ -73,7 +73,17 @@ export function startupBanner(settings, { configFile } = {}) {
|
|
|
73
73
|
: ' Mode local — only this machine can reach it');
|
|
74
74
|
lines.push(' Local ' + urls.local);
|
|
75
75
|
if (urls.network) lines.push(' Network ' + urls.network);
|
|
76
|
-
if (urls.domain)
|
|
76
|
+
if (urls.domain) {
|
|
77
|
+
lines.push(' Domain ' + urls.domain);
|
|
78
|
+
if (settings.domain && !settings.https) {
|
|
79
|
+
lines.push(' HTTPS disabled — `onboarder setup` or `onboarder https setup` enables trusted TLS');
|
|
80
|
+
} else if (settings.https) {
|
|
81
|
+
lines.push(' HTTPS Caddy obtains, renews, and terminates TLS for this domain');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (settings.mode === 'self-hosted' && settings.accessKey) {
|
|
85
|
+
lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
|
|
86
|
+
}
|
|
77
87
|
if (settings.mode === 'self-hosted' && !settings.accessKey) {
|
|
78
88
|
lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
|
|
79
89
|
lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
|
|
@@ -130,7 +140,7 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
130
140
|
...CONFIG,
|
|
131
141
|
configPath: configFile,
|
|
132
142
|
getSettings: () => readSettings(configFile),
|
|
133
|
-
boot: { host, port },
|
|
143
|
+
boot: { host, port, domain: settings.domain, https: settings.https },
|
|
134
144
|
});
|
|
135
145
|
|
|
136
146
|
installExitCleanup();
|
|
@@ -166,9 +176,9 @@ export async function startServer({ configFile = configPath(), openBrowser, log
|
|
|
166
176
|
log(startupBanner(live, { configFile }));
|
|
167
177
|
|
|
168
178
|
const shouldOpen = openBrowser ?? (live.autoOpen && process.stdout.isTTY && !process.env.NO_OPEN);
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
if (shouldOpen) openInBrowser(
|
|
179
|
+
// Local requests never need a credential. Remote browsers are sent to the
|
|
180
|
+
// themed login page and exchange the key for an HttpOnly session cookie.
|
|
181
|
+
if (shouldOpen) openInBrowser(browserUrl(live));
|
|
172
182
|
return { server, settings: live, host, port };
|
|
173
183
|
}
|
|
174
184
|
|
package/server/router.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
// The route table and the
|
|
1
|
+
// The route table and the gates in front of it.
|
|
2
2
|
//
|
|
3
3
|
// Everything arrives here: one function decides whether a request is allowed to
|
|
4
4
|
// be answered at all, then which handler answers it. The routes are a list rather
|
|
5
5
|
// than a ladder of `if` statements so that the whole surface of the server is
|
|
6
|
-
// visible in one screen
|
|
6
|
+
// visible in one screen, and everything else is a static file.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// `evil.com` into our own origin, and `Origin`/`Sec-Fetch-Site` stops a page the
|
|
13
|
-
// person happened to have open from driving the API.
|
|
8
|
+
// There are three concerns. Local mode relies on `Host`, `Origin`, and
|
|
9
|
+
// `Sec-Fetch-Site` to stop another page and DNS rebinding from driving it.
|
|
10
|
+
// Self-hosted mode adds real access-key authentication: local browser requests
|
|
11
|
+
// stay open, remote browsers get a signed session, and API clients use Bearer.
|
|
14
12
|
|
|
15
13
|
import { handleDocs } from './apiDocs.js';
|
|
16
14
|
import { handleFile } from './apiFile.js';
|
|
@@ -25,7 +23,9 @@ import { handleDiff, handleDiffRefs } from './apiDiff.js';
|
|
|
25
23
|
import { handleToolsInstall, handleToolsRun, handleToolsStatus } from './apiTools.js';
|
|
26
24
|
import { handleMcpStart, handleMcpStatus, handleMcpStop, handleMcpCommand } from './apiMcp.js';
|
|
27
25
|
import { handleGetSettings, handleRotateAccessKey, handleUpdateSettings } from './apiSettings.js';
|
|
28
|
-
import {
|
|
26
|
+
import { handleAuthStatus, handleLogin, handleLogout } from './apiAuth.js';
|
|
27
|
+
import { accessKeysMatch, allowedHosts, authReason, bearerToken, DEFAULT_SETTINGS } from './config.js';
|
|
28
|
+
import { hasValidSession } from './auth.js';
|
|
29
29
|
|
|
30
30
|
const ROUTES = [
|
|
31
31
|
{
|
|
@@ -110,6 +110,18 @@ const ROUTES = [
|
|
|
110
110
|
method: 'GET', path: '/api/health',
|
|
111
111
|
run: ({ res }) => sendJSON(res, 200, { ok: true }),
|
|
112
112
|
},
|
|
113
|
+
{
|
|
114
|
+
method: 'GET', path: '/api/auth/status', sameOrigin: true,
|
|
115
|
+
run: ({ req, res, settings }) => handleAuthStatus(req, res, settings),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
method: 'POST', path: '/api/auth/login', body: true, sameOrigin: true,
|
|
119
|
+
run: ({ req, res, body, settings }) => handleLogin(req, res, body, settings),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
method: 'POST', path: '/api/auth/logout', body: true, sameOrigin: true,
|
|
123
|
+
run: ({ req, res }) => handleLogout(req, res),
|
|
124
|
+
},
|
|
113
125
|
{
|
|
114
126
|
// The settings drawer and the CLI read the same public shape: everything
|
|
115
127
|
// about the configuration except the access key itself.
|
|
@@ -128,7 +140,7 @@ const ROUTES = [
|
|
|
128
140
|
// this response, never readable again. In self-hosted mode this endpoint
|
|
129
141
|
// is itself behind the current key, so rotation requires possession.
|
|
130
142
|
method: 'POST', path: '/api/settings/access-key', body: true,
|
|
131
|
-
run: ({ res, config }) => handleRotateAccessKey(res, config),
|
|
143
|
+
run: ({ req, res, config }) => handleRotateAccessKey(req, res, config),
|
|
132
144
|
},
|
|
133
145
|
];
|
|
134
146
|
|
|
@@ -184,6 +196,7 @@ export function createRouter(config) {
|
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
const found = matchRoute(req.method, url.pathname);
|
|
199
|
+
const publicAuthRoute = ['/api/health', '/api/auth/status', '/api/auth/login', '/api/auth/logout'].includes(url.pathname);
|
|
187
200
|
if (req.method !== 'GET' || found?.route.sameOrigin) {
|
|
188
201
|
const foreign = crossOriginReason(req);
|
|
189
202
|
if (foreign) {
|
|
@@ -191,18 +204,34 @@ export function createRouter(config) {
|
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
206
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
// Bearer clients keep their existing API contract. A browser gets a signed,
|
|
208
|
+
// HttpOnly session from the login form instead of storing the raw key.
|
|
209
|
+
const bearerClient = accessKeysMatch(settings.accessKey, bearerToken(req));
|
|
210
|
+
const browserAuthenticated = hasValidSession(req, settings);
|
|
211
|
+
const authDenied = authReason(req, settings);
|
|
212
|
+
const locallyExempt = !authDenied;
|
|
213
|
+
const authenticated = bearerClient || browserAuthenticated || locallyExempt;
|
|
214
|
+
const remoteSelfHosted = settings.mode === 'self-hosted' && Boolean(authDenied);
|
|
215
|
+
|
|
216
|
+
if (remoteSelfHosted && !authenticated && !publicAuthRoute) {
|
|
217
|
+
// API callers keep a machine-readable 401. A browser navigation gets the
|
|
218
|
+
// themed sign-in document so the user never has to paste JSON into a tab.
|
|
219
|
+
const accepts = String(req.headers?.accept || '');
|
|
220
|
+
if (req.method === 'GET' && (url.pathname === '/' || accepts.includes('text/html'))) {
|
|
221
|
+
res.statusCode = 200;
|
|
222
|
+
return await serveStatic(res, '/login.html', config);
|
|
223
|
+
}
|
|
224
|
+
return sendError(res, 401, authDenied || 'Sign in with the Onboarder access key first.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (url.pathname === '/api/auth/logout') {
|
|
228
|
+
// Always clear the browser cookie, even if it had already expired.
|
|
229
|
+
return handleLogout(req, res);
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
if (found) {
|
|
204
233
|
const body = found.route.body ? await readBody(req) : null;
|
|
205
|
-
return await found.route.run({ req, res, url, body, rest: found.rest, config });
|
|
234
|
+
return await found.route.run({ req, res, url, body, rest: found.rest, config, settings });
|
|
206
235
|
}
|
|
207
236
|
|
|
208
237
|
if (req.method === 'GET') return await serveStatic(res, url.pathname, config);
|
package/server/tunnel.js
CHANGED
|
@@ -50,6 +50,11 @@ export function tailscaleCommand(settings) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function installHint(name) {
|
|
53
|
+
if (name === 'caddy') {
|
|
54
|
+
return process.platform === 'darwin'
|
|
55
|
+
? 'brew install caddy'
|
|
56
|
+
: 'Install Caddy with: sudo apt install caddy';
|
|
57
|
+
}
|
|
53
58
|
if (name === 'cloudflared') {
|
|
54
59
|
return process.platform === 'darwin'
|
|
55
60
|
? 'brew install cloudflared (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)'
|