tinywebui 0.1.3 → 0.1.4
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 +3 -3
- package/bin/tinywebui.js +0 -10
- package/package.json +1 -1
- package/public/app.js +21 -1
- package/public/index.html +4 -0
- package/public/styles.css +1 -1
- package/src/access/policy.js +2 -4
- package/src/config/config.js +3 -0
- package/src/http.js +14 -0
- package/src/routes/auth.js +16 -5
- package/src/server.js +33 -8
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ It comes pre-loaded with two TinySuite MCP servers, so a new install can already
|
|
|
47
47
|
- **Hybrid local search** (BM25 plus a small on-device embedding model) over documents and past chats.
|
|
48
48
|
- **Long chats that stay affordable.** Cache-friendly prefixes, one-time compaction of large tool results, and per-round token statistics.
|
|
49
49
|
- **Automations.** Cron-scheduled prompts that run in a chat of your choice.
|
|
50
|
-
- **Private by default.**
|
|
50
|
+
- **Private by default.** Local starts sign in with a one-time link from the terminal, network binds need a password, and a team can use an SSO gateway with roles and an admin panel.
|
|
51
51
|
- **Managed from files.** Version-controlled config that hot-reloads, refuses typos and has a fingerprint you can check against a running instance.
|
|
52
52
|
- **Themes.** Fall Fairy and Cyber Grid are included. See [theme authoring](public/themes/README.md) to write your own.
|
|
53
53
|
|
|
@@ -59,7 +59,7 @@ Requires **Node.js 22.13** or later. The bundled TinySearch and TinyContext serv
|
|
|
59
59
|
npx tinywebui
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
There is no password to set up. The terminal prints a sign-in link (`http://127.0.0.1:7777/#token=…`); open it and the browser stays signed in. Only someone who can read that terminal gets in. Then connect a model. Put an endpoint and key in `tinywebui.config.json` (start from `example.tinywebui.config.json`) or use the Settings panel:
|
|
63
63
|
|
|
64
64
|
```json
|
|
65
65
|
{
|
|
@@ -69,7 +69,7 @@ The first run asks you to create an owner password of at least 15 characters. Op
|
|
|
69
69
|
}
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`tinywebui.config.json` holds your password hash
|
|
72
|
+
`tinywebui.config.json` holds your API keys (and password hash, if you set one) and is gitignored. Keep it that way. Run `npx tinywebui set-password` to sign in with a password instead of the link; you need one to listen on anything other than loopback.
|
|
73
73
|
|
|
74
74
|
`npx tinywebui` uses keyword search until you run `npx tinywebui models pull fast` (about 90 MB) to enable hybrid search.
|
|
75
75
|
|
package/bin/tinywebui.js
CHANGED
|
@@ -91,16 +91,6 @@ try {
|
|
|
91
91
|
console.error(`[tinywebui] unknown command "${args[0]}" (see --help)`);
|
|
92
92
|
process.exit(1);
|
|
93
93
|
}
|
|
94
|
-
const { createConfigSource } = await import('../src/config/config.js');
|
|
95
|
-
const initial = createConfigSource(opts).load({ persistSecret: false });
|
|
96
|
-
if (initial.authMode === 'single' && !initial.authPassword && !process.env.TINYWEBUI_PASSWORD) {
|
|
97
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
98
|
-
throw new Error('owner password required: run `tinywebui set-password` in a terminal, or set TINYWEBUI_PASSWORD before starting');
|
|
99
|
-
}
|
|
100
|
-
const { setPassword } = await import('../src/set-password.js');
|
|
101
|
-
console.log('[tinywebui] Set an owner password before the first start.');
|
|
102
|
-
await setPassword(opts);
|
|
103
|
-
}
|
|
104
94
|
const { start } = await import('../src/server.js');
|
|
105
95
|
// Command-line flags beat the file; the file beats the built-in defaults.
|
|
106
96
|
await start({
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -24,12 +24,32 @@ import { initAutomations, loadAutomations } from './panels/automations.js';
|
|
|
24
24
|
import { loadUsage } from './panels/usage.js';
|
|
25
25
|
import { initAdmin, loadAdmin } from './panels/admin.js';
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 'single' mode with no session: trade a launch link's token for a session,
|
|
29
|
+
* or show the sign-in screen and stop there.
|
|
30
|
+
*/
|
|
28
31
|
async function needsLogin() {
|
|
32
|
+
const token = new URLSearchParams(location.hash.slice(1)).get('token');
|
|
33
|
+
// Off the address bar first, so it isn't bookmarked or shared by accident.
|
|
34
|
+
if (token) history.replaceState(null, '', location.pathname + location.search);
|
|
29
35
|
const me = await loadAccess();
|
|
30
36
|
if (me.authMode !== 'single' || me.user) return false;
|
|
37
|
+
if (token && me.login === 'link') {
|
|
38
|
+
try {
|
|
39
|
+
await api.post('/api/auth/login', { token });
|
|
40
|
+
location.reload();
|
|
41
|
+
return true;
|
|
42
|
+
} catch { /* stale link: fall through to the sign-in screen */ }
|
|
43
|
+
}
|
|
31
44
|
const box = $('login');
|
|
32
45
|
box.hidden = false;
|
|
46
|
+
if (me.login === 'link') {
|
|
47
|
+
$('loginForm').hidden = true;
|
|
48
|
+
$('loginLink').hidden = false;
|
|
49
|
+
// The link pasted into this same tab only changes the hash: start over.
|
|
50
|
+
addEventListener('hashchange', () => location.reload());
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
33
53
|
$('loginPassword').focus();
|
|
34
54
|
$('loginForm').onsubmit = async (e) => {
|
|
35
55
|
e.preventDefault();
|
package/public/index.html
CHANGED
|
@@ -238,6 +238,10 @@
|
|
|
238
238
|
<button class="primary" type="submit">Sign in</button>
|
|
239
239
|
<div id="loginMsg" class="usage" role="alert"></div>
|
|
240
240
|
</form>
|
|
241
|
+
<div id="loginLink" class="login-card" hidden>
|
|
242
|
+
<h2>TinyWebUI</h2>
|
|
243
|
+
<p>Open the sign-in link printed in the terminal where TinyWebUI is running.</p>
|
|
244
|
+
</div>
|
|
241
245
|
</div>
|
|
242
246
|
|
|
243
247
|
<section id="admin" aria-label="Admin">
|
package/public/styles.css
CHANGED
|
@@ -295,7 +295,7 @@ main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height:
|
|
|
295
295
|
#login[hidden] { display:none; }
|
|
296
296
|
.login-card { width:min(22rem,100%); display:flex; flex-direction:column; gap:.6rem; padding:1.5rem; border:1px solid var(--line); border-radius:var(--r-md); background:var(--raise); }
|
|
297
297
|
.login-card h2 { margin:0 0 .5rem; }
|
|
298
|
-
#loginMsg:empty { display:none; }
|
|
298
|
+
#loginMsg:empty, .login-card[hidden] { display:none; }
|
|
299
299
|
.admin-users { display:flex; flex-direction:column; gap:.4rem; }
|
|
300
300
|
.admin-row { display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; padding:.5rem 0; border-top:1px solid var(--line); font-size:var(--fs-meta); }
|
|
301
301
|
.admin-row:first-child { border-top:0; }
|
package/src/access/policy.js
CHANGED
|
@@ -40,7 +40,7 @@ const DEFAULT_ROLES = {
|
|
|
40
40
|
export const FILE_ONLY = new Set([
|
|
41
41
|
'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
|
|
42
42
|
'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
|
|
43
|
-
'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'frozen', 'autoMigrate', 'retrieval',
|
|
43
|
+
'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'allowedHosts', 'frozen', 'autoMigrate', 'retrieval',
|
|
44
44
|
'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
|
|
45
45
|
]);
|
|
46
46
|
|
|
@@ -118,9 +118,7 @@ export function validateConfig(cfg, { env = process.env } = {}) {
|
|
|
118
118
|
if (mode === 'single') {
|
|
119
119
|
if (cfg.authPassword && !/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/i.test(String(cfg.authPassword))) {
|
|
120
120
|
errors.push('authPassword must be a hash: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD');
|
|
121
|
-
} else if (!cfg.authPassword &&
|
|
122
|
-
errors.push("authMode 'single' needs a password: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
|
|
123
|
-
} else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
|
|
121
|
+
} else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
|
|
124
122
|
errors.push(`TINYWEBUI_PASSWORD needs at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
125
123
|
}
|
|
126
124
|
}
|
package/src/config/config.js
CHANGED
|
@@ -179,6 +179,9 @@ export const DEFAULTS = {
|
|
|
179
179
|
// Extra origins allowed to send state-changing requests (e.g. an admin
|
|
180
180
|
// tool on another host). The page's own origin is always allowed.
|
|
181
181
|
allowedOrigins: [],
|
|
182
|
+
// Extra Host names answered when authMode is 'none' (e.g. a LAN name for a
|
|
183
|
+
// local-only install). Loopback names are always allowed.
|
|
184
|
+
allowedHosts: [],
|
|
182
185
|
// false: refuse to start on a database that needs migrating; run
|
|
183
186
|
// `tinywebui migrate` as its own deployment step instead.
|
|
184
187
|
autoMigrate: true,
|
package/src/http.js
CHANGED
|
@@ -75,6 +75,20 @@ export function crossSite(req, cfg) {
|
|
|
75
75
|
return req.headers['sec-fetch-site'] === 'cross-site';
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* DNS-rebinding guard. With no login, a hostile site can point its own name at
|
|
80
|
+
* 127.0.0.1 and pass the same-origin check above (Origin and Host both name it),
|
|
81
|
+
* so a no-login install only answers to loopback names and allowedHosts.
|
|
82
|
+
* Logged-in modes are safe: the attacker's origin has no session cookie.
|
|
83
|
+
*/
|
|
84
|
+
export function badHost(req, cfg) {
|
|
85
|
+
if (cfg.authMode !== 'none') return false;
|
|
86
|
+
let hostname;
|
|
87
|
+
try { hostname = new URL(`http://${req.headers.host}`).hostname.toLowerCase(); } catch { return true; }
|
|
88
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]') return false;
|
|
89
|
+
return !(cfg.allowedHosts || []).some((h) => h.toLowerCase() === hostname);
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
export async function serveStatic(req, res) {
|
|
79
93
|
// Resolve the index BEFORE normalising: on Windows normalize('/') returns a
|
|
80
94
|
// lone backslash, so a check for '/' after it never matches and the root
|
package/src/routes/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { json, readJson } from '../http.js';
|
|
2
3
|
import { isClosed, findEntry, labelFor } from '../config/models.js';
|
|
3
4
|
import { modelsFor, fingerprint, resolveAccess } from '../access/policy.js';
|
|
@@ -12,7 +13,12 @@ import { FEATURE } from '../../public/shared/features.js';
|
|
|
12
13
|
const LOGIN_MAX = 5;
|
|
13
14
|
const LOGIN_WINDOW_MS = 15 * 60_000;
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
const sameSecret = (given, expected) => {
|
|
17
|
+
const a = Buffer.from(String(given ?? '')), b = Buffer.from(expected);
|
|
18
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function authRoutes({ config, modelFor, multiUser, passwordHash, launchToken, source, store, version }) {
|
|
16
22
|
const loginFailures = new Map();
|
|
17
23
|
|
|
18
24
|
return [
|
|
@@ -22,6 +28,8 @@ export function authRoutes({ config, modelFor, multiUser, passwordHash, source,
|
|
|
22
28
|
const model = signedIn ? modelFor(auth.user?.id, auth.role) : null;
|
|
23
29
|
return json(res, 200, {
|
|
24
30
|
authMode: cfg.authMode,
|
|
31
|
+
// How 'single' signs in: a password, or the link printed at startup.
|
|
32
|
+
login: cfg.authMode === 'single' ? (passwordHash() ? 'password' : 'link') : null,
|
|
25
33
|
logoutUrl: cfg.logoutUrl || '',
|
|
26
34
|
isAdmin: Boolean(auth.isAdmin),
|
|
27
35
|
// What this person may personalise, and their saved instructions (multi-user only).
|
|
@@ -50,11 +58,14 @@ export function authRoutes({ config, modelFor, multiUser, passwordHash, source,
|
|
|
50
58
|
if (entry && entry.count >= LOGIN_MAX) {
|
|
51
59
|
return json(res, 429, { error: 'too many attempts, try again later' });
|
|
52
60
|
}
|
|
53
|
-
const { password } = await readJson(req);
|
|
54
|
-
|
|
61
|
+
const { password, token: given } = await readJson(req);
|
|
62
|
+
const hash = passwordHash();
|
|
63
|
+
// The launch link only stands in for a password that doesn't exist.
|
|
64
|
+
const ok = hash ? verifyPassword(password, hash) : Boolean(launchToken()) && sameSecret(given, launchToken());
|
|
65
|
+
if (!ok) {
|
|
55
66
|
loginFailures.set(who, { first: entry?.first ?? now, count: (entry?.count ?? 0) + 1 });
|
|
56
|
-
audit('auth.rejected', { reason: 'bad_password' });
|
|
57
|
-
return json(res, 401, { error: 'wrong password' });
|
|
67
|
+
audit('auth.rejected', { reason: hash ? 'bad_password' : 'bad_launch_token' });
|
|
68
|
+
return json(res, 401, { error: hash ? 'wrong password' : 'open the sign-in link printed in the terminal' });
|
|
58
69
|
}
|
|
59
70
|
loginFailures.delete(who);
|
|
60
71
|
const token = createSession(store, OWNER_ID, cfg.sessionTtlDays);
|
package/src/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { watch, existsSync } from 'node:fs';
|
|
4
5
|
import { dirname } from 'node:path';
|
|
@@ -12,7 +13,7 @@ import { hashPassword, applyAccessPolicy } from './access/auth.js';
|
|
|
12
13
|
import { audit } from './audit.js';
|
|
13
14
|
import { logger } from './log.js';
|
|
14
15
|
import { resolveAuth, OWNER_ID } from './access/auth_gate.js';
|
|
15
|
-
import { json, HttpError, SECURITY_HEADERS, crossSite, serveStatic } from './http.js';
|
|
16
|
+
import { json, HttpError, SECURITY_HEADERS, crossSite, badHost, serveStatic } from './http.js';
|
|
16
17
|
import { expandToolDef, callExpand } from './tools/context_tool.js';
|
|
17
18
|
import { documentToolDef, callReadDocument } from './tools/document_tool.js';
|
|
18
19
|
import { askToolDef, callAskUser } from './tools/ask_tool.js';
|
|
@@ -53,7 +54,7 @@ function routeTable(app) {
|
|
|
53
54
|
const deps = {
|
|
54
55
|
store: app.store, runs: app.runs, scheduler: app.scheduler, retrieval: app.retrieval,
|
|
55
56
|
source: app.source, version: app.version,
|
|
56
|
-
config: () => app.cfg, hub: () => app.hub, passwordHash: () => app.passwordHash,
|
|
57
|
+
config: () => app.cfg, hub: () => app.hub, passwordHash: () => app.passwordHash, launchToken: () => app.launchToken,
|
|
57
58
|
saveConfig: (patch, by) => app.saveConfig(patch, by), refreshConfig: () => app.refreshConfig(),
|
|
58
59
|
auditMcp: (by, before) => app.auditMcp(by, before), swapHub: (servers) => app.swapHub(servers),
|
|
59
60
|
multiUser: () => app.multiUser(), modelFor: (userId, role) => app.modelFor(userId, role),
|
|
@@ -73,6 +74,12 @@ export const API_ROUTES = routeTable({}).map((r) => [r.method, r.path, r.feature
|
|
|
73
74
|
// trust boundary sits.
|
|
74
75
|
const ADMIN_ONLY_FIELDS = /^(apiKey|keyHint|connectors|authPassword|sessionSecret|google|baseUrl|trusted|adminEmails|models$)/;
|
|
75
76
|
|
|
77
|
+
const LOOPBACK_BINDS = ['127.0.0.1', '::1', 'localhost'];
|
|
78
|
+
|
|
79
|
+
// 'single': the stored hash, or one made in memory from $TINYWEBUI_PASSWORD
|
|
80
|
+
// (handy for containers); null means sign-in is by launch link only.
|
|
81
|
+
const ownerHash = (cfg) => cfg.authPassword || (process.env.TINYWEBUI_PASSWORD ? hashPassword(process.env.TINYWEBUI_PASSWORD) : null);
|
|
82
|
+
|
|
76
83
|
/**
|
|
77
84
|
* Starts an instance. Everything but port/host is optional and passed to
|
|
78
85
|
* createConfigSource: `config` (keys set here win and are locked in the UI),
|
|
@@ -83,9 +90,13 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
83
90
|
const initial = source.load();
|
|
84
91
|
const problems = configProblems(initial);
|
|
85
92
|
if (problems.length) throw new Error(problems.join('\n'));
|
|
86
|
-
|
|
93
|
+
const loopback = LOOPBACK_BINDS.includes(host);
|
|
94
|
+
if (initial.authMode === 'none' && !loopback) {
|
|
87
95
|
throw new Error("authMode 'none' is limited to loopback; use password login or a trusted-header gateway for network access");
|
|
88
96
|
}
|
|
97
|
+
if (initial.authMode === 'single' && !ownerHash(initial) && !loopback) {
|
|
98
|
+
throw new Error("authMode 'single' needs a password on a network bind: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
|
|
99
|
+
}
|
|
89
100
|
|
|
90
101
|
const store = new Store(source.dbPath(initial), { migrate: initial.autoMigrate !== false });
|
|
91
102
|
// Runs live in this process, so nothing from before it can still be waiting.
|
|
@@ -121,9 +132,12 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
121
132
|
retrieval: new Retrieval(store, { ...initial.retrieval, mode: resolved.mode }, embedder),
|
|
122
133
|
cfg: initial,
|
|
123
134
|
hub: null,
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
135
|
+
passwordHash: initial.authMode === 'single' ? ownerHash(initial) : null,
|
|
136
|
+
// A passwordless loopback install signs in with this instead: printed as a
|
|
137
|
+
// link at startup, so only someone who can read this terminal gets in.
|
|
138
|
+
// Made whenever it could be needed, so a reload that drops the password
|
|
139
|
+
// still leaves a way in.
|
|
140
|
+
launchToken: initial.authMode === 'single' && loopback ? randomBytes(32).toString('hex') : null,
|
|
127
141
|
|
|
128
142
|
// Every UI/API change lands in config.json (source.save) and is audited:
|
|
129
143
|
// who, which keys, the new values (none of these keys are secrets), and the
|
|
@@ -321,6 +335,10 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
321
335
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
|
|
322
336
|
const url = new URL(req.url || '/', 'http://x');
|
|
323
337
|
const { pathname } = url;
|
|
338
|
+
if (badHost(req, app.cfg)) {
|
|
339
|
+
audit('request.bad_host_rejected', { host: String(req.headers.host).slice(0, 100), path: pathname });
|
|
340
|
+
return json(res, 403, { error: 'unknown host' });
|
|
341
|
+
}
|
|
324
342
|
if (crossSite(req, app.cfg)) {
|
|
325
343
|
audit('request.cross_site_rejected', { method: req.method, path: pathname });
|
|
326
344
|
return json(res, 403, { error: 'cross-site request refused' });
|
|
@@ -358,7 +376,10 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
358
376
|
|
|
359
377
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
360
378
|
scheduler.arm();
|
|
361
|
-
|
|
379
|
+
const origin = `http://${host.includes(':') ? `[${host}]` : host}:${server.address().port}`;
|
|
380
|
+
// In the fragment, so it never reaches a server log or a Referer header.
|
|
381
|
+
app.launchUrl = app.launchToken ? `${origin}/#token=${app.launchToken}` : origin;
|
|
382
|
+
log.info(app.launchToken && !app.passwordHash ? `sign in: ${app.launchUrl}` : origin);
|
|
362
383
|
// Anything stored before dense retrieval was on (or under another model)
|
|
363
384
|
// is embedded in the background; document queries fill in on demand too.
|
|
364
385
|
retrieval.backfill((msg) => log.info(`retrieval: ${msg}`))
|
|
@@ -399,7 +420,10 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
399
420
|
const mcpChanged = text !== mcpText;
|
|
400
421
|
if (before === after && !mcpChanged) return true; // our own write, or a no-op save
|
|
401
422
|
app.cfg = next;
|
|
402
|
-
if (next.authMode === 'single')
|
|
423
|
+
if (next.authMode === 'single') {
|
|
424
|
+
app.passwordHash = ownerHash(next);
|
|
425
|
+
if (!app.passwordHash) log.info(`sign in: ${app.launchUrl}`);
|
|
426
|
+
}
|
|
403
427
|
if (next.authMode === 'trusted-header') applyAccessPolicy(store, next);
|
|
404
428
|
if (mcpChanged) await app.swapHub(servers);
|
|
405
429
|
scheduler.arm();
|
|
@@ -432,6 +456,7 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
432
456
|
const onHup = () => scheduleReload('SIGHUP');
|
|
433
457
|
process.on('SIGHUP', onHup);
|
|
434
458
|
server.reload = reloadFromFiles;
|
|
459
|
+
server.launchUrl = app.launchUrl;
|
|
435
460
|
|
|
436
461
|
// Real teardown, reused two ways: on SIGINT/SIGTERM it exits the process, and
|
|
437
462
|
// as `server.shutdown()` it does not -- which is what a test harness needs.
|