ollanet 0.6.24 → 0.7.0
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 +5 -4
- package/dist/argv.d.ts +18 -0
- package/dist/argv.js +35 -0
- package/dist/bench.js +8 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +65 -26
- package/dist/compare.js +35 -15
- package/dist/funnel.d.ts +70 -0
- package/dist/funnel.js +195 -0
- package/dist/help.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/mcp.js +6 -4
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.js +41 -6
- package/dist/pull.js +6 -4
- package/dist/scan.d.ts +20 -0
- package/dist/scan.js +98 -20
- package/dist/serve-store.d.ts +52 -0
- package/dist/serve-store.js +215 -0
- package/dist/serve.d.ts +108 -0
- package/dist/serve.js +840 -0
- package/package.json +1 -1
package/dist/serve.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only catalog server for cloud agents.
|
|
3
|
+
*
|
|
4
|
+
* ollanet serve [--listen 127.0.0.1:11500] [--refresh 10m]
|
|
5
|
+
* ollanet serve token add <name> [--expires 30d]
|
|
6
|
+
*
|
|
7
|
+
* Serves `GET /v1/catalog` (bearer token) and `GET /healthz`. The catalog lists
|
|
8
|
+
* models grouped by an opaque host label: no hostnames, IPs, ports, OS, or
|
|
9
|
+
* paths. It never proxies prompts, compares, pulls, deletes, or ps.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import { isIP } from "node:net";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { failUsage, isHelpFlag, isQuiet, printHelp, takeFlag } from "./argv.js";
|
|
16
|
+
import { loadConfig } from "./config.js";
|
|
17
|
+
import { ollamaShow } from "./ollama-chat.js";
|
|
18
|
+
import { FUNNEL_PORTS, FunnelError, startFunnel } from "./funnel.js";
|
|
19
|
+
import { scanNetwork } from "./scan.js";
|
|
20
|
+
import { loadLastScan } from "./scan-store.js";
|
|
21
|
+
import { addToken, isExpired, loadServeStore, matchToken, parseDuration, recordLastUsed, removeLabel, removeToken, removeTokens, lastActivity, serveStorePath, setLabel, } from "./serve-store.js";
|
|
22
|
+
const DEFAULT_LISTEN = "127.0.0.1:11500";
|
|
23
|
+
const DEFAULT_REFRESH = "10m";
|
|
24
|
+
const MIN_REFRESH_MS = 30_000;
|
|
25
|
+
const DEFAULT_RATE = 60;
|
|
26
|
+
export function helpText() {
|
|
27
|
+
return `Usage:
|
|
28
|
+
ollanet serve [--listen <host:port>] [--refresh <interval|off>] [--rate <n>]
|
|
29
|
+
ollanet serve --funnel [--funnel-port 443|8443|10000] [--keep-funnel]
|
|
30
|
+
ollanet serve status [--json]
|
|
31
|
+
ollanet serve token add <name> [--expires <duration>]
|
|
32
|
+
ollanet serve token list [--json]
|
|
33
|
+
ollanet serve token rm [name] (no name: pick from a list in a terminal)
|
|
34
|
+
ollanet serve token prune [--expired] [--unused-days <n>] [--yes]
|
|
35
|
+
(no flags: expired, or unused 30+ days)
|
|
36
|
+
ollanet serve label set <machine> <label>
|
|
37
|
+
ollanet serve label rm <machine>
|
|
38
|
+
ollanet serve label list
|
|
39
|
+
|
|
40
|
+
Read-only catalog for agents that are not on your network (for example through
|
|
41
|
+
Tailscale Funnel). It lists which models exist, grouped by an opaque host label
|
|
42
|
+
(a serve label, else an alias name, else host-1, host-2, …). It never returns
|
|
43
|
+
hostnames, IPs, ports, OS, or paths, and never runs prompts, compares, pulls,
|
|
44
|
+
deletes, or ps.
|
|
45
|
+
|
|
46
|
+
Endpoints:
|
|
47
|
+
GET /healthz "ok" (no auth)
|
|
48
|
+
GET /v1/catalog Catalog JSON (Authorization: Bearer <token>)
|
|
49
|
+
|
|
50
|
+
Tokens are printed once; only their SHA-256 hashes are saved in
|
|
51
|
+
${serveStorePath()}
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
--listen <host:port> Bind address (default ${DEFAULT_LISTEN})
|
|
55
|
+
--allow-remote Allow a non-loopback bind (default: refuse)
|
|
56
|
+
--refresh <interval> Re-scan every interval, e.g. 10m, 1h, or off
|
|
57
|
+
(default ${DEFAULT_REFRESH}; starts from the last scan)
|
|
58
|
+
--rate <n> Requests per minute per token (default ${DEFAULT_RATE})
|
|
59
|
+
--expires <duration> Token lifetime for token add, e.g. 30d, 12h
|
|
60
|
+
--funnel Publish through Tailscale Funnel (needs a token, HTTPS
|
|
61
|
+
certificates, and the funnel node attribute); turned
|
|
62
|
+
off again when serve exits
|
|
63
|
+
--funnel-port <n> Public HTTPS port: 443 (default), 8443, or 10000
|
|
64
|
+
--keep-funnel Leave the Funnel up after serve exits
|
|
65
|
+
-q, --quiet No startup or access log lines on stderr`;
|
|
66
|
+
}
|
|
67
|
+
function usage() {
|
|
68
|
+
failUsage(helpText());
|
|
69
|
+
}
|
|
70
|
+
function serverKeys(server) {
|
|
71
|
+
const dns = (server.dnsName ?? "").replace(/\.$/, "").toLowerCase();
|
|
72
|
+
const keys = [server.hostname, dns, dns.split(".")[0], server.ip, ...(server.also ?? [])]
|
|
73
|
+
.filter((k) => typeof k === "string" && k.length > 0)
|
|
74
|
+
.map((k) => k.toLowerCase());
|
|
75
|
+
return new Set(keys);
|
|
76
|
+
}
|
|
77
|
+
/** Capabilities cache key (internal only; never serialized). */
|
|
78
|
+
export function capsKey(server, model) {
|
|
79
|
+
return `${server.endpoint}\n${model}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Pick an opaque label per server: serve label, else the first alias name
|
|
83
|
+
* (sorted) pointing at it, else host-N. Labels are unique.
|
|
84
|
+
*/
|
|
85
|
+
export function hostLabels(servers, aliases, labels) {
|
|
86
|
+
const used = new Set();
|
|
87
|
+
const aliasNames = Object.keys(aliases).sort();
|
|
88
|
+
let n = 0;
|
|
89
|
+
return servers.map((server) => {
|
|
90
|
+
const keys = serverKeys(server);
|
|
91
|
+
let label = Object.entries(labels).find(([machine]) => keys.has(machine.toLowerCase()))?.[1] ??
|
|
92
|
+
aliasNames.find((name) => keys.has((aliases[name]?.machine ?? "").toLowerCase()));
|
|
93
|
+
if (!label) {
|
|
94
|
+
do {
|
|
95
|
+
n += 1;
|
|
96
|
+
label = `host-${n}`;
|
|
97
|
+
} while (used.has(label));
|
|
98
|
+
}
|
|
99
|
+
let unique = label;
|
|
100
|
+
for (let i = 2; used.has(unique); i += 1)
|
|
101
|
+
unique = `${label}-${i}`;
|
|
102
|
+
used.add(unique);
|
|
103
|
+
return unique;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build the sanitized catalog. Only allow-listed fields are copied, so new
|
|
108
|
+
* scan fields cannot leak by accident.
|
|
109
|
+
*/
|
|
110
|
+
export function buildCatalog(input) {
|
|
111
|
+
const labels = hostLabels(input.servers, input.aliases, input.labels);
|
|
112
|
+
const hosts = input.servers.map((server, i) => ({
|
|
113
|
+
label: labels[i],
|
|
114
|
+
models: server.models.map((m) => {
|
|
115
|
+
const caps = input.capabilities?.get(capsKey(server, m.name));
|
|
116
|
+
return {
|
|
117
|
+
name: m.name,
|
|
118
|
+
...(typeof m.size === "number" ? { size: m.size } : {}),
|
|
119
|
+
...(m.parameter_size ? { parameter_size: m.parameter_size } : {}),
|
|
120
|
+
...(m.quantization_level ? { quantization_level: m.quantization_level } : {}),
|
|
121
|
+
...(m.family ? { family: m.family } : {}),
|
|
122
|
+
...(caps ? { capabilities: [...caps] } : {}),
|
|
123
|
+
tuned: Boolean(m.tuned),
|
|
124
|
+
};
|
|
125
|
+
}),
|
|
126
|
+
}));
|
|
127
|
+
const aliases = Object.keys(input.aliases)
|
|
128
|
+
.sort()
|
|
129
|
+
.map((name) => {
|
|
130
|
+
const alias = input.aliases[name];
|
|
131
|
+
const idx = input.servers.findIndex((s) => serverKeys(s).has(alias.machine.toLowerCase()));
|
|
132
|
+
return { name, host: idx >= 0 ? labels[idx] : null, model: alias.model };
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
generated_at: new Date().toISOString(),
|
|
136
|
+
scanned_at: input.scannedAt,
|
|
137
|
+
hosts,
|
|
138
|
+
aliases,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function fetchAllCapabilities(servers) {
|
|
142
|
+
const caps = new Map();
|
|
143
|
+
await Promise.all(servers.map(async (server) => {
|
|
144
|
+
for (const model of server.models) {
|
|
145
|
+
try {
|
|
146
|
+
const shown = await ollamaShow(server.endpoint, model.name, 5000);
|
|
147
|
+
if (shown && Array.isArray(shown.capabilities)) {
|
|
148
|
+
caps.set(capsKey(server, model.name), shown.capabilities.filter((c) => typeof c === "string"));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Capabilities are optional in the catalog.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}));
|
|
156
|
+
return caps;
|
|
157
|
+
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Server
|
|
160
|
+
export function isLoopbackHost(host) {
|
|
161
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
162
|
+
if (h === "localhost")
|
|
163
|
+
return true;
|
|
164
|
+
if (isIP(h) === 4)
|
|
165
|
+
return h.startsWith("127.");
|
|
166
|
+
if (isIP(h) === 6)
|
|
167
|
+
return h === "::1" || h === "0:0:0:0:0:0:0:1";
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
export function parseListen(raw) {
|
|
171
|
+
const m = /^(?:\[([^\]]+)\]|([^:]+)):(\d{1,5})$/.exec(raw.trim());
|
|
172
|
+
if (!m)
|
|
173
|
+
throw new Error(`--listen must be host:port (got "${raw}")`);
|
|
174
|
+
const host = (m[1] ?? m[2]);
|
|
175
|
+
const port = Number(m[3]);
|
|
176
|
+
if (port > 65535)
|
|
177
|
+
throw new Error(`--listen port out of range: ${port}`);
|
|
178
|
+
return { host, port };
|
|
179
|
+
}
|
|
180
|
+
const SECURITY_HEADERS = {
|
|
181
|
+
"Cache-Control": "no-store",
|
|
182
|
+
"X-Content-Type-Options": "nosniff",
|
|
183
|
+
};
|
|
184
|
+
function sendJson(res, status, body, extra = {}) {
|
|
185
|
+
const text = `${JSON.stringify(body, null, 2)}\n`;
|
|
186
|
+
res.writeHead(status, {
|
|
187
|
+
...SECURITY_HEADERS,
|
|
188
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
189
|
+
"Content-Length": Buffer.byteLength(text),
|
|
190
|
+
...extra,
|
|
191
|
+
});
|
|
192
|
+
res.end(text);
|
|
193
|
+
}
|
|
194
|
+
export async function startServe(opts) {
|
|
195
|
+
const log = opts.log ?? (() => { });
|
|
196
|
+
let current = buildCatalog({ servers: [], scannedAt: null, aliases: {}, labels: {} });
|
|
197
|
+
let refreshing = null;
|
|
198
|
+
async function rebuild(rescan) {
|
|
199
|
+
let servers = [];
|
|
200
|
+
let scannedAt = null;
|
|
201
|
+
const last = rescan ? null : await loadLastScan();
|
|
202
|
+
if (last) {
|
|
203
|
+
servers = last.servers;
|
|
204
|
+
scannedAt = last.scanned_at ?? null;
|
|
205
|
+
}
|
|
206
|
+
else if (rescan || opts.refreshMs > 0) {
|
|
207
|
+
const payload = await scanNetwork();
|
|
208
|
+
servers = payload.servers;
|
|
209
|
+
scannedAt = new Date().toISOString();
|
|
210
|
+
}
|
|
211
|
+
const [config, store, capabilities] = await Promise.all([
|
|
212
|
+
loadConfig(),
|
|
213
|
+
loadServeStore(),
|
|
214
|
+
fetchAllCapabilities(servers),
|
|
215
|
+
]);
|
|
216
|
+
current = buildCatalog({ servers, scannedAt, aliases: config.aliases, labels: store.labels, capabilities });
|
|
217
|
+
}
|
|
218
|
+
function refresh(rescan) {
|
|
219
|
+
refreshing ??= rebuild(rescan).finally(() => {
|
|
220
|
+
refreshing = null;
|
|
221
|
+
});
|
|
222
|
+
return refreshing;
|
|
223
|
+
}
|
|
224
|
+
await refresh(false);
|
|
225
|
+
const windows = new Map();
|
|
226
|
+
const lastUsed = lastUsedTracker(opts.lastUsedFlushMs ?? 60_000, log);
|
|
227
|
+
async function authenticate(req) {
|
|
228
|
+
const header = req.headers.authorization ?? "";
|
|
229
|
+
const m = /^Bearer\s+(\S{1,200})$/i.exec(header);
|
|
230
|
+
if (!m)
|
|
231
|
+
return undefined;
|
|
232
|
+
const store = await loadServeStore();
|
|
233
|
+
const token = matchToken(store.tokens, m[1]);
|
|
234
|
+
if (token)
|
|
235
|
+
lastUsed.touch(token.sha256);
|
|
236
|
+
return token?.name;
|
|
237
|
+
}
|
|
238
|
+
function rateLimited(name) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const w = windows.get(name);
|
|
241
|
+
if (!w || now - w.start >= 60_000) {
|
|
242
|
+
windows.set(name, { start: now, count: 1 });
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
w.count += 1;
|
|
246
|
+
return w.count > opts.rate ? Math.ceil((w.start + 60_000 - now) / 1000) : 0;
|
|
247
|
+
}
|
|
248
|
+
const server = http.createServer((req, res) => {
|
|
249
|
+
const method = req.method ?? "GET";
|
|
250
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
251
|
+
let who = "-";
|
|
252
|
+
res.on("finish", () => log(`${new Date().toISOString()} ${who} ${method} ${pathname} ${res.statusCode}`));
|
|
253
|
+
// Never read request bodies.
|
|
254
|
+
req.resume();
|
|
255
|
+
void (async () => {
|
|
256
|
+
if (pathname === "/healthz") {
|
|
257
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
258
|
+
sendJson(res, 405, { error: "method not allowed" }, { Allow: "GET, HEAD" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
res.writeHead(200, { ...SECURITY_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
|
|
262
|
+
res.end(method === "HEAD" ? undefined : "ok\n");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (pathname === "/v1" || pathname.startsWith("/v1/")) {
|
|
266
|
+
const name = await authenticate(req);
|
|
267
|
+
if (!name) {
|
|
268
|
+
sendJson(res, 401, { error: "unauthorized" }, { "WWW-Authenticate": 'Bearer realm="ollanet"' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
who = name;
|
|
272
|
+
const retry = rateLimited(name);
|
|
273
|
+
if (retry > 0) {
|
|
274
|
+
sendJson(res, 429, { error: "rate limited" }, { "Retry-After": String(retry) });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (pathname !== "/v1/catalog") {
|
|
278
|
+
sendJson(res, 404, { error: "not found" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (method !== "GET") {
|
|
282
|
+
sendJson(res, 405, { error: "method not allowed" }, { Allow: "GET" });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
sendJson(res, 200, current);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
sendJson(res, 404, { error: "not found" });
|
|
289
|
+
})().catch(() => {
|
|
290
|
+
if (!res.headersSent)
|
|
291
|
+
sendJson(res, 500, { error: "internal error" });
|
|
292
|
+
else
|
|
293
|
+
res.destroy();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
server.headersTimeout = 10_000;
|
|
297
|
+
server.requestTimeout = 15_000;
|
|
298
|
+
await new Promise((resolve, reject) => {
|
|
299
|
+
server.once("error", reject);
|
|
300
|
+
server.listen(opts.port, opts.host, () => {
|
|
301
|
+
server.off("error", reject);
|
|
302
|
+
resolve();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
const timer = opts.refreshMs > 0
|
|
306
|
+
? setInterval(() => {
|
|
307
|
+
refresh(true).catch((err) => log(`refresh failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
308
|
+
}, opts.refreshMs)
|
|
309
|
+
: null;
|
|
310
|
+
const addr = server.address();
|
|
311
|
+
const port = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
312
|
+
const hostPart = opts.host.includes(":") ? `[${opts.host}]` : opts.host;
|
|
313
|
+
return {
|
|
314
|
+
url: `http://${hostPart}:${port}`,
|
|
315
|
+
port,
|
|
316
|
+
catalog: () => current,
|
|
317
|
+
refresh,
|
|
318
|
+
close: async () => {
|
|
319
|
+
if (timer)
|
|
320
|
+
clearInterval(timer);
|
|
321
|
+
await new Promise((resolve) => {
|
|
322
|
+
server.close(() => resolve());
|
|
323
|
+
server.closeAllConnections();
|
|
324
|
+
});
|
|
325
|
+
await lastUsed.flush();
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Debounced last-use writer: the first use of a token writes right away, later
|
|
331
|
+
* uses at most once per `intervalMs` per token, and close() flushes the rest.
|
|
332
|
+
* Writes are serialized; failures are logged, never fatal.
|
|
333
|
+
*/
|
|
334
|
+
function lastUsedTracker(intervalMs, log) {
|
|
335
|
+
const pending = new Map();
|
|
336
|
+
const written = new Map();
|
|
337
|
+
let timer = null;
|
|
338
|
+
let chain = Promise.resolve();
|
|
339
|
+
function flush() {
|
|
340
|
+
if (timer) {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
timer = null;
|
|
343
|
+
}
|
|
344
|
+
if (pending.size > 0) {
|
|
345
|
+
const batch = new Map(pending);
|
|
346
|
+
pending.clear();
|
|
347
|
+
const now = Date.now();
|
|
348
|
+
for (const sha of batch.keys())
|
|
349
|
+
written.set(sha, now);
|
|
350
|
+
chain = chain
|
|
351
|
+
.then(() => recordLastUsed(batch))
|
|
352
|
+
.catch((err) => log(`last-used write failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
353
|
+
}
|
|
354
|
+
return chain;
|
|
355
|
+
}
|
|
356
|
+
function touch(sha) {
|
|
357
|
+
const now = Date.now();
|
|
358
|
+
pending.set(sha, new Date(now).toISOString());
|
|
359
|
+
const wait = (written.get(sha) ?? -Infinity) + intervalMs - now;
|
|
360
|
+
if (wait <= 0) {
|
|
361
|
+
void flush();
|
|
362
|
+
}
|
|
363
|
+
else if (!timer) {
|
|
364
|
+
timer = setTimeout(() => void flush(), wait);
|
|
365
|
+
timer.unref();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { touch, flush };
|
|
369
|
+
}
|
|
370
|
+
export function serveStatePath() {
|
|
371
|
+
return path.join(path.dirname(serveStorePath()), "serve-state.json");
|
|
372
|
+
}
|
|
373
|
+
function pidAlive(pid) {
|
|
374
|
+
try {
|
|
375
|
+
process.kill(pid, 0);
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
return err.code === "EPERM";
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function statusCommand(args) {
|
|
383
|
+
const json = args.includes("--json");
|
|
384
|
+
let state = null;
|
|
385
|
+
try {
|
|
386
|
+
state = JSON.parse(await readFile(serveStatePath(), "utf8"));
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
state = null;
|
|
390
|
+
}
|
|
391
|
+
const running = !!state && Number.isInteger(state.pid) && pidAlive(state.pid);
|
|
392
|
+
if (json) {
|
|
393
|
+
process.stdout.write(`${JSON.stringify(running ? { running, ...state } : { running: false }, null, 2)}\n`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!running || !state) {
|
|
397
|
+
process.stdout.write("ollanet serve is not running.\n");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
process.stdout.write(`ollanet serve running (pid ${state.pid}, since ${state.started_at})\n`);
|
|
401
|
+
process.stdout.write(` local: ${state.url}\n`);
|
|
402
|
+
process.stdout.write(state.funnel_url
|
|
403
|
+
? ` funnel: ${state.funnel_url}\n catalog: ${state.funnel_url}v1/catalog\n`
|
|
404
|
+
: " funnel: off\n");
|
|
405
|
+
}
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// CLI
|
|
408
|
+
async function tokenCommand(args) {
|
|
409
|
+
const sub = args.shift();
|
|
410
|
+
if (sub === "add") {
|
|
411
|
+
let expiresMs;
|
|
412
|
+
const positional = [];
|
|
413
|
+
while (args.length > 0) {
|
|
414
|
+
const arg = args.shift();
|
|
415
|
+
const expires = takeFlag(arg, "--expires", args, usage);
|
|
416
|
+
if (expires !== undefined) {
|
|
417
|
+
expiresMs = parseDuration(expires);
|
|
418
|
+
if (!expiresMs) {
|
|
419
|
+
console.error(`--expires must be a duration like 30d, 12h, or 90m (got "${expires}")`);
|
|
420
|
+
usage();
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (arg.startsWith("-")) {
|
|
425
|
+
console.error(`Unknown flag: ${arg}`);
|
|
426
|
+
usage();
|
|
427
|
+
}
|
|
428
|
+
positional.push(arg);
|
|
429
|
+
}
|
|
430
|
+
if (positional.length !== 1)
|
|
431
|
+
usage();
|
|
432
|
+
const { token, record, file } = await addToken(positional[0], expiresMs);
|
|
433
|
+
process.stdout.write(`${token}\n`);
|
|
434
|
+
if (!isQuiet()) {
|
|
435
|
+
console.error(`Token "${record.name}" created${record.expires_at ? `, expires ${record.expires_at}` : ""}. ` +
|
|
436
|
+
`It is shown only once; ${file} keeps just its SHA-256 hash.`);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (sub === "list" || sub === "ls") {
|
|
441
|
+
const json = args.includes("--json");
|
|
442
|
+
const { tokens } = await loadServeStore();
|
|
443
|
+
const rows = tokens.map((t) => ({
|
|
444
|
+
name: t.name,
|
|
445
|
+
created_at: t.created_at,
|
|
446
|
+
expires_at: t.expires_at,
|
|
447
|
+
expired: isExpired(t),
|
|
448
|
+
last_used: t.last_used ?? null,
|
|
449
|
+
}));
|
|
450
|
+
if (json) {
|
|
451
|
+
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (rows.length === 0) {
|
|
455
|
+
process.stdout.write("No serve tokens. Add one with: ollanet serve token add <name>\n");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
process.stdout.write(tokenTable(tokens));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (sub === "prune")
|
|
462
|
+
return pruneCommand(args);
|
|
463
|
+
if (sub === "rm" || sub === "remove" || sub === "delete") {
|
|
464
|
+
if (args.length === 0)
|
|
465
|
+
return interactiveRemove();
|
|
466
|
+
if (args.length !== 1)
|
|
467
|
+
usage();
|
|
468
|
+
if (!(await removeToken(args[0]))) {
|
|
469
|
+
console.error(`No serve token named "${args[0]}".`);
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
process.stdout.write(`Removed token ${args[0]}\n`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
console.error(`Unknown token subcommand: ${sub ?? "(none)"}`);
|
|
477
|
+
usage();
|
|
478
|
+
}
|
|
479
|
+
/** "never", "just now", "5m ago", "3h ago", "12d ago". */
|
|
480
|
+
export function relativeTime(iso, now = Date.now()) {
|
|
481
|
+
if (!iso)
|
|
482
|
+
return "never";
|
|
483
|
+
const t = Date.parse(iso);
|
|
484
|
+
if (!Number.isFinite(t))
|
|
485
|
+
return "never";
|
|
486
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
487
|
+
if (s < 60)
|
|
488
|
+
return "just now";
|
|
489
|
+
if (s < 3600)
|
|
490
|
+
return `${Math.floor(s / 60)}m ago`;
|
|
491
|
+
if (s < 48 * 3600)
|
|
492
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
493
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
494
|
+
}
|
|
495
|
+
function shortTime(iso) {
|
|
496
|
+
return `${iso.slice(0, 16).replace("T", " ")}Z`;
|
|
497
|
+
}
|
|
498
|
+
/** Aligned token table: NAME, CREATED, EXPIRES, LAST USED (optionally numbered). */
|
|
499
|
+
export function tokenTable(tokens, opts = {}) {
|
|
500
|
+
const now = opts.now ?? Date.now();
|
|
501
|
+
const header = ["NAME", "CREATED", "EXPIRES", "LAST USED"];
|
|
502
|
+
const rows = tokens.map((t) => [
|
|
503
|
+
t.name,
|
|
504
|
+
shortTime(t.created_at),
|
|
505
|
+
t.expires_at ? `${isExpired(t, now) ? "EXPIRED " : ""}${shortTime(t.expires_at)}` : "never",
|
|
506
|
+
relativeTime(t.last_used, now),
|
|
507
|
+
]);
|
|
508
|
+
const all = [header, ...rows].map((r, i) => opts.numbered ? [i === 0 ? "#" : String(i), ...r] : r);
|
|
509
|
+
const widths = all[0].map((_, c) => Math.max(...all.map((r) => r[c].length)));
|
|
510
|
+
return all.map((r) => `${r.map((cell, c) => cell.padEnd(widths[c])).join(" ").trimEnd()}\n`).join("");
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Parse a pick like "1,3", "2-4", "1 3", or "all" against a list of `count`.
|
|
514
|
+
* Returns 0-based indices ([] for an empty answer = cancel), or an error string.
|
|
515
|
+
*/
|
|
516
|
+
export function parseSelection(input, count) {
|
|
517
|
+
const text = input.trim().toLowerCase();
|
|
518
|
+
if (!text)
|
|
519
|
+
return [];
|
|
520
|
+
if (text === "all" || text === "*")
|
|
521
|
+
return Array.from({ length: count }, (_, i) => i);
|
|
522
|
+
const picked = new Set();
|
|
523
|
+
for (const part of text.split(/[\s,]+/).filter(Boolean)) {
|
|
524
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
|
|
525
|
+
if (!m)
|
|
526
|
+
return `Not a number or range: "${part}"`;
|
|
527
|
+
const a = Number(m[1]);
|
|
528
|
+
const b = m[2] ? Number(m[2]) : a;
|
|
529
|
+
if (a < 1 || b > count || a > b)
|
|
530
|
+
return `Out of range: "${part}" (1–${count})`;
|
|
531
|
+
for (let i = a; i <= b; i += 1)
|
|
532
|
+
picked.add(i - 1);
|
|
533
|
+
}
|
|
534
|
+
return [...picked].sort((x, y) => x - y);
|
|
535
|
+
}
|
|
536
|
+
function isYes(answer) {
|
|
537
|
+
return /^\s*y(es)?\s*$/i.test(answer);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Numbered pick + y/N confirm. Returns the names to remove ([] = nothing).
|
|
541
|
+
* Throws on an invalid pick. `ask` and `write` are injectable for tests.
|
|
542
|
+
*/
|
|
543
|
+
export async function chooseTokensToRemove(tokens, ask, write) {
|
|
544
|
+
write(tokenTable(tokens, { numbered: true }));
|
|
545
|
+
const pick = parseSelection(await ask("Remove which? (e.g. 1,3 or all; Enter to cancel): "), tokens.length);
|
|
546
|
+
if (typeof pick === "string")
|
|
547
|
+
throw new Error(pick);
|
|
548
|
+
if (pick.length === 0)
|
|
549
|
+
return [];
|
|
550
|
+
const names = pick.map((i) => tokens[i].name);
|
|
551
|
+
return isYes(await ask(`Remove ${names.length} token(s): ${names.join(", ")}? [y/N] `)) ? names : [];
|
|
552
|
+
}
|
|
553
|
+
function canPrompt() {
|
|
554
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
555
|
+
}
|
|
556
|
+
async function withPrompt(fn) {
|
|
557
|
+
const { createInterface } = await import("node:readline/promises");
|
|
558
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
559
|
+
try {
|
|
560
|
+
return await fn((q) => rl.question(q));
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
rl.close();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function interactiveRemove() {
|
|
567
|
+
if (!canPrompt()) {
|
|
568
|
+
console.error("serve token rm needs a name when stdin/stderr are not a terminal.");
|
|
569
|
+
usage();
|
|
570
|
+
}
|
|
571
|
+
const { tokens } = await loadServeStore();
|
|
572
|
+
if (tokens.length === 0) {
|
|
573
|
+
process.stdout.write("No serve tokens.\n");
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const names = await withPrompt((ask) => chooseTokensToRemove(tokens, ask, (text) => process.stderr.write(text)));
|
|
577
|
+
if (names.length === 0) {
|
|
578
|
+
console.error("Nothing removed.");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const removed = await removeTokens(names);
|
|
582
|
+
process.stdout.write(`Removed ${removed.join(", ")}\n`);
|
|
583
|
+
}
|
|
584
|
+
/** `token prune` with no flags removes expired tokens and these. */
|
|
585
|
+
export const DEFAULT_PRUNE_UNUSED_DAYS = 30;
|
|
586
|
+
/** Tokens `prune` would remove, with a reason each. */
|
|
587
|
+
export function pruneCandidates(tokens, opts, now = Date.now()) {
|
|
588
|
+
const out = [];
|
|
589
|
+
for (const t of tokens) {
|
|
590
|
+
if (opts.expired && isExpired(t, now)) {
|
|
591
|
+
out.push({ token: t, reason: "expired" });
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (opts.unusedDays != null && now - lastActivity(t) >= opts.unusedDays * 86_400_000) {
|
|
595
|
+
out.push({ token: t, reason: t.last_used ? `unused ${relativeTime(t.last_used, now)}` : "never used" });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
async function pruneCommand(args) {
|
|
601
|
+
let expired = false;
|
|
602
|
+
let yes = false;
|
|
603
|
+
let unusedDays;
|
|
604
|
+
while (args.length > 0) {
|
|
605
|
+
const arg = args.shift();
|
|
606
|
+
if (arg === "--expired") {
|
|
607
|
+
expired = true;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (arg === "--yes" || arg === "-y") {
|
|
611
|
+
yes = true;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const days = takeFlag(arg, "--unused-days", args, usage);
|
|
615
|
+
if (days !== undefined) {
|
|
616
|
+
unusedDays = Number(days);
|
|
617
|
+
if (!Number.isFinite(unusedDays) || unusedDays < 0) {
|
|
618
|
+
console.error(`--unused-days must be a number of days (got "${days}")`);
|
|
619
|
+
usage();
|
|
620
|
+
}
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
console.error(`Unknown argument: ${arg}`);
|
|
624
|
+
usage();
|
|
625
|
+
}
|
|
626
|
+
if (!expired && unusedDays === undefined) {
|
|
627
|
+
// No criterion: the everyday cleanup.
|
|
628
|
+
expired = true;
|
|
629
|
+
unusedDays = DEFAULT_PRUNE_UNUSED_DAYS;
|
|
630
|
+
}
|
|
631
|
+
const { tokens } = await loadServeStore();
|
|
632
|
+
const candidates = pruneCandidates(tokens, { expired, unusedDays });
|
|
633
|
+
if (candidates.length === 0) {
|
|
634
|
+
process.stdout.write("Nothing to prune.\n");
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const list = candidates.map((c) => ` ${c.token.name} (${c.reason})`).join("\n");
|
|
638
|
+
if (!yes) {
|
|
639
|
+
if (!canPrompt()) {
|
|
640
|
+
console.error(`Would remove:\n${list}\nRefusing without --yes (not a terminal).`);
|
|
641
|
+
process.exitCode = 1;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
process.stderr.write(`Will remove:\n${list}\n`);
|
|
645
|
+
const ok = await withPrompt(async (ask) => isYes(await ask(`Remove ${candidates.length} token(s)? [y/N] `)));
|
|
646
|
+
if (!ok) {
|
|
647
|
+
console.error("Nothing removed.");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const removed = await removeTokens(candidates.map((c) => c.token.name));
|
|
652
|
+
process.stdout.write(`Removed ${removed.join(", ")}\n`);
|
|
653
|
+
}
|
|
654
|
+
async function labelCommand(args) {
|
|
655
|
+
const sub = args.shift();
|
|
656
|
+
if (sub === "set" || sub === "add") {
|
|
657
|
+
if (args.length !== 2)
|
|
658
|
+
usage();
|
|
659
|
+
await setLabel(args[0], args[1]);
|
|
660
|
+
process.stdout.write(`Label ${args[1]} for ${args[0]}\n`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (sub === "rm" || sub === "remove") {
|
|
664
|
+
if (args.length !== 1)
|
|
665
|
+
usage();
|
|
666
|
+
if (!(await removeLabel(args[0]))) {
|
|
667
|
+
console.error(`No serve label for "${args[0]}".`);
|
|
668
|
+
process.exitCode = 1;
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
process.stdout.write(`Removed label for ${args[0]}\n`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (sub === "list" || sub === "ls") {
|
|
675
|
+
const { labels } = await loadServeStore();
|
|
676
|
+
const entries = Object.entries(labels);
|
|
677
|
+
if (entries.length === 0) {
|
|
678
|
+
process.stdout.write("No serve labels (aliases or host-N are used).\n");
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
for (const [machine, label] of entries)
|
|
682
|
+
process.stdout.write(`${label} ← ${machine}\n`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
console.error(`Unknown label subcommand: ${sub ?? "(none)"}`);
|
|
686
|
+
usage();
|
|
687
|
+
}
|
|
688
|
+
export async function main() {
|
|
689
|
+
const args = process.argv.slice(2);
|
|
690
|
+
if (args.some((a) => isHelpFlag(a)))
|
|
691
|
+
printHelp(helpText());
|
|
692
|
+
if (args[0] === "token")
|
|
693
|
+
return tokenCommand(args.slice(1));
|
|
694
|
+
if (args[0] === "label")
|
|
695
|
+
return labelCommand(args.slice(1));
|
|
696
|
+
if (args[0] === "status")
|
|
697
|
+
return statusCommand(args.slice(1));
|
|
698
|
+
let listen = DEFAULT_LISTEN;
|
|
699
|
+
let refreshRaw = DEFAULT_REFRESH;
|
|
700
|
+
let rate = DEFAULT_RATE;
|
|
701
|
+
let allowRemote = false;
|
|
702
|
+
let funnel = false;
|
|
703
|
+
let keepFunnel = false;
|
|
704
|
+
let funnelPort = 443;
|
|
705
|
+
while (args.length > 0) {
|
|
706
|
+
const arg = args.shift();
|
|
707
|
+
const l = takeFlag(arg, "--listen", args, usage);
|
|
708
|
+
if (l !== undefined) {
|
|
709
|
+
listen = l;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const r = takeFlag(arg, "--refresh", args, usage);
|
|
713
|
+
if (r !== undefined) {
|
|
714
|
+
refreshRaw = r;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const rt = takeFlag(arg, "--rate", args, usage);
|
|
718
|
+
if (rt !== undefined) {
|
|
719
|
+
rate = Number(rt);
|
|
720
|
+
if (!Number.isInteger(rate) || rate < 1) {
|
|
721
|
+
console.error(`--rate must be a positive integer (got "${rt}")`);
|
|
722
|
+
usage();
|
|
723
|
+
}
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (arg === "--allow-remote") {
|
|
727
|
+
allowRemote = true;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (arg === "--funnel") {
|
|
731
|
+
funnel = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (arg === "--keep-funnel") {
|
|
735
|
+
keepFunnel = true;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const fp = takeFlag(arg, "--funnel-port", args, usage);
|
|
739
|
+
if (fp !== undefined) {
|
|
740
|
+
funnelPort = Number(fp);
|
|
741
|
+
if (!FUNNEL_PORTS.includes(funnelPort)) {
|
|
742
|
+
console.error(`--funnel-port must be one of ${FUNNEL_PORTS.join(", ")} (got "${fp}")`);
|
|
743
|
+
usage();
|
|
744
|
+
}
|
|
745
|
+
funnel = true;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
console.error(`Unknown argument: ${arg}`);
|
|
749
|
+
usage();
|
|
750
|
+
}
|
|
751
|
+
const { host, port } = parseListen(listen);
|
|
752
|
+
if (!isLoopbackHost(host) && !allowRemote) {
|
|
753
|
+
throw new Error(`Refusing to listen on ${host}: not a loopback address. Keep the default and expose it ` +
|
|
754
|
+
`with Tailscale Funnel/Serve, or pass --allow-remote.`);
|
|
755
|
+
}
|
|
756
|
+
const off = /^(off|never|0)$/i.test(refreshRaw.trim());
|
|
757
|
+
const refreshMs = off ? 0 : parseDuration(refreshRaw);
|
|
758
|
+
if (refreshMs === undefined || (refreshMs > 0 && refreshMs < MIN_REFRESH_MS)) {
|
|
759
|
+
throw new Error(`--refresh must be off or a duration of at least 30s (got "${refreshRaw}")`);
|
|
760
|
+
}
|
|
761
|
+
const activeTokens = (await loadServeStore()).tokens.filter((t) => !isExpired(t));
|
|
762
|
+
if (funnel && activeTokens.length === 0) {
|
|
763
|
+
throw new Error("Refusing --funnel: there are no active serve tokens and the Funnel URL is public. " +
|
|
764
|
+
"Create one first: ollanet serve token add <name>");
|
|
765
|
+
}
|
|
766
|
+
const quiet = isQuiet();
|
|
767
|
+
const log = (line) => {
|
|
768
|
+
if (!quiet)
|
|
769
|
+
console.error(line);
|
|
770
|
+
};
|
|
771
|
+
const handle = await startServe({ host, port, refreshMs, rate, log });
|
|
772
|
+
const cat = handle.catalog();
|
|
773
|
+
const models = cat.hosts.reduce((n, h) => n + h.models.length, 0);
|
|
774
|
+
log(`ollanet serve listening on ${handle.url} (read-only catalog)`);
|
|
775
|
+
log(`catalog: ${cat.hosts.length} host(s), ${models} model(s)` +
|
|
776
|
+
`${cat.scanned_at ? ` from scan at ${cat.scanned_at}` : ""}; ` +
|
|
777
|
+
`refresh ${refreshMs > 0 ? `every ${refreshRaw}` : "off"}`);
|
|
778
|
+
if (activeTokens.length === 0) {
|
|
779
|
+
log("No active tokens: /v1/* answers 401 until you run ollanet serve token add <name>");
|
|
780
|
+
}
|
|
781
|
+
if (!isLoopbackHost(host)) {
|
|
782
|
+
log(`Warning: listening on non-loopback ${host} (--allow-remote).`);
|
|
783
|
+
}
|
|
784
|
+
let funnelHandle = null;
|
|
785
|
+
if (funnel) {
|
|
786
|
+
const localHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
787
|
+
const target = `http://${localHost.includes(":") ? `[${localHost}]` : localHost}:${handle.port}`;
|
|
788
|
+
try {
|
|
789
|
+
funnelHandle = await startFunnel({ localPort: handle.port, target, funnelPort });
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
await handle.close();
|
|
793
|
+
if (err instanceof FunnelError) {
|
|
794
|
+
console.error(err.message);
|
|
795
|
+
process.exitCode = 1;
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
throw err;
|
|
799
|
+
}
|
|
800
|
+
// The public URL is the result of --funnel, so it goes to stdout even with -q.
|
|
801
|
+
process.stdout.write(`Funnel: ${funnelHandle.publicUrl}\n`);
|
|
802
|
+
process.stdout.write(`Catalog: ${funnelHandle.publicUrl}v1/catalog\n`);
|
|
803
|
+
if (!funnelHandle.added) {
|
|
804
|
+
log("Funnel was already pointing at this port; leaving it as is when serve exits.");
|
|
805
|
+
}
|
|
806
|
+
else if (keepFunnel) {
|
|
807
|
+
log(`--keep-funnel: the Funnel stays up after exit. Turn it off with: tailscale funnel --https=${funnelPort} off`);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
log("The Funnel is turned off when serve exits (Ctrl+C).");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const statePath = serveStatePath();
|
|
814
|
+
const state = {
|
|
815
|
+
pid: process.pid,
|
|
816
|
+
url: handle.url,
|
|
817
|
+
funnel_url: funnelHandle?.publicUrl ?? null,
|
|
818
|
+
started_at: new Date().toISOString(),
|
|
819
|
+
};
|
|
820
|
+
await mkdir(path.dirname(statePath), { recursive: true })
|
|
821
|
+
.then(() => writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"))
|
|
822
|
+
.catch(() => { });
|
|
823
|
+
let tornDown = false;
|
|
824
|
+
const teardown = () => {
|
|
825
|
+
if (tornDown)
|
|
826
|
+
return;
|
|
827
|
+
tornDown = true;
|
|
828
|
+
if (funnelHandle && !keepFunnel)
|
|
829
|
+
funnelHandle.stop();
|
|
830
|
+
};
|
|
831
|
+
// Sync work only in 'exit': turn off our Funnel even on an unexpected exit.
|
|
832
|
+
process.once("exit", teardown);
|
|
833
|
+
const stop = () => {
|
|
834
|
+
teardown();
|
|
835
|
+
void Promise.all([handle.close(), rm(statePath, { force: true })]).finally(() => process.exit(0));
|
|
836
|
+
};
|
|
837
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
|
|
838
|
+
process.once(sig, stop);
|
|
839
|
+
}
|
|
840
|
+
}
|