home-hosted 0.7.16 → 0.7.18
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/AGENTS.md +193 -385
- package/README.md +25 -6
- package/dist/cli.js +1082 -542
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +6 -2
- package/docs/REVERSE_PROXY.md +11 -4
- package/docs/SERVERS.md +8 -3
- package/docs/UI_CREATION.md +13 -4
- package/package.json +3 -2
- package/uis/stock/dist/assets/{index-CAp6K13b.js → index-DaynUz2r.js} +7 -7
- package/uis/stock/dist/assets/{index-D8S55m4E.css → index-Dp4rjHQQ.css} +1 -1
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/ui.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import process from "node:process";
|
|
3
2
|
import { fileURLToPath } from "node:url";
|
|
4
3
|
import { defineCommand, runCommand } from "citty";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import readline from "node:readline";
|
|
6
|
+
import fs, { createReadStream } from "node:fs";
|
|
7
7
|
import { exec, execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
9
9
|
import os from "node:os";
|
|
@@ -14,6 +14,8 @@ import { isDevelopment } from "std-env";
|
|
|
14
14
|
import { DetailedError } from "@namesmt/utils";
|
|
15
15
|
import { type } from "arktype";
|
|
16
16
|
import crypto, { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
|
|
17
|
+
import { Readable, Writable } from "node:stream";
|
|
18
|
+
import { bodyLimit } from "hono/body-limit";
|
|
17
19
|
import { streamSSE } from "hono/streaming";
|
|
18
20
|
import { autoRetry } from "@grammyjs/auto-retry";
|
|
19
21
|
import { Bot } from "grammy";
|
|
@@ -24,7 +26,6 @@ import { Hono } from "hono";
|
|
|
24
26
|
import { HTTPException } from "hono/http-exception";
|
|
25
27
|
import { Scalar } from "@scalar/hono-api-reference";
|
|
26
28
|
import https from "node:https";
|
|
27
|
-
import { Readable, Writable } from "node:stream";
|
|
28
29
|
import { finished } from "node:stream/promises";
|
|
29
30
|
import { BlobReader, ZipReader, ZipWriter, configure } from "@zip.js/zip.js";
|
|
30
31
|
import { serve } from "srvx";
|
|
@@ -48,6 +49,44 @@ var __exportAll = (all, no_symbols) => {
|
|
|
48
49
|
return target;
|
|
49
50
|
};
|
|
50
51
|
//#endregion
|
|
52
|
+
//#region src/cli/nearest.ts
|
|
53
|
+
/**
|
|
54
|
+
* The nearest word to a typo, or null when nothing is close.
|
|
55
|
+
*
|
|
56
|
+
* Two callers need it — a mistyped command and a mistyped option — so it takes its candidate list as
|
|
57
|
+
* a parameter rather than reaching for one. One implementation, so both suggest by the same rule.
|
|
58
|
+
*
|
|
59
|
+
* Levenshtein distance, capped: only a genuinely near miss is worth naming, because suggesting an
|
|
60
|
+
* unrelated word is worse than suggesting none.
|
|
61
|
+
*/
|
|
62
|
+
function nearestWord(input, candidates) {
|
|
63
|
+
if (candidates.length === 0) return null;
|
|
64
|
+
const distance = (a, b) => {
|
|
65
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
66
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
67
|
+
const current = [i];
|
|
68
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
69
|
+
const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
70
|
+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, substitution);
|
|
71
|
+
}
|
|
72
|
+
previous = current;
|
|
73
|
+
}
|
|
74
|
+
return previous[b.length];
|
|
75
|
+
};
|
|
76
|
+
const best = candidates.map((name) => ({
|
|
77
|
+
name,
|
|
78
|
+
d: distance(input.toLowerCase(), name.toLowerCase())
|
|
79
|
+
})).sort((left, right) => left.d - right.d || left.name.localeCompare(right.name))[0];
|
|
80
|
+
if (best === void 0) return null;
|
|
81
|
+
const proportional = Math.floor(Math.min(input.length, best.name.length) / 2);
|
|
82
|
+
const allowed = Math.min(proportional, ABSOLUTE_BOUND);
|
|
83
|
+
return best.d <= allowed ? best.name : null;
|
|
84
|
+
}
|
|
85
|
+
var ABSOLUTE_BOUND;
|
|
86
|
+
var init_nearest = __esmMin((() => {
|
|
87
|
+
ABSOLUTE_BOUND = 2;
|
|
88
|
+
}));
|
|
89
|
+
//#endregion
|
|
51
90
|
//#region src/cli/args.ts
|
|
52
91
|
/** `--home`/`--project` are handled for every command, so they are peeled off first. */
|
|
53
92
|
function extractDirFlags(argv) {
|
|
@@ -130,15 +169,33 @@ function aliasesOf(def) {
|
|
|
130
169
|
if (def === void 0 || !("alias" in def) || def.alias === void 0) return [];
|
|
131
170
|
return Array.isArray(def.alias) ? def.alias : [def.alias];
|
|
132
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Every spelling that reaches one declared option: the key, its kebab form, each alias, and the
|
|
174
|
+
* `no-` negation citty gives a boolean. `findArg` accepts on this set and the suggestion offers from
|
|
175
|
+
* it, so a suggestion can never name a spelling the parser would then refuse.
|
|
176
|
+
*/
|
|
177
|
+
function namesOf$1(key, def) {
|
|
178
|
+
return [
|
|
179
|
+
key,
|
|
180
|
+
kebab(key),
|
|
181
|
+
...aliasesOf(def)
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
/** The spellings of every option this command declares, for a suggestion. */
|
|
185
|
+
function declaredOptionNames(argsDef) {
|
|
186
|
+
const names = [];
|
|
187
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
188
|
+
if (def === void 0 || def.type === "positional") continue;
|
|
189
|
+
names.push(...namesOf$1(key, def));
|
|
190
|
+
if (def.type === "boolean") names.push(...namesOf$1(key, def).map((name) => `no-${name}`));
|
|
191
|
+
}
|
|
192
|
+
return names;
|
|
193
|
+
}
|
|
133
194
|
function findArg(argsDef, name) {
|
|
134
195
|
for (const [key, def] of Object.entries(argsDef)) {
|
|
135
196
|
if (def === void 0) continue;
|
|
136
197
|
if (def.type === "positional") continue;
|
|
137
|
-
if ((
|
|
138
|
-
key,
|
|
139
|
-
kebab(key),
|
|
140
|
-
...aliasesOf(def)
|
|
141
|
-
])).has(name)) return def;
|
|
198
|
+
if (namesOf$1(key, def).includes(name)) return def;
|
|
142
199
|
if (def.type === "boolean" && (name === `no-${key}` || name === `no-${kebab(key)}`)) return def;
|
|
143
200
|
}
|
|
144
201
|
}
|
|
@@ -166,7 +223,10 @@ function rejectUnknownFlags(argv, argsDef) {
|
|
|
166
223
|
const equals = body.indexOf("=");
|
|
167
224
|
const name = equals === -1 ? body : body.slice(0, equals);
|
|
168
225
|
const def = name.length === 0 ? void 0 : findArg(argsDef, name);
|
|
169
|
-
if (def === void 0)
|
|
226
|
+
if (def === void 0) {
|
|
227
|
+
const suggested = nearestWord(name, declaredOptionNames(argsDef));
|
|
228
|
+
return `Unknown option '${token}'${suggested === null ? "" : ` — did you mean \`--${suggested}\`?`}`;
|
|
229
|
+
}
|
|
170
230
|
if (def.type === "string" && equals === -1 && argv[index + 1] === void 0) return `Option '${token}' needs a value`;
|
|
171
231
|
if (def.type === "string" && equals === -1) index += 1;
|
|
172
232
|
}
|
|
@@ -188,6 +248,7 @@ function buildDaemonArgv(flags) {
|
|
|
188
248
|
}
|
|
189
249
|
var HELP_TOKENS, VERSION_TOKENS, HELP_FLAGS, VERSION_FLAGS, CAMEL;
|
|
190
250
|
var init_args = __esmMin((() => {
|
|
251
|
+
init_nearest();
|
|
191
252
|
HELP_TOKENS = /* @__PURE__ */ new Set([
|
|
192
253
|
"help",
|
|
193
254
|
"--help",
|
|
@@ -306,6 +367,54 @@ var init_runtime = __esmMin((() => {
|
|
|
306
367
|
NANNY_COMMAND = "__nanny";
|
|
307
368
|
}));
|
|
308
369
|
//#endregion
|
|
370
|
+
//#region src/helpers/version.ts
|
|
371
|
+
var version_exports = /* @__PURE__ */ __exportAll({
|
|
372
|
+
appVersion: () => appVersion,
|
|
373
|
+
versionMismatchNote: () => versionMismatchNote
|
|
374
|
+
});
|
|
375
|
+
/**
|
|
376
|
+
* The running release, read from the package manifest.
|
|
377
|
+
*
|
|
378
|
+
* The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
|
|
379
|
+
* is found by walking up rather than by a fixed relative path, and the name is checked:
|
|
380
|
+
* a nested manifest must not be trusted as this package's.
|
|
381
|
+
*
|
|
382
|
+
* `src/cli.ts` reaches this through a **relative** import, not `#src/*`. That file is the
|
|
383
|
+
* bootstrap and may only pull in modules that load node builtins statically — a `#src`
|
|
384
|
+
* import there would pull the daemon graph in before citty is even asked anything.
|
|
385
|
+
*/
|
|
386
|
+
function appVersion() {
|
|
387
|
+
if (cached !== null) return cached;
|
|
388
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
389
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
390
|
+
try {
|
|
391
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
392
|
+
if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
|
|
393
|
+
cached = manifest.version;
|
|
394
|
+
return cached;
|
|
395
|
+
}
|
|
396
|
+
} catch {}
|
|
397
|
+
const parent = path.dirname(dir);
|
|
398
|
+
if (parent === dir) break;
|
|
399
|
+
dir = parent;
|
|
400
|
+
}
|
|
401
|
+
cached = "0.0.0";
|
|
402
|
+
return cached;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The one line to add when this CLI and the panel it is talking to are not the same
|
|
406
|
+
* release — which is what a linked checkout, a global install and a project pin
|
|
407
|
+
* produce. `null` when they agree.
|
|
408
|
+
*/
|
|
409
|
+
function versionMismatchNote(panelVersion) {
|
|
410
|
+
const cli = appVersion();
|
|
411
|
+
return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
|
|
412
|
+
}
|
|
413
|
+
var cached;
|
|
414
|
+
var init_version = __esmMin((() => {
|
|
415
|
+
cached = null;
|
|
416
|
+
}));
|
|
417
|
+
//#endregion
|
|
309
418
|
//#region src/helpers/daemon-log.ts
|
|
310
419
|
var daemon_log_exports = /* @__PURE__ */ __exportAll({
|
|
311
420
|
followLog: () => followLog,
|
|
@@ -493,50 +602,6 @@ var init_daemon_log = __esmMin((() => {
|
|
|
493
602
|
LOG_ROTATE_BYTES = 5242880;
|
|
494
603
|
}));
|
|
495
604
|
//#endregion
|
|
496
|
-
//#region src/helpers/version.ts
|
|
497
|
-
var version_exports = /* @__PURE__ */ __exportAll({
|
|
498
|
-
appVersion: () => appVersion,
|
|
499
|
-
versionMismatchNote: () => versionMismatchNote
|
|
500
|
-
});
|
|
501
|
-
/**
|
|
502
|
-
* The running release, read from the package manifest.
|
|
503
|
-
*
|
|
504
|
-
* The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
|
|
505
|
-
* is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
|
|
506
|
-
* its own read because it may only import node builtins statically.
|
|
507
|
-
*/
|
|
508
|
-
function appVersion() {
|
|
509
|
-
if (cached !== null) return cached;
|
|
510
|
-
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
511
|
-
for (let depth = 0; depth < 4; depth++) {
|
|
512
|
-
try {
|
|
513
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
514
|
-
if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
|
|
515
|
-
cached = manifest.version;
|
|
516
|
-
return cached;
|
|
517
|
-
}
|
|
518
|
-
} catch {}
|
|
519
|
-
const parent = path.dirname(dir);
|
|
520
|
-
if (parent === dir) break;
|
|
521
|
-
dir = parent;
|
|
522
|
-
}
|
|
523
|
-
cached = "0.0.0";
|
|
524
|
-
return cached;
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* The one line to add when this CLI and the panel it is talking to are not the same
|
|
528
|
-
* release — which is what a linked checkout, a global install and a project pin
|
|
529
|
-
* produce. `null` when they agree.
|
|
530
|
-
*/
|
|
531
|
-
function versionMismatchNote(panelVersion) {
|
|
532
|
-
const cli = appVersion();
|
|
533
|
-
return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
|
|
534
|
-
}
|
|
535
|
-
var cached;
|
|
536
|
-
var init_version = __esmMin((() => {
|
|
537
|
-
cached = null;
|
|
538
|
-
}));
|
|
539
|
-
//#endregion
|
|
540
605
|
//#region src/helpers/factory.ts
|
|
541
606
|
var appFactory;
|
|
542
607
|
var init_factory = __esmMin((() => {
|
|
@@ -631,7 +696,7 @@ function parseBind(value) {
|
|
|
631
696
|
const parsed = bindSchema(value);
|
|
632
697
|
return parsed instanceof type.errors ? null : parsed;
|
|
633
698
|
}
|
|
634
|
-
var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
699
|
+
var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, telegramChatsSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
635
700
|
var init_contracts = __esmMin((() => {
|
|
636
701
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
637
702
|
workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
|
|
@@ -699,10 +764,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
699
764
|
runOnce: "boolean = true"
|
|
700
765
|
}).onUndeclaredKey("reject");
|
|
701
766
|
bootstrapOrNullSchema = bootstrapSchema.or(type("null"));
|
|
767
|
+
labelOrNullSchema = type("string | null");
|
|
702
768
|
logBufferLinesSchema = type("50 <= number.integer <= 100000");
|
|
703
769
|
serverSchema = type({
|
|
704
770
|
id: "/^[a-z0-9][a-z0-9_-]*$/",
|
|
705
|
-
label:
|
|
771
|
+
label: labelOrNullSchema.optional(),
|
|
706
772
|
enabled: "boolean = true",
|
|
707
773
|
autostart: "boolean = false",
|
|
708
774
|
/**
|
|
@@ -728,7 +794,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
728
794
|
health: healthSchema.default(() => ({})),
|
|
729
795
|
stop: stopSchema.default(() => ({})),
|
|
730
796
|
logBufferLines: logBufferLinesSchema.default(() => 500),
|
|
731
|
-
/** Ids this server needs running first (and healthy);
|
|
797
|
+
/** Ids this server needs running first (and healthy); `stop-all` stops dependents before it. */
|
|
732
798
|
dependsOn: type("string[]").default(() => []),
|
|
733
799
|
/** Optional KEY=value file loaded at spawn; its values override `env`. */
|
|
734
800
|
envFile: "string = \"\"",
|
|
@@ -743,11 +809,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
743
809
|
}).onUndeclaredKey("reject");
|
|
744
810
|
authSchema = type({
|
|
745
811
|
enabled: "boolean = true",
|
|
812
|
+
/** Idle timeout: a session dies this long after its **last** request, not after login. */
|
|
746
813
|
sessionTtlMs: "number >= 60000 = 604800000",
|
|
747
814
|
/** `auto` adds `Secure` when the request arrived over https (proxy-aware). */
|
|
748
815
|
cookieSecure: "\"auto\" | \"always\" | \"never\" = \"auto\"",
|
|
749
816
|
/** Trust `x-forwarded-*` from a reverse proxy; also drives the client IP. */
|
|
750
|
-
trustProxy: "boolean = false",
|
|
817
|
+
trustProxy: "boolean | \"loopback\" = false",
|
|
751
818
|
maxLoginAttempts: "number.integer >= 1 = 5",
|
|
752
819
|
lockoutMs: "number >= 1000 = 60000"
|
|
753
820
|
}).onUndeclaredKey("reject");
|
|
@@ -953,7 +1020,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
953
1020
|
enabled: "boolean?",
|
|
954
1021
|
sessionTtlMs: "number >= 60000?",
|
|
955
1022
|
cookieSecure: "\"auto\" | \"always\" | \"never\"?",
|
|
956
|
-
trustProxy: "boolean?",
|
|
1023
|
+
trustProxy: "boolean | \"loopback\"?",
|
|
957
1024
|
maxLoginAttempts: "number.integer >= 1?",
|
|
958
1025
|
lockoutMs: "number >= 1000?"
|
|
959
1026
|
}).onUndeclaredKey("reject");
|
|
@@ -992,7 +1059,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
992
1059
|
}).onUndeclaredKey("reject");
|
|
993
1060
|
tlsPatchSchema = type({ enabled: "boolean?" }).onUndeclaredKey("reject");
|
|
994
1061
|
controlPatchSchema = type({
|
|
995
|
-
label: "1 <= string <= 60?",
|
|
1062
|
+
label: "1 <= string <= 60 | null?",
|
|
996
1063
|
port: "1 <= number.integer <= 65535?",
|
|
997
1064
|
host: bindSchema.optional(),
|
|
998
1065
|
openBrowser: "boolean?",
|
|
@@ -1096,7 +1163,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1096
1163
|
blockedReason: "string | null",
|
|
1097
1164
|
sessionTtlMs: "number",
|
|
1098
1165
|
cookieSecure: "string",
|
|
1099
|
-
trustProxy: "boolean",
|
|
1166
|
+
trustProxy: "boolean | \"loopback\"",
|
|
1100
1167
|
maxLoginAttempts: "number",
|
|
1101
1168
|
lockoutMs: "number"
|
|
1102
1169
|
});
|
|
@@ -1301,6 +1368,10 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1301
1368
|
lastResult: "string | null",
|
|
1302
1369
|
lastResultAt: "number | null"
|
|
1303
1370
|
});
|
|
1371
|
+
telegramChatsSchema = type({ chats: type({
|
|
1372
|
+
id: "string | number",
|
|
1373
|
+
title: "string"
|
|
1374
|
+
}).array() });
|
|
1304
1375
|
notificationViewSchema = type({ telegram: telegramStatusSchema });
|
|
1305
1376
|
sessionViewSchema = type({
|
|
1306
1377
|
authenticated: "boolean",
|
|
@@ -1477,6 +1548,17 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1477
1548
|
"responseMs": "number | null",
|
|
1478
1549
|
"resources": processResourcesSchema.or(type("null"))
|
|
1479
1550
|
});
|
|
1551
|
+
healthResponseSchema = type({
|
|
1552
|
+
"status": "\"ok\" | \"degraded\"",
|
|
1553
|
+
"uptimeMs": "number",
|
|
1554
|
+
"servers?": type({
|
|
1555
|
+
total: "number",
|
|
1556
|
+
running: "number",
|
|
1557
|
+
crashed: "number",
|
|
1558
|
+
unhealthy: "number"
|
|
1559
|
+
}),
|
|
1560
|
+
"hostAlerts?": "string[]"
|
|
1561
|
+
});
|
|
1480
1562
|
controlViewSchema = type({
|
|
1481
1563
|
/** The configured panel name, for the shell to render. */
|
|
1482
1564
|
label: "string",
|
|
@@ -1574,7 +1656,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1574
1656
|
});
|
|
1575
1657
|
logHistoryQuerySchema = type({
|
|
1576
1658
|
tail: "string?",
|
|
1577
|
-
/**
|
|
1659
|
+
/**
|
|
1660
|
+
* Case-insensitive substring filter. Both this and `stream` are filters, so the route reads a
|
|
1661
|
+
* wider window than `tail` and applies them to that: filtering only the display tail would
|
|
1662
|
+
* report an older match as "no results", and "the last N stderr lines" as whatever happened
|
|
1663
|
+
* to fall in a window sized for all streams. `searched` reports how many lines were read.
|
|
1664
|
+
*/
|
|
1578
1665
|
search: "string?",
|
|
1579
1666
|
stream: "\"stdout\" | \"stderr\" | \"system\"?"
|
|
1580
1667
|
});
|
|
@@ -1607,10 +1694,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1607
1694
|
}).onUndeclaredKey("reject");
|
|
1608
1695
|
telegramTokenSchema = type({ botToken: "string >= 1" }).onUndeclaredKey("reject");
|
|
1609
1696
|
apiErrorSchema = type({
|
|
1610
|
-
message: "string",
|
|
1697
|
+
"message": "string",
|
|
1611
1698
|
/** Stable, machine-readable; `AUTH_REQUIRED` also drives the login redirect. */
|
|
1612
|
-
code: "string",
|
|
1613
|
-
|
|
1699
|
+
"code": "string",
|
|
1700
|
+
/** Structured context when the failure has any — a field list, the routes holding a certificate. */
|
|
1701
|
+
"detail?": "unknown"
|
|
1614
1702
|
}).onUndeclaredKey("reject");
|
|
1615
1703
|
tlsUploadSchema = type({
|
|
1616
1704
|
certificate: "string >= 1",
|
|
@@ -1663,6 +1751,10 @@ var init_openapi$1 = __esmMin((() => {
|
|
|
1663
1751
|
404: {
|
|
1664
1752
|
description: "Unknown id",
|
|
1665
1753
|
content: jsonBody(apiErrorSchema)
|
|
1754
|
+
},
|
|
1755
|
+
413: {
|
|
1756
|
+
description: "The body is larger than this route accepts",
|
|
1757
|
+
content: jsonBody(apiErrorSchema)
|
|
1666
1758
|
}
|
|
1667
1759
|
};
|
|
1668
1760
|
}));
|
|
@@ -1678,6 +1770,7 @@ function validate(target, schema) {
|
|
|
1678
1770
|
return validator(target, schema, (result) => {
|
|
1679
1771
|
if (result.success === false) throw new DetailedError("validation failed", {
|
|
1680
1772
|
statusCode: 400,
|
|
1773
|
+
code: "INVALID_INPUT",
|
|
1681
1774
|
detail: normalizeIssues(result.error)
|
|
1682
1775
|
});
|
|
1683
1776
|
});
|
|
@@ -1712,6 +1805,28 @@ function writeFileAtomic(file, content, options = {}) {
|
|
|
1712
1805
|
}
|
|
1713
1806
|
var init_atomic = __esmMin((() => {}));
|
|
1714
1807
|
//#endregion
|
|
1808
|
+
//#region src/shared/shape.ts
|
|
1809
|
+
/**
|
|
1810
|
+
* A plain object, and nothing else: not `null`, and not an array.
|
|
1811
|
+
*
|
|
1812
|
+
* Every untyped boundary here narrows to this shape, and it lived as **nine** definitions across
|
|
1813
|
+
* `src/` and the UIs: seven strict (one an inline ternary rather than a named function, which is why
|
|
1814
|
+
* a search for the name found only some of them) and two — both UIs' `lib/api.ts` — that accepted an
|
|
1815
|
+
* array.
|
|
1816
|
+
*
|
|
1817
|
+
* The `!Array.isArray` half is the one that matters: `typeof [] === 'object'` is true, so without
|
|
1818
|
+
* it an array narrows to a record and reading a property off it silently yields `undefined`
|
|
1819
|
+
* instead of failing — and iterating it with `Object.entries` yields the *indices*.
|
|
1820
|
+
*
|
|
1821
|
+
* The two loose copies happened to behave the same on the payloads they see, but only by accident
|
|
1822
|
+
* of which properties they read. They use this one now, so the next reader does not have to work
|
|
1823
|
+
* that out.
|
|
1824
|
+
*/
|
|
1825
|
+
function isRecord(value) {
|
|
1826
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1827
|
+
}
|
|
1828
|
+
var init_shape = __esmMin((() => {}));
|
|
1829
|
+
//#endregion
|
|
1715
1830
|
//#region src/config/secrets.ts
|
|
1716
1831
|
function deriveKey(password, salt, cost, keylen) {
|
|
1717
1832
|
return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
|
|
@@ -1760,32 +1875,29 @@ function apiTokenRecord(token, now = Date.now()) {
|
|
|
1760
1875
|
updatedAt: now
|
|
1761
1876
|
};
|
|
1762
1877
|
}
|
|
1763
|
-
function isRecord$4(value) {
|
|
1764
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1765
|
-
}
|
|
1766
1878
|
/** A field map: strings only, empty values dropped; anything else reads as absent. */
|
|
1767
1879
|
function readDdnsValues(value) {
|
|
1768
|
-
if (!isRecord
|
|
1880
|
+
if (!isRecord(value)) return {};
|
|
1769
1881
|
const out = {};
|
|
1770
1882
|
for (const [key, field] of Object.entries(value)) if (typeof field === "string" && field.length > 0) out[key] = field;
|
|
1771
1883
|
return out;
|
|
1772
1884
|
}
|
|
1773
1885
|
function isSealedDdns(value) {
|
|
1774
|
-
return isRecord
|
|
1886
|
+
return isRecord(value) && value.algo === DDNS_SEALED_ALGO && typeof value.iv === "string" && typeof value.tag === "string" && typeof value.data === "string";
|
|
1775
1887
|
}
|
|
1776
1888
|
function isPlainDdns(value) {
|
|
1777
1889
|
return "plain" in value;
|
|
1778
1890
|
}
|
|
1779
1891
|
/** Every shape this file has held: sealed, `{ provider, values }`, or a bare field map. */
|
|
1780
1892
|
function readStoredDdns(value) {
|
|
1781
|
-
if (!isRecord
|
|
1893
|
+
if (!isRecord(value)) return {};
|
|
1782
1894
|
const out = {};
|
|
1783
1895
|
for (const [accountId, entry] of Object.entries(value)) {
|
|
1784
1896
|
if (isSealedDdns(entry)) {
|
|
1785
1897
|
out[accountId] = entry;
|
|
1786
1898
|
continue;
|
|
1787
1899
|
}
|
|
1788
|
-
if (!isRecord
|
|
1900
|
+
if (!isRecord(entry)) continue;
|
|
1789
1901
|
const values = readDdnsValues("values" in entry ? entry.values : entry);
|
|
1790
1902
|
if (Object.keys(values).length === 0) continue;
|
|
1791
1903
|
out[accountId] = {
|
|
@@ -1797,8 +1909,8 @@ function readStoredDdns(value) {
|
|
|
1797
1909
|
return out;
|
|
1798
1910
|
}
|
|
1799
1911
|
function readDdnsKdf(value) {
|
|
1800
|
-
if (!isRecord
|
|
1801
|
-
const cost = isRecord
|
|
1912
|
+
if (!isRecord(value) || value.algo !== "scrypt" || typeof value.salt !== "string") return null;
|
|
1913
|
+
const cost = isRecord(value.cost) ? value.cost : {};
|
|
1802
1914
|
const N = Number(cost.N);
|
|
1803
1915
|
const r = Number(cost.r);
|
|
1804
1916
|
const p = Number(cost.p);
|
|
@@ -1858,6 +1970,7 @@ var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_
|
|
|
1858
1970
|
var init_secrets = __esmMin((() => {
|
|
1859
1971
|
init_atomic();
|
|
1860
1972
|
init_logger();
|
|
1973
|
+
init_shape();
|
|
1861
1974
|
COST = {
|
|
1862
1975
|
N: 16384,
|
|
1863
1976
|
r: 8,
|
|
@@ -1878,6 +1991,7 @@ var init_secrets = __esmMin((() => {
|
|
|
1878
1991
|
cacheKey = "";
|
|
1879
1992
|
ddnsKeyCache = null;
|
|
1880
1993
|
warnedAccounts = /* @__PURE__ */ new Set();
|
|
1994
|
+
warnedDefaultSecret = false;
|
|
1881
1995
|
ddnsSecret;
|
|
1882
1996
|
constructor(file, ddnsSecret, scope = "all") {
|
|
1883
1997
|
this.file = file;
|
|
@@ -1885,6 +1999,16 @@ var init_secrets = __esmMin((() => {
|
|
|
1885
1999
|
const candidate = (ddnsSecret ?? process.env.HHOSTED_DDNS_SECRET ?? "").trim();
|
|
1886
2000
|
this.ddnsSecret = candidate.length > 0 ? candidate : "hh";
|
|
1887
2001
|
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Whether the DDNS credentials are sealed under the built-in key.
|
|
2004
|
+
*
|
|
2005
|
+
* Sealing is what stops a leaked *file* — a backup, a committed state directory — from handing over
|
|
2006
|
+
* the DNS credentials. Under the default the key is the literal `hh`, so anyone holding the file can
|
|
2007
|
+
* open it. The docs say so; this is the same fact for code that wants to say so at runtime.
|
|
2008
|
+
*/
|
|
2009
|
+
get usingDefaultDdnsSecret() {
|
|
2010
|
+
return this.ddnsSecret === "hh";
|
|
2011
|
+
}
|
|
1888
2012
|
get path() {
|
|
1889
2013
|
return this.file;
|
|
1890
2014
|
}
|
|
@@ -2108,6 +2232,10 @@ var init_secrets = __esmMin((() => {
|
|
|
2108
2232
|
sealStored(contents) {
|
|
2109
2233
|
const plain = Object.entries(contents.ddns).filter((entry) => isPlainDdns(entry[1]));
|
|
2110
2234
|
if (plain.length === 0) return contents;
|
|
2235
|
+
if (this.usingDefaultDdnsSecret && !this.warnedDefaultSecret) {
|
|
2236
|
+
this.warnedDefaultSecret = true;
|
|
2237
|
+
logger.warn(`ddns: credentials are sealed under the built-in key — set HHOSTED_DDNS_SECRET to protect them if this state directory is ever copied or committed (docs/DDNS.md)`);
|
|
2238
|
+
}
|
|
2111
2239
|
const kdf = contents.ddnsKdf ?? newDdnsKdf();
|
|
2112
2240
|
const key = this.ddnsKey(kdf);
|
|
2113
2241
|
const ddns = { ...contents.ddns };
|
|
@@ -2300,7 +2428,6 @@ var init_auth$1 = __esmMin((() => {
|
|
|
2300
2428
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
2301
2429
|
this.sessions.set(token, {
|
|
2302
2430
|
token,
|
|
2303
|
-
createdAt: now,
|
|
2304
2431
|
expiresAt: now + config.sessionTtlMs,
|
|
2305
2432
|
lastSeenAt: now,
|
|
2306
2433
|
ip
|
|
@@ -2499,7 +2626,7 @@ function checkExposure(control, passwordSet, usingDefaultPassword = false) {
|
|
|
2499
2626
|
*/
|
|
2500
2627
|
function checkProxyExposure(proxy, authEnabled, passwordSet, usingDefaultPassword = false) {
|
|
2501
2628
|
if (!proxy.enabled) return null;
|
|
2502
|
-
const panelRoutes = proxy.routes.filter((route) => route.enabled && route.target === "panel"
|
|
2629
|
+
const panelRoutes = proxy.routes.filter((route) => route.enabled && route.target === "panel");
|
|
2503
2630
|
if (panelRoutes.length === 0) return null;
|
|
2504
2631
|
const hosts = panelRoutes.map((route) => route.host).join(", ");
|
|
2505
2632
|
if (!authEnabled && !passwordSet) return `the reverse proxy would serve the control panel at ${hosts} but authentication is disabled and no password is set`;
|
|
@@ -2508,6 +2635,23 @@ function checkProxyExposure(proxy, authEnabled, passwordSet, usingDefaultPasswor
|
|
|
2508
2635
|
if (usingDefaultPassword) return `the reverse proxy would serve the control panel at ${hosts} but it still uses the default password — change it first`;
|
|
2509
2636
|
return null;
|
|
2510
2637
|
}
|
|
2638
|
+
/**
|
|
2639
|
+
* A warning for the combination that makes the login lockout advisory.
|
|
2640
|
+
*
|
|
2641
|
+
* `trustProxy: true` tells srvx to trust `x-forwarded-*` from **any** peer, and it resolves the client
|
|
2642
|
+
* address from the leftmost entry of `x-forwarded-for` — a value the caller controls. Login failures are
|
|
2643
|
+
* keyed on that address, so a client that rotates the header never accumulates them: measured, 30 wrong
|
|
2644
|
+
* passwords from 30 forged addresses produced **zero** 429s, against 27 from a fixed address.
|
|
2645
|
+
*
|
|
2646
|
+
* This is a warning rather than a refusal because `true` has legitimate uses — a proxy on another host —
|
|
2647
|
+
* and, once the header is trusted, the real peer is not reachable through srvx (`#remoteAddress` is
|
|
2648
|
+
* private), so the panel cannot re-key the lockout on it. Prefer `host: local` with the proxy on this
|
|
2649
|
+
* machine, or leave `trustProxy` off and reach the panel directly.
|
|
2650
|
+
*/
|
|
2651
|
+
function proxyTrustWarning(control) {
|
|
2652
|
+
if (control.auth.trustProxy !== true || !isExposed(control.host)) return null;
|
|
2653
|
+
return `auth.trustProxy is on while the panel is bound to ${control.host}: the client address comes from the caller's own x-forwarded-for header, so the login lockout (maxLoginAttempts/lockoutMs) counts per forged address and can be bypassed. Keep the bind on local with the proxy on this machine, or put an authenticating gateway in front`;
|
|
2654
|
+
}
|
|
2511
2655
|
var init_exposure = __esmMin((() => {
|
|
2512
2656
|
init_bind();
|
|
2513
2657
|
}));
|
|
@@ -2671,10 +2815,8 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
2671
2815
|
DEFAULT_WORKSPACE_ID: () => DEFAULT_WORKSPACE_ID,
|
|
2672
2816
|
daemonLogPath: () => daemonLogPath,
|
|
2673
2817
|
dataRoot: () => dataRoot,
|
|
2674
|
-
defaultBackupsDirName: () => defaultBackupsDirName,
|
|
2675
2818
|
globalSecretsPath: () => globalSecretsPath,
|
|
2676
2819
|
globalSettingsPath: () => globalSettingsPath,
|
|
2677
|
-
globalSettingsSchemaPath: () => globalSettingsSchemaPath,
|
|
2678
2820
|
hasLegacyLayout: () => hasLegacyLayout,
|
|
2679
2821
|
hhDir: () => hhDir,
|
|
2680
2822
|
legacy: () => legacy,
|
|
@@ -2701,12 +2843,9 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
2701
2843
|
workspaceLogsDir: () => workspaceLogsDir,
|
|
2702
2844
|
workspaceSecretsPath: () => workspaceSecretsPath,
|
|
2703
2845
|
workspaceServersPath: () => workspaceServersPath,
|
|
2704
|
-
workspaceServersSchemaPath: () => workspaceServersSchemaPath,
|
|
2705
2846
|
workspaceSettingsPath: () => workspaceSettingsPath,
|
|
2706
|
-
workspaceSettingsSchemaPath: () => workspaceSettingsSchemaPath,
|
|
2707
2847
|
workspaceStateDir: () => workspaceStateDir,
|
|
2708
|
-
workspacesPath: () => workspacesPath
|
|
2709
|
-
workspacesSchemaPath: () => workspacesSchemaPath
|
|
2848
|
+
workspacesPath: () => workspacesPath
|
|
2710
2849
|
});
|
|
2711
2850
|
/**
|
|
2712
2851
|
* Where home-hosted keeps everything it owns. `HHOSTED_HOME` overrides it —
|
|
@@ -2741,16 +2880,10 @@ function workspaceDir(id) {
|
|
|
2741
2880
|
function workspaceSettingsPath(id) {
|
|
2742
2881
|
return path.join(workspaceDir(id), "settings.json");
|
|
2743
2882
|
}
|
|
2744
|
-
function workspaceSettingsSchemaPath(id) {
|
|
2745
|
-
return path.join(workspaceDir(id), "settings.schema.json");
|
|
2746
|
-
}
|
|
2747
2883
|
/** The servers a workspace supervises. */
|
|
2748
2884
|
function workspaceServersPath(id) {
|
|
2749
2885
|
return path.join(workspaceDir(id), "servers.config.json");
|
|
2750
2886
|
}
|
|
2751
|
-
function workspaceServersSchemaPath(id) {
|
|
2752
|
-
return path.join(workspaceDir(id), "servers.config.schema.json");
|
|
2753
|
-
}
|
|
2754
2887
|
/** Workspace secrets: Telegram bot token and DDNS credentials. Mode 0600. */
|
|
2755
2888
|
function workspaceSecretsPath(id) {
|
|
2756
2889
|
return path.join(workspaceDir(id), ".secrets.json");
|
|
@@ -2791,19 +2924,16 @@ function resolveUserPath(target, base = projectDir) {
|
|
|
2791
2924
|
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
2792
2925
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
2793
2926
|
}
|
|
2794
|
-
var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath,
|
|
2927
|
+
var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSecretsPath, workspacesPath, tlsDir, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, proxyChallengeAuthPath, legacy;
|
|
2795
2928
|
var init_paths = __esmMin((() => {
|
|
2796
2929
|
dataRoot = resolveDataRoot();
|
|
2797
2930
|
projectDir = resolveProjectDir();
|
|
2798
2931
|
hhDir = path.join(dataRoot, ".hh");
|
|
2799
2932
|
DEFAULT_WORKSPACE_ID = "default";
|
|
2800
2933
|
globalSettingsPath = path.join(hhDir, "settings.json");
|
|
2801
|
-
globalSettingsSchemaPath = path.join(hhDir, "settings.schema.json");
|
|
2802
2934
|
globalSecretsPath = path.join(hhDir, ".control-secrets.json");
|
|
2803
2935
|
workspacesPath = path.join(hhDir, "workspaces.json");
|
|
2804
|
-
workspacesSchemaPath = path.join(hhDir, "workspaces.schema.json");
|
|
2805
2936
|
tlsDir = path.join(hhDir, ".tls");
|
|
2806
|
-
defaultBackupsDirName = ".backups";
|
|
2807
2937
|
daemonLogPath = path.join(hhDir, ".logs", "home-hosted.log");
|
|
2808
2938
|
runtimePath = path.join(hhDir, "run.json");
|
|
2809
2939
|
uiDir = path.join(hhDir, ".ui");
|
|
@@ -2936,14 +3066,14 @@ function createBackupsRoute(deps) {
|
|
|
2936
3066
|
200: { description: "the archive (application/zip)" },
|
|
2937
3067
|
404: ERROR_RESPONSES[404]
|
|
2938
3068
|
}
|
|
2939
|
-
}), validate("param", nameParam), (c) => {
|
|
3069
|
+
}), validate("param", nameParam), async (c) => {
|
|
2940
3070
|
const file = deps.backups.resolve(c.req.valid("param").name);
|
|
2941
3071
|
if (file === null) throw new DetailedError("unknown backup", {
|
|
2942
3072
|
statusCode: 404,
|
|
2943
3073
|
code: "UNKNOWN_BACKUP"
|
|
2944
3074
|
});
|
|
2945
|
-
const stats = fs.
|
|
2946
|
-
return c.body(
|
|
3075
|
+
const stats = await fs.promises.stat(file);
|
|
3076
|
+
return c.body(Readable.toWeb(createReadStream(file)), 200, {
|
|
2947
3077
|
"Content-Type": "application/zip",
|
|
2948
3078
|
"Content-Length": String(stats.size),
|
|
2949
3079
|
"Content-Disposition": `attachment; filename="${path.basename(file)}"`
|
|
@@ -2964,92 +3094,115 @@ function createBackupsRoute(deps) {
|
|
|
2964
3094
|
ok: true,
|
|
2965
3095
|
files: deps.backups.list()
|
|
2966
3096
|
});
|
|
2967
|
-
}).post(
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
code: "MISSING_FILE"
|
|
2996
|
-
});
|
|
2997
|
-
if (file.size > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
3097
|
+
}).post(
|
|
3098
|
+
"/backups/restore",
|
|
3099
|
+
describeRoute({
|
|
3100
|
+
tags: ["backups"],
|
|
3101
|
+
summary: "Plan or apply a restore from a stored or uploaded archive",
|
|
3102
|
+
responses: {
|
|
3103
|
+
200: {
|
|
3104
|
+
description: "The plan",
|
|
3105
|
+
content: jsonBody(restorePlanSchema)
|
|
3106
|
+
},
|
|
3107
|
+
400: ERROR_RESPONSES[400],
|
|
3108
|
+
404: ERROR_RESPONSES[404],
|
|
3109
|
+
413: ERROR_RESPONSES[413]
|
|
3110
|
+
}
|
|
3111
|
+
}),
|
|
3112
|
+
/**
|
|
3113
|
+
* Bound the body **while it arrives**, not after.
|
|
3114
|
+
*
|
|
3115
|
+
* The handler's own checks read `Content-Length` first — a header a chunked request simply does
|
|
3116
|
+
* not send — and then compare `file.size` after `parseBody()` has already buffered the whole
|
|
3117
|
+
* body into memory. So an upload with no length is buffered in full and *then* refused: the
|
|
3118
|
+
* ceiling was a memory limit rather than a guard. This middleware counts chunks and aborts at the
|
|
3119
|
+
* cap, which is the half the header check cannot do.
|
|
3120
|
+
*/
|
|
3121
|
+
bodyLimit({
|
|
3122
|
+
maxSize: MAX_UPLOAD_BYTES,
|
|
3123
|
+
onError: () => {
|
|
3124
|
+
throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
2998
3125
|
statusCode: 413,
|
|
2999
3126
|
code: "UPLOAD_TOO_LARGE"
|
|
3000
3127
|
});
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3128
|
+
}
|
|
3129
|
+
}),
|
|
3130
|
+
async (c) => {
|
|
3131
|
+
const confirm = c.req.query("confirm") === "true";
|
|
3132
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
3133
|
+
let archive = null;
|
|
3134
|
+
let uploadedTo = null;
|
|
3135
|
+
let request;
|
|
3136
|
+
try {
|
|
3137
|
+
if (contentType.includes("multipart/form-data")) {
|
|
3138
|
+
const declared = Number.parseInt(c.req.header("content-length") ?? "0", 10);
|
|
3139
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
3140
|
+
statusCode: 413,
|
|
3141
|
+
code: "UPLOAD_TOO_LARGE"
|
|
3142
|
+
});
|
|
3143
|
+
const body = await c.req.parseBody();
|
|
3144
|
+
const file = body.file;
|
|
3145
|
+
if (!(file instanceof File)) throw new DetailedError("expected a `file` field with the archive", {
|
|
3146
|
+
statusCode: 400,
|
|
3147
|
+
code: "MISSING_FILE"
|
|
3148
|
+
});
|
|
3149
|
+
if (file.size > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
3150
|
+
statusCode: 413,
|
|
3151
|
+
code: "UPLOAD_TOO_LARGE"
|
|
3152
|
+
});
|
|
3153
|
+
const uploads = path.join(deps.backups.directory, "uploads");
|
|
3154
|
+
fs.mkdirSync(uploads, { recursive: true });
|
|
3155
|
+
uploadedTo = path.join(uploads, `upload-${crypto.randomUUID()}.zip`);
|
|
3156
|
+
fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()), { mode: 384 });
|
|
3157
|
+
archive = uploadedTo;
|
|
3158
|
+
const rawInclude = typeof body.include === "string" && body.include.length > 0 ? body.include : null;
|
|
3159
|
+
let include;
|
|
3160
|
+
if (rawInclude !== null) try {
|
|
3161
|
+
include = JSON.parse(rawInclude);
|
|
3162
|
+
} catch {
|
|
3163
|
+
throw new DetailedError("`include` must be a JSON array of item ids", {
|
|
3164
|
+
statusCode: 400,
|
|
3165
|
+
code: "INVALID_INCLUDE"
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
request = parseOrThrow(restoreRequestSchema, {
|
|
3169
|
+
...typeof body.password === "string" ? { password: body.password } : {},
|
|
3170
|
+
...rawInclude === null ? {} : { include }
|
|
3171
|
+
}, "body");
|
|
3172
|
+
} else {
|
|
3173
|
+
request = parseOrThrow(restoreRequestSchema, await c.req.json().catch(() => ({})), "body");
|
|
3174
|
+
if (request.name === void 0) throw new DetailedError("expected a backup name or a file upload", {
|
|
3012
3175
|
statusCode: 400,
|
|
3013
|
-
code: "
|
|
3176
|
+
code: "MISSING_ARCHIVE"
|
|
3177
|
+
});
|
|
3178
|
+
archive = deps.backups.resolve(request.name);
|
|
3179
|
+
if (archive === null) throw new DetailedError("unknown backup", {
|
|
3180
|
+
statusCode: 404,
|
|
3181
|
+
code: "UNKNOWN_BACKUP"
|
|
3014
3182
|
});
|
|
3015
3183
|
}
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
} else {
|
|
3021
|
-
request = parseOrThrow(restoreRequestSchema, await c.req.json().catch(() => ({})), "body");
|
|
3022
|
-
if (request.name === void 0) throw new DetailedError("expected a backup name or a file upload", {
|
|
3023
|
-
statusCode: 400,
|
|
3024
|
-
code: "MISSING_ARCHIVE"
|
|
3184
|
+
const plan = await deps.backups.restore(archive, {
|
|
3185
|
+
confirm,
|
|
3186
|
+
password: request.password,
|
|
3187
|
+
include: request.include
|
|
3025
3188
|
});
|
|
3026
|
-
|
|
3027
|
-
if (
|
|
3028
|
-
statusCode:
|
|
3029
|
-
code: "
|
|
3189
|
+
if (plan.needsPassword) return c.json(plan);
|
|
3190
|
+
if (plan.error !== void 0) throw new DetailedError(plan.error, {
|
|
3191
|
+
statusCode: 400,
|
|
3192
|
+
code: "RESTORE_FAILED",
|
|
3193
|
+
detail: {
|
|
3194
|
+
items: plan.items,
|
|
3195
|
+
applied: plan.applied,
|
|
3196
|
+
skipped: plan.skipped
|
|
3197
|
+
}
|
|
3030
3198
|
});
|
|
3199
|
+
if (confirm) logger.info(`restored from ${path.basename(archive)}: ${plan.applied.join(", ")}`);
|
|
3200
|
+
return c.json(plan);
|
|
3201
|
+
} finally {
|
|
3202
|
+
if (uploadedTo !== null) fs.rmSync(uploadedTo, { force: true });
|
|
3031
3203
|
}
|
|
3032
|
-
const plan = await deps.backups.restore(archive, {
|
|
3033
|
-
confirm,
|
|
3034
|
-
password: request.password,
|
|
3035
|
-
include: request.include
|
|
3036
|
-
});
|
|
3037
|
-
if (plan.needsPassword) return c.json(plan);
|
|
3038
|
-
if (plan.error !== void 0) throw new DetailedError(plan.error, {
|
|
3039
|
-
statusCode: 400,
|
|
3040
|
-
code: "RESTORE_FAILED",
|
|
3041
|
-
detail: {
|
|
3042
|
-
items: plan.items,
|
|
3043
|
-
applied: plan.applied,
|
|
3044
|
-
skipped: plan.skipped
|
|
3045
|
-
}
|
|
3046
|
-
});
|
|
3047
|
-
if (confirm) logger.info(`restored from ${path.basename(archive)}: ${plan.applied.join(", ")}`);
|
|
3048
|
-
return c.json(plan);
|
|
3049
|
-
} finally {
|
|
3050
|
-
if (uploadedTo !== null) fs.rmSync(uploadedTo, { force: true });
|
|
3051
3204
|
}
|
|
3052
|
-
|
|
3205
|
+
);
|
|
3053
3206
|
}
|
|
3054
3207
|
var MAX_UPLOAD_BYTES, nameParam;
|
|
3055
3208
|
var init_backups$1 = __esmMin((() => {
|
|
@@ -3065,12 +3218,37 @@ var init_backups$1 = __esmMin((() => {
|
|
|
3065
3218
|
}));
|
|
3066
3219
|
//#endregion
|
|
3067
3220
|
//#region src/helpers/action-result.ts
|
|
3221
|
+
/** The one shape a missing entry takes, so the marker and the message cannot drift apart. */
|
|
3222
|
+
function missingServer(id) {
|
|
3223
|
+
return {
|
|
3224
|
+
ok: false,
|
|
3225
|
+
error: `unknown server "${id}"`,
|
|
3226
|
+
[UNKNOWN_SERVER]: true
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
/**
|
|
3230
|
+
* The same failure where the caller throws instead of answering — the route side of `missingServer()`.
|
|
3231
|
+
*
|
|
3232
|
+
* `api/logs.ts` and `api/servers/$.routes.ts` each carried a byte-identical private copy of this, with
|
|
3233
|
+
* six call sites between them. Two copies of a wire contract (the message, the 404, the `UNKNOWN_SERVER`
|
|
3234
|
+
* code) is the drift that already happened once for `request`: a change landing in one UI and not the
|
|
3235
|
+
* other. Defining it beside the message keeps one owner for the wording.
|
|
3236
|
+
*/
|
|
3237
|
+
function unknownServerError(id) {
|
|
3238
|
+
return new DetailedError(`unknown server "${id}"`, {
|
|
3239
|
+
statusCode: 404,
|
|
3240
|
+
code: "UNKNOWN_SERVER"
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3068
3243
|
/** Unknown ids are 404; a server that exists but cannot act is a 409. */
|
|
3069
3244
|
function statusForAction(result) {
|
|
3070
3245
|
if (result.ok) return 200;
|
|
3071
|
-
return result
|
|
3246
|
+
return result[UNKNOWN_SERVER] === true ? 404 : 409;
|
|
3072
3247
|
}
|
|
3073
|
-
var
|
|
3248
|
+
var UNKNOWN_SERVER;
|
|
3249
|
+
var init_action_result = __esmMin((() => {
|
|
3250
|
+
UNKNOWN_SERVER = Symbol("hh.unknownServer");
|
|
3251
|
+
}));
|
|
3074
3252
|
//#endregion
|
|
3075
3253
|
//#region src/helpers/deferred.ts
|
|
3076
3254
|
/**
|
|
@@ -3088,10 +3266,28 @@ function afterResponse(task, onError) {
|
|
|
3088
3266
|
}
|
|
3089
3267
|
var init_deferred = __esmMin((() => {}));
|
|
3090
3268
|
//#endregion
|
|
3091
|
-
//#region src/
|
|
3092
|
-
|
|
3093
|
-
|
|
3269
|
+
//#region src/helpers/secret-compare.ts
|
|
3270
|
+
/**
|
|
3271
|
+
* Compares two secrets without leaking their contents through timing.
|
|
3272
|
+
*
|
|
3273
|
+
* `a !== b` short-circuits at the first differing byte, so an attacker who can measure response time
|
|
3274
|
+
* recovers a secret one byte at a time. That matters wherever the secret is the *only* thing standing
|
|
3275
|
+
* between a caller and a privileged action — the `/_hh` control channel, which is deliberately outside
|
|
3276
|
+
* `/api` and guarded by nothing else.
|
|
3277
|
+
*
|
|
3278
|
+
* Length is compared first because `timingSafeEqual` **throws** on differing lengths. That leaks only
|
|
3279
|
+
* the length, which is not secret here (the token has a fixed shape and a `hh_` prefix); rejecting it
|
|
3280
|
+
* is what keeps a truncated header from becoming a 500.
|
|
3281
|
+
*/
|
|
3282
|
+
function secretEqual(actual, expected) {
|
|
3283
|
+
const left = Buffer$1.from(actual, "utf8");
|
|
3284
|
+
const right = Buffer$1.from(expected, "utf8");
|
|
3285
|
+
if (left.length !== right.length || left.length === 0) return false;
|
|
3286
|
+
return crypto.timingSafeEqual(left, right);
|
|
3094
3287
|
}
|
|
3288
|
+
var init_secret_compare = __esmMin((() => {}));
|
|
3289
|
+
//#endregion
|
|
3290
|
+
//#region src/config/schema.ts
|
|
3095
3291
|
/**
|
|
3096
3292
|
* Resolves one entry against the workspace's server defaults. A group (`restart`,
|
|
3097
3293
|
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
@@ -3103,13 +3299,14 @@ function mergeDefaults(defaults, entry) {
|
|
|
3103
3299
|
for (const [key, value] of Object.entries(defaults)) {
|
|
3104
3300
|
const current = merged[key];
|
|
3105
3301
|
if (current === void 0) merged[key] = value;
|
|
3106
|
-
else if (isRecord
|
|
3302
|
+
else if (isRecord(value) && isRecord(current)) merged[key] = mergeDefaults(value, current);
|
|
3107
3303
|
}
|
|
3108
3304
|
return merged;
|
|
3109
3305
|
}
|
|
3110
3306
|
var metaSchema, globalSettingsSchema, workspaceSettingsSchema, serversFileSchema, GLOBAL_SETTINGS_KEYS, WORKSPACE_SETTINGS_KEYS, SERVERS_FILE_KEYS;
|
|
3111
3307
|
var init_schema = __esmMin((() => {
|
|
3112
3308
|
init_contracts();
|
|
3309
|
+
init_shape();
|
|
3113
3310
|
metaSchema = type({
|
|
3114
3311
|
writtenBy: "string = \"\"",
|
|
3115
3312
|
schema: "number.integer >= 1 = 1"
|
|
@@ -3185,6 +3382,11 @@ var init_workspaces = __esmMin((() => {
|
|
|
3185
3382
|
}).onUndeclaredKey("reject");
|
|
3186
3383
|
WorkspaceError = class extends Error {
|
|
3187
3384
|
name = "WorkspaceError";
|
|
3385
|
+
code;
|
|
3386
|
+
constructor(message, code) {
|
|
3387
|
+
super(message);
|
|
3388
|
+
this.code = code;
|
|
3389
|
+
}
|
|
3188
3390
|
};
|
|
3189
3391
|
seed = () => [{
|
|
3190
3392
|
id: DEFAULT_WORKSPACE_ID,
|
|
@@ -3255,14 +3457,14 @@ var init_workspaces = __esmMin((() => {
|
|
|
3255
3457
|
label: label.length > 0 ? label : id
|
|
3256
3458
|
});
|
|
3257
3459
|
if (validated instanceof type.errors) throw new WorkspaceError(validated.summary);
|
|
3258
|
-
if (this.has(id)) throw new WorkspaceError(`workspace "${id}" already exists
|
|
3460
|
+
if (this.has(id)) throw new WorkspaceError(`workspace "${id}" already exists`, "WORKSPACE_EXISTS");
|
|
3259
3461
|
const next = [...this.list, validated];
|
|
3260
3462
|
this.commit(next);
|
|
3261
3463
|
return validated;
|
|
3262
3464
|
}
|
|
3263
3465
|
rename(id, label) {
|
|
3264
3466
|
const index = this.list.findIndex((entry) => entry.id === id);
|
|
3265
|
-
if (index < 0) throw new WorkspaceError(`unknown workspace "${id}"
|
|
3467
|
+
if (index < 0) throw new WorkspaceError(`unknown workspace "${id}"`, "UNKNOWN_WORKSPACE");
|
|
3266
3468
|
const validated = workspaceSchema({
|
|
3267
3469
|
id,
|
|
3268
3470
|
label: label.trim()
|
|
@@ -3275,7 +3477,7 @@ var init_workspaces = __esmMin((() => {
|
|
|
3275
3477
|
}
|
|
3276
3478
|
remove(id) {
|
|
3277
3479
|
const entry = this.get(id);
|
|
3278
|
-
if (entry === void 0) throw new WorkspaceError(`unknown workspace "${id}"
|
|
3480
|
+
if (entry === void 0) throw new WorkspaceError(`unknown workspace "${id}"`, "UNKNOWN_WORKSPACE");
|
|
3279
3481
|
if (this.list.length <= 1) throw new WorkspaceError("cannot remove the only workspace");
|
|
3280
3482
|
this.commit(this.list.filter((candidate) => candidate.id !== id));
|
|
3281
3483
|
return entry;
|
|
@@ -3336,7 +3538,7 @@ function createControlRoute(deps) {
|
|
|
3336
3538
|
/** The one place the local channel's credentials are read. */
|
|
3337
3539
|
function assertLocalCall(c, action) {
|
|
3338
3540
|
const token = c.req.header("x-home-hosted-token");
|
|
3339
|
-
if (token === void 0 || token
|
|
3541
|
+
if (token === void 0 || !secretEqual(token, deps.runtimeToken)) throw new DetailedError("invalid token", {
|
|
3340
3542
|
statusCode: 403,
|
|
3341
3543
|
code: "INVALID_TOKEN"
|
|
3342
3544
|
});
|
|
@@ -3399,6 +3601,7 @@ var init_control = __esmMin((() => {
|
|
|
3399
3601
|
init_deferred();
|
|
3400
3602
|
init_factory();
|
|
3401
3603
|
init_logger();
|
|
3604
|
+
init_secret_compare();
|
|
3402
3605
|
init_validator();
|
|
3403
3606
|
init_workspace();
|
|
3404
3607
|
init_loopback();
|
|
@@ -3408,11 +3611,13 @@ var init_control = __esmMin((() => {
|
|
|
3408
3611
|
/** What would have to run to bring `from` up to `to`. */
|
|
3409
3612
|
function planConfigMigrations(from, options = {}) {
|
|
3410
3613
|
const to = options.to ?? 1;
|
|
3614
|
+
const steps = (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to);
|
|
3411
3615
|
return {
|
|
3412
3616
|
from,
|
|
3413
3617
|
to,
|
|
3414
|
-
steps
|
|
3415
|
-
tooNew: from > to
|
|
3618
|
+
steps,
|
|
3619
|
+
tooNew: from > to,
|
|
3620
|
+
reached: steps.reduce((at, step) => step.to, from)
|
|
3416
3621
|
};
|
|
3417
3622
|
}
|
|
3418
3623
|
/** Applies the plan in order; the caller owns writing the result. */
|
|
@@ -3431,18 +3636,15 @@ var init_migrations = __esmMin((() => {
|
|
|
3431
3636
|
}));
|
|
3432
3637
|
//#endregion
|
|
3433
3638
|
//#region src/config/parse.ts
|
|
3434
|
-
function isRecord$2(value) {
|
|
3435
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3436
|
-
}
|
|
3437
3639
|
function deleteAtPath(root, path) {
|
|
3438
3640
|
let node = root;
|
|
3439
3641
|
for (const key of path.slice(0, -1)) {
|
|
3440
|
-
node = Array.isArray(node) ? node[Number(key)] : isRecord
|
|
3642
|
+
node = Array.isArray(node) ? node[Number(key)] : isRecord(node) ? node[key] : void 0;
|
|
3441
3643
|
if (node === void 0) return;
|
|
3442
3644
|
}
|
|
3443
3645
|
const last = path[path.length - 1];
|
|
3444
3646
|
if (last === void 0) return;
|
|
3445
|
-
if (isRecord
|
|
3647
|
+
if (isRecord(node)) delete node[String(last)];
|
|
3446
3648
|
else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
|
|
3447
3649
|
}
|
|
3448
3650
|
/**
|
|
@@ -3467,7 +3669,7 @@ function parseTolerant(value, schema, prefix, unknownKeys) {
|
|
|
3467
3669
|
value: null,
|
|
3468
3670
|
error: parsed.summary
|
|
3469
3671
|
};
|
|
3470
|
-
if (!isRecord
|
|
3672
|
+
if (!isRecord(candidate)) return {
|
|
3471
3673
|
value: null,
|
|
3472
3674
|
error: parsed.summary
|
|
3473
3675
|
};
|
|
@@ -3497,11 +3699,11 @@ function parseGrouped(raw, label, definition, options) {
|
|
|
3497
3699
|
schemaVersion: 1,
|
|
3498
3700
|
writtenBy: null
|
|
3499
3701
|
};
|
|
3500
|
-
if (!isRecord
|
|
3702
|
+
if (!isRecord(raw)) {
|
|
3501
3703
|
errors.push(`the ${label} must contain a JSON object`);
|
|
3502
3704
|
return result;
|
|
3503
3705
|
}
|
|
3504
|
-
const meta = isRecord
|
|
3706
|
+
const meta = isRecord(raw.meta) ? raw.meta : null;
|
|
3505
3707
|
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
3506
3708
|
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
3507
3709
|
result.schemaVersion = schemaVersion;
|
|
@@ -3583,11 +3785,11 @@ function parseServersFile(raw, defaults, options = {}) {
|
|
|
3583
3785
|
schemaVersion: 1,
|
|
3584
3786
|
writtenBy: null
|
|
3585
3787
|
};
|
|
3586
|
-
if (!isRecord
|
|
3788
|
+
if (!isRecord(raw)) {
|
|
3587
3789
|
errors.push("the servers config must contain a JSON object");
|
|
3588
3790
|
return result;
|
|
3589
3791
|
}
|
|
3590
|
-
const meta = isRecord
|
|
3792
|
+
const meta = isRecord(raw.meta) ? raw.meta : null;
|
|
3591
3793
|
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
3592
3794
|
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
3593
3795
|
result.schemaVersion = schemaVersion;
|
|
@@ -3605,9 +3807,9 @@ function parseServersFile(raw, defaults, options = {}) {
|
|
|
3605
3807
|
const seen = /* @__PURE__ */ new Set();
|
|
3606
3808
|
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
3607
3809
|
const label = `servers[${index}]`;
|
|
3608
|
-
const parsed = parseTolerant(isRecord
|
|
3810
|
+
const parsed = parseTolerant(isRecord(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
|
|
3609
3811
|
if (parsed.error !== null) {
|
|
3610
|
-
const id = isRecord
|
|
3812
|
+
const id = isRecord(entry) ? entry.id : void 0;
|
|
3611
3813
|
errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
|
|
3612
3814
|
return;
|
|
3613
3815
|
}
|
|
@@ -3617,20 +3819,31 @@ function parseServersFile(raw, defaults, options = {}) {
|
|
|
3617
3819
|
return;
|
|
3618
3820
|
}
|
|
3619
3821
|
seen.add(server.id);
|
|
3822
|
+
const { label: entryLabel, ...rest } = server;
|
|
3620
3823
|
result.servers.push({
|
|
3621
|
-
...
|
|
3622
|
-
port: server.port ?? null
|
|
3824
|
+
...rest,
|
|
3825
|
+
port: server.port ?? null,
|
|
3826
|
+
...entryLabel === null || entryLabel === void 0 ? {} : { label: entryLabel }
|
|
3623
3827
|
});
|
|
3624
3828
|
});
|
|
3625
|
-
warnings.push(...
|
|
3829
|
+
warnings.push(...validateCrossField(result.servers));
|
|
3626
3830
|
return result;
|
|
3627
3831
|
}
|
|
3628
|
-
/**
|
|
3629
|
-
|
|
3832
|
+
/**
|
|
3833
|
+
* Config that validates field-by-field but is wrong as a whole.
|
|
3834
|
+
*
|
|
3835
|
+
* Warned rather than refused, because supervision still runs and a hard failure would take a working
|
|
3836
|
+
* panel down over a setting one field away from correct.
|
|
3837
|
+
*/
|
|
3838
|
+
function validateCrossField(servers) {
|
|
3630
3839
|
const ids = new Set(servers.map((server) => server.id));
|
|
3631
3840
|
const errors = [];
|
|
3632
|
-
for (const server of servers)
|
|
3633
|
-
|
|
3841
|
+
for (const server of servers) {
|
|
3842
|
+
const { health } = server;
|
|
3843
|
+
if (health.mode === "http" && health.http.method === "HEAD" && health.http.expectBody.length > 0) errors.push(`"${server.id}" requires a response body with method HEAD, which has none — the body check is ignored`);
|
|
3844
|
+
for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
3845
|
+
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
3846
|
+
}
|
|
3634
3847
|
const visiting = /* @__PURE__ */ new Set();
|
|
3635
3848
|
const settled = /* @__PURE__ */ new Set();
|
|
3636
3849
|
const byId = new Map(servers.map((server) => [server.id, server]));
|
|
@@ -3665,20 +3878,22 @@ var init_parse = __esmMin((() => {
|
|
|
3665
3878
|
init_migrations();
|
|
3666
3879
|
init_schema();
|
|
3667
3880
|
init_version();
|
|
3881
|
+
init_shape();
|
|
3668
3882
|
UNDECLARED = "must be removed";
|
|
3669
3883
|
}));
|
|
3670
3884
|
//#endregion
|
|
3671
3885
|
//#region src/config/patch.ts
|
|
3672
|
-
function isRecord$1(value) {
|
|
3673
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3674
|
-
}
|
|
3675
3886
|
function applyPatch(target, patch, mergeKeys) {
|
|
3676
3887
|
for (const [key, value] of Object.entries(patch)) {
|
|
3677
3888
|
if (value === void 0) continue;
|
|
3678
|
-
if (mergeKeys.has(key) && isRecord
|
|
3889
|
+
if (mergeKeys.has(key) && isRecord(value) && isRecord(target[key])) {
|
|
3679
3890
|
target[key] = mergeGroup(target[key], value);
|
|
3680
3891
|
continue;
|
|
3681
3892
|
}
|
|
3893
|
+
if (value === null) {
|
|
3894
|
+
delete target[key];
|
|
3895
|
+
continue;
|
|
3896
|
+
}
|
|
3682
3897
|
target[key] = value;
|
|
3683
3898
|
}
|
|
3684
3899
|
}
|
|
@@ -3696,7 +3911,7 @@ function mergeGroup(target, patch) {
|
|
|
3696
3911
|
delete merged[key];
|
|
3697
3912
|
continue;
|
|
3698
3913
|
}
|
|
3699
|
-
if (isRecord
|
|
3914
|
+
if (isRecord(value) && isRecord(merged[key])) {
|
|
3700
3915
|
merged[key] = mergeGroup(merged[key], value);
|
|
3701
3916
|
continue;
|
|
3702
3917
|
}
|
|
@@ -3706,6 +3921,7 @@ function mergeGroup(target, patch) {
|
|
|
3706
3921
|
}
|
|
3707
3922
|
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, EMPTY_MERGE_KEYS;
|
|
3708
3923
|
var init_patch = __esmMin((() => {
|
|
3924
|
+
init_shape();
|
|
3709
3925
|
SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
|
|
3710
3926
|
"restart",
|
|
3711
3927
|
"health",
|
|
@@ -3756,6 +3972,16 @@ var init_settings$1 = __esmMin((() => {
|
|
|
3756
3972
|
init_paths();
|
|
3757
3973
|
ConfigError = class extends Error {
|
|
3758
3974
|
name = "ConfigError";
|
|
3975
|
+
/**
|
|
3976
|
+
* The *kind* of config problem, when a caller has to branch on it rather than just report it.
|
|
3977
|
+
* `UNKNOWN_SERVER` is the one case today: the servers route answers 404 for a missing id and 400
|
|
3978
|
+
* for an invalid patch, and it decided that by matching the message text.
|
|
3979
|
+
*/
|
|
3980
|
+
code;
|
|
3981
|
+
constructor(message, code) {
|
|
3982
|
+
super(message);
|
|
3983
|
+
this.code = code;
|
|
3984
|
+
}
|
|
3759
3985
|
};
|
|
3760
3986
|
GlobalSettingsStore = class {
|
|
3761
3987
|
file;
|
|
@@ -4054,12 +4280,22 @@ async function call$1(path, headers, context, init = {}) {
|
|
|
4054
4280
|
* Walks the host's parents through `GET /zones?name=`, which is how the
|
|
4055
4281
|
* reference image resolves a zone without a pasted zone id; the id is cached in
|
|
4056
4282
|
* the panel's state file, so it usually costs nothing.
|
|
4283
|
+
*
|
|
4284
|
+
* A cached id is **confirmed before use**, the same way `findRecord` treats its own. It was returned
|
|
4285
|
+
* outright, so a stale id — the zone deleted, the API token swapped to another account, a restored
|
|
4286
|
+
* `ddns.json` — made every later call address a zone the token cannot see, and the walk that would have
|
|
4287
|
+
* found the right one never ran: the update failed permanently, with the provider's error for a zone
|
|
4288
|
+
* that is not even the host's. One extra request per run, and only when a cache exists.
|
|
4057
4289
|
*/
|
|
4058
4290
|
async function resolveZone(host, headers, context, cached) {
|
|
4059
|
-
if (cached !== void 0)
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4291
|
+
if (cached !== void 0) {
|
|
4292
|
+
const confirmed = await call$1(`/zones/${cached}`, headers, context);
|
|
4293
|
+
if (confirmed.error === null && confirmed.data !== null) return {
|
|
4294
|
+
id: cached,
|
|
4295
|
+
name: host
|
|
4296
|
+
};
|
|
4297
|
+
if (confirmed.status === 0) throw new Error(confirmed.error ?? "could not reach Cloudflare");
|
|
4298
|
+
}
|
|
4063
4299
|
const labels = host.split(".");
|
|
4064
4300
|
for (let index = 0; index <= labels.length - 2; index++) {
|
|
4065
4301
|
const candidate = labels.slice(index).join(".");
|
|
@@ -5381,6 +5617,8 @@ function validateDdnsConfig(config) {
|
|
|
5381
5617
|
errors.push(`"${domain.host}" points at unknown account "${domain.account}"`);
|
|
5382
5618
|
continue;
|
|
5383
5619
|
}
|
|
5620
|
+
const zone = domain.zone?.trim().toLowerCase();
|
|
5621
|
+
if (zone !== void 0 && zone.length > 0 && domain.host.toLowerCase() !== zone && !domain.host.toLowerCase().endsWith(`.${zone}`)) errors.push(`"${domain.host}" has zone "${domain.zone}", which it is not inside — a zone is the registered domain the record lives in`);
|
|
5384
5622
|
const provider = ddnsProvider(account.provider);
|
|
5385
5623
|
if (provider !== null) for (const recordType of domain.types) {
|
|
5386
5624
|
if (!provider.families.includes(recordType)) errors.push(`${account.label || provider.label} cannot manage ${recordType} records ("${domain.host}")`);
|
|
@@ -5661,7 +5899,7 @@ var init_store = __esmMin((() => {
|
|
|
5661
5899
|
}
|
|
5662
5900
|
updateServer(id, patch) {
|
|
5663
5901
|
const index = (this.rawServers.servers ?? []).findIndex((entry) => entry.id === id);
|
|
5664
|
-
if (index < 0) throw new ConfigError(`unknown server "${id}"
|
|
5902
|
+
if (index < 0) throw new ConfigError(`unknown server "${id}"`, "UNKNOWN_SERVER");
|
|
5665
5903
|
const draft = structuredClone(this.rawServers);
|
|
5666
5904
|
const entry = draft.servers[index];
|
|
5667
5905
|
applyPatch(entry, patch, SERVER_MERGE_KEYS);
|
|
@@ -5723,7 +5961,7 @@ var init_store = __esmMin((() => {
|
|
|
5723
5961
|
const list = draft.servers ?? [];
|
|
5724
5962
|
const before = list.length;
|
|
5725
5963
|
draft.servers = list.filter((entry) => entry.id !== id);
|
|
5726
|
-
if (draft.servers.length === before) throw new ConfigError(`unknown server "${id}"
|
|
5964
|
+
if (draft.servers.length === before) throw new ConfigError(`unknown server "${id}"`, "UNKNOWN_SERVER");
|
|
5727
5965
|
this.commitServers(draft);
|
|
5728
5966
|
}
|
|
5729
5967
|
/**
|
|
@@ -5945,20 +6183,28 @@ var init_ddns$1 = __esmMin((() => {
|
|
|
5945
6183
|
accountParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
|
|
5946
6184
|
}));
|
|
5947
6185
|
//#endregion
|
|
5948
|
-
//#region src/
|
|
6186
|
+
//#region src/shared/server-key.ts
|
|
5949
6187
|
/**
|
|
5950
|
-
*
|
|
6188
|
+
* The SSE bucket key: one server inside one workspace.
|
|
5951
6189
|
*
|
|
5952
|
-
*
|
|
5953
|
-
*
|
|
5954
|
-
*
|
|
5955
|
-
* the
|
|
6190
|
+
* A server id is only unique within its workspace, so anything keyed by id alone mixes frames between
|
|
6191
|
+
* same-named servers — and this key was **three separate definitions**: `services/events.ts` (which
|
|
6192
|
+
* dispatches) and both UIs' composables (which subscribe). Byte-identical today, but a change to the
|
|
6193
|
+
* format on either side would route messages to the wrong bucket or to none, silently — the panel
|
|
6194
|
+
* would simply stop updating, with nothing to error on.
|
|
6195
|
+
*
|
|
6196
|
+
* It lives in `src/shared` rather than a UI because this module imports no Vue and is reachable from
|
|
6197
|
+
* the server and both UIs alike.
|
|
5956
6198
|
*/
|
|
5957
6199
|
function serverKey(workspaceId, serverId) {
|
|
5958
6200
|
return `${workspaceId}/${serverId}`;
|
|
5959
6201
|
}
|
|
6202
|
+
var init_server_key = __esmMin((() => {}));
|
|
6203
|
+
//#endregion
|
|
6204
|
+
//#region src/services/events.ts
|
|
5960
6205
|
var ALL, EventHub;
|
|
5961
6206
|
var init_events$1 = __esmMin((() => {
|
|
6207
|
+
init_server_key();
|
|
5962
6208
|
ALL = "*";
|
|
5963
6209
|
EventHub = class {
|
|
5964
6210
|
listeners = /* @__PURE__ */ new Map();
|
|
@@ -5976,11 +6222,6 @@ var init_events$1 = __esmMin((() => {
|
|
|
5976
6222
|
this.dispatch(ALL, message);
|
|
5977
6223
|
if (message.serverId && message.workspaceId) this.dispatch(serverKey(message.workspaceId, message.serverId), message);
|
|
5978
6224
|
}
|
|
5979
|
-
get subscriberCount() {
|
|
5980
|
-
let total = 0;
|
|
5981
|
-
for (const bucket of this.listeners.values()) total += bucket.size;
|
|
5982
|
-
return total;
|
|
5983
|
-
}
|
|
5984
6225
|
dispatch(key, message) {
|
|
5985
6226
|
const bucket = this.listeners.get(key);
|
|
5986
6227
|
if (!bucket) return;
|
|
@@ -6084,17 +6325,7 @@ function createHealthRoute(deps) {
|
|
|
6084
6325
|
responses: {
|
|
6085
6326
|
200: {
|
|
6086
6327
|
description: "Serving",
|
|
6087
|
-
content: jsonBody(
|
|
6088
|
-
"status": "\"ok\" | \"degraded\"",
|
|
6089
|
-
"uptimeMs": "number",
|
|
6090
|
-
"servers?": type({
|
|
6091
|
-
total: "number",
|
|
6092
|
-
running: "number",
|
|
6093
|
-
crashed: "number",
|
|
6094
|
-
unhealthy: "number"
|
|
6095
|
-
}),
|
|
6096
|
-
"hostAlerts?": "string[]"
|
|
6097
|
-
}))
|
|
6328
|
+
content: jsonBody(healthResponseSchema)
|
|
6098
6329
|
},
|
|
6099
6330
|
503: { description: "An autostart server has crashed" }
|
|
6100
6331
|
}
|
|
@@ -6122,15 +6353,10 @@ var init_health = __esmMin((() => {
|
|
|
6122
6353
|
init_factory();
|
|
6123
6354
|
init_openapi$1();
|
|
6124
6355
|
init_auth();
|
|
6356
|
+
init_contracts();
|
|
6125
6357
|
}));
|
|
6126
6358
|
//#endregion
|
|
6127
6359
|
//#region src/api/logs.ts
|
|
6128
|
-
function unknownServer$1(id) {
|
|
6129
|
-
return new DetailedError(`unknown server "${id}"`, {
|
|
6130
|
-
statusCode: 404,
|
|
6131
|
-
code: "UNKNOWN_SERVER"
|
|
6132
|
-
});
|
|
6133
|
-
}
|
|
6134
6360
|
function createLogsRoute(deps) {
|
|
6135
6361
|
return appFactory.createApp().get("/logs", describeRoute({
|
|
6136
6362
|
tags: ["logs"],
|
|
@@ -6157,15 +6383,24 @@ function createLogsRoute(deps) {
|
|
|
6157
6383
|
}), validate("query", historyQuery), validate("param", idParam$2), (c) => {
|
|
6158
6384
|
const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
|
|
6159
6385
|
const { id } = c.req.valid("param");
|
|
6160
|
-
if (!runtime.store.getServer(id)) throw
|
|
6386
|
+
if (!runtime.store.getServer(id)) throw unknownServerError(id);
|
|
6161
6387
|
const query = c.req.valid("query");
|
|
6162
6388
|
const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
|
|
6163
6389
|
const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
|
|
6164
6390
|
const info = runtime.logFiles.info(id);
|
|
6165
6391
|
const search = query.search?.trim() ?? "";
|
|
6166
|
-
const
|
|
6392
|
+
const stream = query.stream ?? "";
|
|
6393
|
+
/**
|
|
6394
|
+
* A filter that keeps only *some* of the lines has to read a wider window than the
|
|
6395
|
+
* display tail, or "the last 500 stderr lines" silently means "the stderr lines among
|
|
6396
|
+
* the last 500 overall" — 50 of them, in a log where every tenth line is stderr, and
|
|
6397
|
+
* 500 exist. `search` already widened for exactly this reason; `stream` is the same
|
|
6398
|
+
* shape of filter and did not, so it was short whenever the two streams interleave.
|
|
6399
|
+
*/
|
|
6400
|
+
const window = search.length > 0 || stream.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
|
|
6167
6401
|
let lines = runtime.logFiles.readTail(id, window);
|
|
6168
|
-
if (
|
|
6402
|
+
if (stream.length > 0) lines = lines.filter((line) => line.stream === stream);
|
|
6403
|
+
const windowLines = lines.length;
|
|
6169
6404
|
if (search.length > 0) {
|
|
6170
6405
|
const needle = search.toLowerCase();
|
|
6171
6406
|
lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
@@ -6175,7 +6410,7 @@ function createLogsRoute(deps) {
|
|
|
6175
6410
|
enabled: info.enabled,
|
|
6176
6411
|
sizeBytes: info.sizeBytes,
|
|
6177
6412
|
files: info.files.map((file) => file.name),
|
|
6178
|
-
searched: search.length > 0 ?
|
|
6413
|
+
searched: search.length > 0 ? windowLines : null,
|
|
6179
6414
|
lines: lines.slice(-tail)
|
|
6180
6415
|
});
|
|
6181
6416
|
}).get("/logs/:id/download", describeRoute({
|
|
@@ -6188,7 +6423,7 @@ function createLogsRoute(deps) {
|
|
|
6188
6423
|
}), validate("query", downloadQuery), validate("param", idParam$2), (c) => {
|
|
6189
6424
|
const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
|
|
6190
6425
|
const { id } = c.req.valid("param");
|
|
6191
|
-
if (!runtime.store.getServer(id)) throw
|
|
6426
|
+
if (!runtime.store.getServer(id)) throw unknownServerError(id);
|
|
6192
6427
|
const requested = c.req.valid("query").file ?? `${id}.log`;
|
|
6193
6428
|
if (!runtime.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
|
|
6194
6429
|
statusCode: 404,
|
|
@@ -6211,13 +6446,14 @@ function createLogsRoute(deps) {
|
|
|
6211
6446
|
}), validate("query", workspaceQuerySchema), validate("param", idParam$2), (c) => {
|
|
6212
6447
|
const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
|
|
6213
6448
|
const { id } = c.req.valid("param");
|
|
6214
|
-
if (!runtime.store.getServer(id)) throw
|
|
6449
|
+
if (!runtime.store.getServer(id)) throw unknownServerError(id);
|
|
6215
6450
|
runtime.logFiles.clear(id);
|
|
6216
6451
|
return c.json({ ok: true });
|
|
6217
6452
|
});
|
|
6218
6453
|
}
|
|
6219
6454
|
var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$2, downloadQuery, historyQuery;
|
|
6220
6455
|
var init_logs$1 = __esmMin((() => {
|
|
6456
|
+
init_action_result();
|
|
6221
6457
|
init_factory();
|
|
6222
6458
|
init_openapi$1();
|
|
6223
6459
|
init_validator();
|
|
@@ -6445,10 +6681,7 @@ function createNotificationsRoute(deps) {
|
|
|
6445
6681
|
responses: {
|
|
6446
6682
|
200: {
|
|
6447
6683
|
description: "Chats",
|
|
6448
|
-
content: jsonBody(
|
|
6449
|
-
id: "string | number",
|
|
6450
|
-
title: "string"
|
|
6451
|
-
}).array() }))
|
|
6684
|
+
content: jsonBody(telegramChatsSchema)
|
|
6452
6685
|
},
|
|
6453
6686
|
400: ERROR_RESPONSES[400]
|
|
6454
6687
|
}
|
|
@@ -6836,19 +7069,35 @@ function sameArg(a, b) {
|
|
|
6836
7069
|
const normalize = (value) => comparable(value).replace(/["\\]/g, "");
|
|
6837
7070
|
return normalize(a) === normalize(b);
|
|
6838
7071
|
}
|
|
6839
|
-
/**
|
|
7072
|
+
/** Whether a value names a file with no directory part, under either platform's separator. */
|
|
7073
|
+
function isBare(value) {
|
|
7074
|
+
return !value.includes("/") && !value.includes("\\");
|
|
7075
|
+
}
|
|
7076
|
+
/**
|
|
7077
|
+
* The same file spelled differently compares equal — but only while one of the two spellings is a
|
|
7078
|
+
* bare name, which is the case the fallback exists for: the config says `node` and the process table
|
|
7079
|
+
* reports `/usr/bin/node`, or the reverse on Windows.
|
|
7080
|
+
*
|
|
7081
|
+
* When **both** spellings name a directory, they are two different files. Comparing basenames
|
|
7082
|
+
* unconditionally made `/tmp/evil/server.js` match `/srv/web/server.js`, and a false match here is
|
|
7083
|
+
* not cosmetic: the panel would treat a process it never spawned as its own entry, so `follow` adopts
|
|
7084
|
+
* a stranger and `reclaim`/`kill` may remove one.
|
|
7085
|
+
*/
|
|
6840
7086
|
function sameWord(a, b) {
|
|
6841
7087
|
const left = comparable(a);
|
|
6842
7088
|
const right = comparable(b);
|
|
6843
|
-
if (left === right
|
|
6844
|
-
|
|
7089
|
+
if (left === right) return true;
|
|
7090
|
+
if (!isBare(left) && !isBare(right)) return false;
|
|
7091
|
+
return path.basename(left) === path.basename(right) || path.extname(b) === "" && path.basename(left, path.extname(left)) === right || path.extname(a) === "" && path.basename(right, path.extname(right)) === left;
|
|
6845
7092
|
}
|
|
6846
7093
|
/**
|
|
6847
7094
|
* True when the argv a process is running is the entry's own: the image must match where
|
|
6848
7095
|
* `spawn` would have looked it up — the image Path, or the first word of the command line
|
|
6849
7096
|
* — and the words after it must open with the entry's args, compared literally. So
|
|
6850
7097
|
* `spawn --port 4000` also covers `spawn -p 4000 --extra`, which is what a self-restarting
|
|
6851
|
-
* wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js
|
|
7098
|
+
* wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js` — two paths that name
|
|
7099
|
+
* a directory are different files, and the basename fallback applies only while one spelling is bare
|
|
7100
|
+
* (see `sameWord`).
|
|
6852
7101
|
*
|
|
6853
7102
|
* `words` must already be the process's own argv. A command-line *string* is only correct
|
|
6854
7103
|
* on Windows, where the OS hands one out; `/proc/<pid>/cmdline` quotes are literal bytes
|
|
@@ -7036,13 +7285,20 @@ async function killPortHolders(port, exclude) {
|
|
|
7036
7285
|
} catch {}
|
|
7037
7286
|
return pids;
|
|
7038
7287
|
}
|
|
7039
|
-
/**
|
|
7040
|
-
|
|
7288
|
+
/**
|
|
7289
|
+
* Signal 0 asks the OS whether the pid still exists, without touching it.
|
|
7290
|
+
*
|
|
7291
|
+
* `EPERM` means the process is there but is **not ours to signal** — a port holder owned by another
|
|
7292
|
+
* user is exactly that. Returning `false` for it made `nannyIsAlive` report a live nanny as gone, so
|
|
7293
|
+
* `resumePersistent` consumed its state file and started a second copy of the entry while the first ran
|
|
7294
|
+
* on untracked. `helpers/daemon.ts` already had this right; the two predicates now agree.
|
|
7295
|
+
*/
|
|
7296
|
+
function isProcessAlive(pid) {
|
|
7041
7297
|
try {
|
|
7042
7298
|
process.kill(pid, 0);
|
|
7043
7299
|
return true;
|
|
7044
|
-
} catch {
|
|
7045
|
-
return
|
|
7300
|
+
} catch (error) {
|
|
7301
|
+
return error.code === "EPERM";
|
|
7046
7302
|
}
|
|
7047
7303
|
}
|
|
7048
7304
|
function signalPid$1(pid, name) {
|
|
@@ -7060,13 +7316,13 @@ function delay$2(ms) {
|
|
|
7060
7316
|
*/
|
|
7061
7317
|
async function terminatePids(pids, options = {}) {
|
|
7062
7318
|
const graceMs = options.graceMs ?? 3e3;
|
|
7063
|
-
const aliveBefore = [...new Set(pids)].filter(isProcessAlive
|
|
7319
|
+
const aliveBefore = [...new Set(pids)].filter(isProcessAlive);
|
|
7064
7320
|
for (const pid of aliveBefore) signalPid$1(pid, "SIGTERM");
|
|
7065
7321
|
const deadline = Date.now() + graceMs;
|
|
7066
|
-
let alive = aliveBefore.filter(isProcessAlive
|
|
7322
|
+
let alive = aliveBefore.filter(isProcessAlive);
|
|
7067
7323
|
while (alive.length > 0 && Date.now() < deadline) {
|
|
7068
7324
|
await delay$2(100);
|
|
7069
|
-
alive = alive.filter(isProcessAlive
|
|
7325
|
+
alive = alive.filter(isProcessAlive);
|
|
7070
7326
|
}
|
|
7071
7327
|
const stopped = aliveBefore.filter((pid) => !alive.includes(pid));
|
|
7072
7328
|
for (const pid of alive) signalPid$1(pid, "SIGKILL");
|
|
@@ -7117,6 +7373,14 @@ async function listPortHolders(port) {
|
|
|
7117
7373
|
return [];
|
|
7118
7374
|
}
|
|
7119
7375
|
}
|
|
7376
|
+
/**
|
|
7377
|
+
* Pids from `lsof -ti` (one per line) or `fuser` (space-separated, on one line, with a leading
|
|
7378
|
+
* space) — both shapes arrive here, which is why the split is on `\s+` rather than `\n`.
|
|
7379
|
+
*
|
|
7380
|
+
* Exported for its test: this feeds `killPortHolders`, which sends SIGKILL, so a parser that
|
|
7381
|
+
* picked up the wrong number would kill an unrelated process. The filter is not decorative —
|
|
7382
|
+
* `Number.parseInt('')` is NaN for the empty leading cell, and a pid of 0 or our own is dropped.
|
|
7383
|
+
*/
|
|
7120
7384
|
function parsePids(stdout) {
|
|
7121
7385
|
return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
|
|
7122
7386
|
}
|
|
@@ -7219,9 +7483,34 @@ function writeNannyState(file, state) {
|
|
|
7219
7483
|
function clearNannyState(file) {
|
|
7220
7484
|
fs.rmSync(file, { force: true });
|
|
7221
7485
|
}
|
|
7222
|
-
/**
|
|
7486
|
+
/**
|
|
7487
|
+
* The same wording the supervisor uses for a child's exit, so history reads alike — plus a hint
|
|
7488
|
+
* when the code has a conventional meaning, because `code 137` alone tells an operator nothing and
|
|
7489
|
+
* that is the one they most want explained (a container limit or the kernel OOM killer).
|
|
7490
|
+
*
|
|
7491
|
+
* The hint is worded as a possibility, never a claim: 128+N is a shell convention, and a program
|
|
7492
|
+
* is free to exit 137 for its own reasons — a JVM reports OOM that way itself. 126 and 127 are
|
|
7493
|
+
* firmer, since the shell sets those when it cannot run the command at all.
|
|
7494
|
+
*/
|
|
7223
7495
|
function describeNannyExit(exit) {
|
|
7224
|
-
|
|
7496
|
+
if (exit.signal !== null) return `signal ${exit.signal}`;
|
|
7497
|
+
const hint = exitCodeHint(exit.code);
|
|
7498
|
+
return hint === null ? `code ${exit.code}` : `code ${exit.code} (${hint})`;
|
|
7499
|
+
}
|
|
7500
|
+
/** What a conventional exit code usually means, or null when there is nothing to add. */
|
|
7501
|
+
function exitCodeHint(code) {
|
|
7502
|
+
if (code === null || code === 0) return null;
|
|
7503
|
+
if (code === 126) return "the command is not executable";
|
|
7504
|
+
if (code === 127) return "the command was not found";
|
|
7505
|
+
if (code <= 128 || code > 192) return null;
|
|
7506
|
+
const signal = signalName(code - 128);
|
|
7507
|
+
if (signal === null) return null;
|
|
7508
|
+
return code === 137 ? `${signal} — which the kernel sends when memory runs out` : `${signal}, if the shell reported the kill`;
|
|
7509
|
+
}
|
|
7510
|
+
/** `SIGKILL` for 9, or null when the number names nothing. */
|
|
7511
|
+
function signalName(number) {
|
|
7512
|
+
for (const [name, value] of Object.entries(os.constants.signals)) if (value === number) return name;
|
|
7513
|
+
return null;
|
|
7225
7514
|
}
|
|
7226
7515
|
/**
|
|
7227
7516
|
* Is the nanny this state describes still ours? A live pid proves nothing on its
|
|
@@ -7230,7 +7519,7 @@ function describeNannyExit(exit) {
|
|
|
7230
7519
|
* everywhere else, Windows included.
|
|
7231
7520
|
*/
|
|
7232
7521
|
async function nannyIsAlive(state, id) {
|
|
7233
|
-
if (state.nannyPid <= 0 || !isProcessAlive
|
|
7522
|
+
if (state.nannyPid <= 0 || !isProcessAlive(state.nannyPid)) return false;
|
|
7234
7523
|
if (Date.now() - state.heartbeatAt <= 3e4) return true;
|
|
7235
7524
|
if (await processCarriesServerId(state.nannyPid, id)) return true;
|
|
7236
7525
|
const words = await processArgv(state.nannyPid);
|
|
@@ -7460,8 +7749,8 @@ function matchFor(route) {
|
|
|
7460
7749
|
const match = { host: [route.host] };
|
|
7461
7750
|
const prefix = route.path.trim();
|
|
7462
7751
|
if (prefix.length > 0) {
|
|
7463
|
-
const base = prefix.startsWith("/") ? prefix : `/${prefix}
|
|
7464
|
-
match.path = base ===
|
|
7752
|
+
const base = (prefix.startsWith("/") ? prefix : `/${prefix}`).replace(/\/+$/, "");
|
|
7753
|
+
match.path = base.length === 0 ? ["/*"] : [base, `${base}/*`];
|
|
7465
7754
|
}
|
|
7466
7755
|
return match;
|
|
7467
7756
|
}
|
|
@@ -7590,6 +7879,24 @@ var init_proxy_config = __esmMin((() => {
|
|
|
7590
7879
|
}));
|
|
7591
7880
|
//#endregion
|
|
7592
7881
|
//#region src/services/tls.ts
|
|
7882
|
+
/** Whether the certificate's validity window covers now. Both ends, from one place. */
|
|
7883
|
+
function validityError(x509, now = Date.now()) {
|
|
7884
|
+
if (new Date(x509.validFrom).getTime() > now) return `the certificate is not valid until ${x509.validFrom}`;
|
|
7885
|
+
if (new Date(x509.validTo).getTime() < now) return `the certificate expired on ${x509.validTo}`;
|
|
7886
|
+
return null;
|
|
7887
|
+
}
|
|
7888
|
+
/** Whether the private key is the one that certificate was issued for. */
|
|
7889
|
+
function publicKeysMatch(certificate, privateKey) {
|
|
7890
|
+
const fromKey = createPublicKey(createPrivateKey(privateKey)).export({
|
|
7891
|
+
type: "spki",
|
|
7892
|
+
format: "der"
|
|
7893
|
+
});
|
|
7894
|
+
const fromCert = new X509Certificate(certificate).publicKey.export({
|
|
7895
|
+
type: "spki",
|
|
7896
|
+
format: "der"
|
|
7897
|
+
});
|
|
7898
|
+
return Buffer$1.from(fromKey).equals(Buffer$1.from(fromCert));
|
|
7899
|
+
}
|
|
7593
7900
|
/** Checks the certificate parses, is time-valid, and matches the private key. */
|
|
7594
7901
|
function validatePair(certificate, privateKey) {
|
|
7595
7902
|
let x509;
|
|
@@ -7602,16 +7909,7 @@ function validatePair(certificate, privateKey) {
|
|
|
7602
7909
|
};
|
|
7603
7910
|
}
|
|
7604
7911
|
try {
|
|
7605
|
-
|
|
7606
|
-
const fromKey = createPublicKey(key).export({
|
|
7607
|
-
type: "spki",
|
|
7608
|
-
format: "der"
|
|
7609
|
-
});
|
|
7610
|
-
const fromCert = x509.publicKey.export({
|
|
7611
|
-
type: "spki",
|
|
7612
|
-
format: "der"
|
|
7613
|
-
});
|
|
7614
|
-
if (!Buffer$1.from(fromKey).equals(Buffer$1.from(fromCert))) return {
|
|
7912
|
+
if (!publicKeysMatch(certificate, privateKey)) return {
|
|
7615
7913
|
ok: false,
|
|
7616
7914
|
error: "the private key does not match the certificate"
|
|
7617
7915
|
};
|
|
@@ -7621,15 +7919,17 @@ function validatePair(certificate, privateKey) {
|
|
|
7621
7919
|
error: `private key is not a valid PEM: ${error instanceof Error ? error.message : String(error)}`
|
|
7622
7920
|
};
|
|
7623
7921
|
}
|
|
7624
|
-
|
|
7922
|
+
const invalid = validityError(x509);
|
|
7923
|
+
if (invalid !== null) return {
|
|
7625
7924
|
ok: false,
|
|
7626
|
-
error:
|
|
7925
|
+
error: invalid
|
|
7627
7926
|
};
|
|
7628
7927
|
return { ok: true };
|
|
7629
7928
|
}
|
|
7630
7929
|
var TlsStore;
|
|
7631
7930
|
var init_tls$1 = __esmMin((() => {
|
|
7632
7931
|
init_atomic();
|
|
7932
|
+
init_logger();
|
|
7633
7933
|
TlsStore = class {
|
|
7634
7934
|
dir;
|
|
7635
7935
|
name;
|
|
@@ -7690,6 +7990,25 @@ var init_tls$1 = __esmMin((() => {
|
|
|
7690
7990
|
this.cachedMtime = "";
|
|
7691
7991
|
return { ok: true };
|
|
7692
7992
|
}
|
|
7993
|
+
/**
|
|
7994
|
+
* The pair to serve, or `null` when there is nothing servable.
|
|
7995
|
+
*
|
|
7996
|
+
* `load()` returns whatever is on disk, and `serve({ tls })` **throws synchronously** on a pair that
|
|
7997
|
+
* does not match (`ERR_OSSL_X509_KEY_VALUES_MISMATCH`) — before `listen()`'s `ready()`/`error` race
|
|
7998
|
+
* can catch it. A mismatched pair therefore fails the reboot: hand-copied files, a restore, or a
|
|
7999
|
+
* crash between `save()`'s two writes all reach it, and the panel reports an OpenSSL code instead of
|
|
8000
|
+
* booting on http or naming the problem. Validated here, where the answer is still recoverable.
|
|
8001
|
+
*/
|
|
8002
|
+
servable() {
|
|
8003
|
+
const pair = this.load();
|
|
8004
|
+
if (pair === null) return null;
|
|
8005
|
+
const validation = validatePair(pair.cert, pair.key);
|
|
8006
|
+
if (!validation.ok) {
|
|
8007
|
+
logger.warn(`the stored TLS pair will not be served: ${validation.error ?? "it is not usable"}`);
|
|
8008
|
+
return null;
|
|
8009
|
+
}
|
|
8010
|
+
return pair;
|
|
8011
|
+
}
|
|
7693
8012
|
clear() {
|
|
7694
8013
|
for (const file of [this.certPath, this.keyPath]) try {
|
|
7695
8014
|
fs.rmSync(file, { force: true });
|
|
@@ -7722,6 +8041,7 @@ var init_tls$1 = __esmMin((() => {
|
|
|
7722
8041
|
const x509 = new X509Certificate(pair.cert);
|
|
7723
8042
|
const validTo = new Date(x509.validTo);
|
|
7724
8043
|
const daysRemaining = Math.floor((validTo.getTime() - Date.now()) / 864e5);
|
|
8044
|
+
const keyMatches = publicKeysMatch(pair.cert, pair.key);
|
|
7725
8045
|
return {
|
|
7726
8046
|
...base,
|
|
7727
8047
|
subject: x509.subject.replace(/\n/g, ", "),
|
|
@@ -7730,8 +8050,8 @@ var init_tls$1 = __esmMin((() => {
|
|
|
7730
8050
|
validTo: validTo.toISOString(),
|
|
7731
8051
|
daysRemaining,
|
|
7732
8052
|
fingerprint: x509.fingerprint256,
|
|
7733
|
-
keyMatches
|
|
7734
|
-
error:
|
|
8053
|
+
keyMatches,
|
|
8054
|
+
error: validityError(x509) ?? (keyMatches ? null : "the private key does not match the certificate")
|
|
7735
8055
|
};
|
|
7736
8056
|
} catch (error) {
|
|
7737
8057
|
return {
|
|
@@ -7743,6 +8063,30 @@ var init_tls$1 = __esmMin((() => {
|
|
|
7743
8063
|
};
|
|
7744
8064
|
}));
|
|
7745
8065
|
//#endregion
|
|
8066
|
+
//#region src/shared/proxy-form.ts
|
|
8067
|
+
/** A name an ACME CA could issue for; anything else gets the engine's own CA. */
|
|
8068
|
+
function isPublicHost(host) {
|
|
8069
|
+
const name = host.trim().toLowerCase();
|
|
8070
|
+
if (name.length === 0 || name === "localhost" || !name.includes(".")) return false;
|
|
8071
|
+
if (LOCAL_TLDS.some((tld) => name.endsWith(tld))) return false;
|
|
8072
|
+
return !/^\d{1,3}(?:\.\d{1,3}){3,}$/.test(name);
|
|
8073
|
+
}
|
|
8074
|
+
var LOCAL_TLDS;
|
|
8075
|
+
var init_proxy_form = __esmMin((() => {
|
|
8076
|
+
init_contracts();
|
|
8077
|
+
LOCAL_TLDS = [
|
|
8078
|
+
".localhost",
|
|
8079
|
+
".local",
|
|
8080
|
+
".internal",
|
|
8081
|
+
".home.arpa",
|
|
8082
|
+
".lan",
|
|
8083
|
+
".home",
|
|
8084
|
+
".test",
|
|
8085
|
+
".invalid",
|
|
8086
|
+
".example"
|
|
8087
|
+
];
|
|
8088
|
+
}));
|
|
8089
|
+
//#endregion
|
|
7746
8090
|
//#region src/services/proxy.ts
|
|
7747
8091
|
/** The DNS names a certificate covers, from its SANs (and its subject as a fallback). */
|
|
7748
8092
|
function namesOf(x509) {
|
|
@@ -7784,7 +8128,9 @@ function retryWindowMinutes(lifetimeMs) {
|
|
|
7784
8128
|
function parseUpstream(value) {
|
|
7785
8129
|
const trimmed = value.trim();
|
|
7786
8130
|
if (trimmed.length === 0) return null;
|
|
7787
|
-
const
|
|
8131
|
+
const explicit = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed);
|
|
8132
|
+
if (explicit !== null && !/^https?$/i.test(explicit[1])) return null;
|
|
8133
|
+
const withScheme = explicit === null ? `http://${trimmed}` : trimmed;
|
|
7788
8134
|
try {
|
|
7789
8135
|
const url = new URL(withScheme);
|
|
7790
8136
|
const port = url.port.length > 0 ? url.port : url.protocol === "https:" ? "443" : "80";
|
|
@@ -7850,12 +8196,6 @@ function validateProxyConfig(config, dnsAccount) {
|
|
|
7850
8196
|
}
|
|
7851
8197
|
return errors;
|
|
7852
8198
|
}
|
|
7853
|
-
function isPublicHost(host) {
|
|
7854
|
-
const name = host.toLowerCase();
|
|
7855
|
-
if (name === "localhost" || !name.includes(".")) return false;
|
|
7856
|
-
if (LOCAL_TLDS.some((tld) => name.endsWith(tld))) return false;
|
|
7857
|
-
return !/^\d{1,3}(?:\.\d{1,3}){3,}$/.test(name);
|
|
7858
|
-
}
|
|
7859
8199
|
/** Caddy answers with `{"error": "..."}`; that sentence is the useful part. */
|
|
7860
8200
|
function engineMessage(body) {
|
|
7861
8201
|
try {
|
|
@@ -7865,7 +8205,7 @@ function engineMessage(body) {
|
|
|
7865
8205
|
return body.trim().length > 0 ? body.trim().slice(0, 400) : null;
|
|
7866
8206
|
}
|
|
7867
8207
|
}
|
|
7868
|
-
var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, ENGINE_COMMAND_TIMEOUT_MS, DNS01_MODULE, engineRecordSchema, challengeAuthSchema, appliedRecordSchema, adminRecordSchema, ProxyService
|
|
8208
|
+
var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, ENGINE_COMMAND_TIMEOUT_MS, DNS01_MODULE, engineRecordSchema, challengeAuthSchema, appliedRecordSchema, adminRecordSchema, ProxyService;
|
|
7869
8209
|
var init_proxy$1 = __esmMin((() => {
|
|
7870
8210
|
init_atomic();
|
|
7871
8211
|
init_logger();
|
|
@@ -7878,6 +8218,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7878
8218
|
init_proxy$2();
|
|
7879
8219
|
init_proxy_config();
|
|
7880
8220
|
init_tls$1();
|
|
8221
|
+
init_proxy_form();
|
|
7881
8222
|
PROXY_ID = "proxy";
|
|
7882
8223
|
ADMIN_TIMEOUT_MS = 5e3;
|
|
7883
8224
|
READY_TIMEOUT_MS = 2e4;
|
|
@@ -7914,6 +8255,8 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7914
8255
|
nanny = null;
|
|
7915
8256
|
admin = null;
|
|
7916
8257
|
lastError = null;
|
|
8258
|
+
/** The parsed failure map, keyed on the log's identity — see `obtainFailures`. */
|
|
8259
|
+
failureCache = null;
|
|
7917
8260
|
/** The installed binary's modules, read once per process; an install clears it. */
|
|
7918
8261
|
moduleCache = null;
|
|
7919
8262
|
/** A legacy `engine.json` is reported once, not on every state frame. */
|
|
@@ -8342,15 +8685,41 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8342
8685
|
/**
|
|
8343
8686
|
* The last thing the engine said about obtaining a certificate, per name. Caddy
|
|
8344
8687
|
* has no admin endpoint that lists failures, so its own log is the source.
|
|
8688
|
+
*
|
|
8689
|
+
* Memoised on the file's identity and mtime, because this is called **per route** from
|
|
8690
|
+
* `routeViews()` — which `view()` runs for every state frame and `sync()` runs every tick.
|
|
8691
|
+
* Each call read the whole log (bounded at 2 MB by rotation), split every line and kept the
|
|
8692
|
+
* last 400: ~1.3 ms each, so ~13 ms of blocking synchronous I/O per second at ten routes,
|
|
8693
|
+
* for a file that only changes when the engine tries to obtain a certificate.
|
|
8694
|
+
*
|
|
8695
|
+
* The key is `ino` + `mtimeMs` + size, not mtime alone: rotation replaces the file, and a
|
|
8696
|
+
* replacement can land in the same millisecond as the previous read.
|
|
8345
8697
|
*/
|
|
8346
8698
|
obtainFailures() {
|
|
8347
|
-
const
|
|
8699
|
+
const file = path.join(this.options.logDir, `${PROXY_ID}.log`);
|
|
8700
|
+
let stamp;
|
|
8701
|
+
try {
|
|
8702
|
+
const stats = fs.statSync(file);
|
|
8703
|
+
stamp = `${stats.ino}:${stats.mtimeMs}:${stats.size}`;
|
|
8704
|
+
} catch {
|
|
8705
|
+
this.failureCache = null;
|
|
8706
|
+
return /* @__PURE__ */ new Map();
|
|
8707
|
+
}
|
|
8708
|
+
if (this.failureCache !== null && this.failureCache.stamp === stamp) return this.failureCache.failures;
|
|
8348
8709
|
let text;
|
|
8349
8710
|
try {
|
|
8350
|
-
text = fs.readFileSync(
|
|
8711
|
+
text = fs.readFileSync(file, "utf8");
|
|
8351
8712
|
} catch {
|
|
8352
|
-
return
|
|
8713
|
+
return /* @__PURE__ */ new Map();
|
|
8353
8714
|
}
|
|
8715
|
+
const failures = this.parseFailures(text, /* @__PURE__ */ new Map());
|
|
8716
|
+
this.failureCache = {
|
|
8717
|
+
stamp,
|
|
8718
|
+
failures
|
|
8719
|
+
};
|
|
8720
|
+
return failures;
|
|
8721
|
+
}
|
|
8722
|
+
parseFailures(text, failures) {
|
|
8354
8723
|
for (const line of text.trimEnd().split("\n").slice(-400)) {
|
|
8355
8724
|
let inner;
|
|
8356
8725
|
try {
|
|
@@ -8412,12 +8781,12 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8412
8781
|
engineRunning() {
|
|
8413
8782
|
const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
|
|
8414
8783
|
if (state === null || state.serverId !== PROXY_ID) return false;
|
|
8415
|
-
return [state.nannyPid, state.childPid].some((pid) => pid !== null && pid > 0 && isProcessAlive
|
|
8784
|
+
return [state.nannyPid, state.childPid].some((pid) => pid !== null && pid > 0 && isProcessAlive(pid));
|
|
8416
8785
|
}
|
|
8417
8786
|
live() {
|
|
8418
8787
|
const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
|
|
8419
8788
|
if (state === null || state.serverId !== PROXY_ID) return false;
|
|
8420
|
-
return state.nannyPid > 0 && isProcessAlive
|
|
8789
|
+
return state.nannyPid > 0 && isProcessAlive(state.nannyPid) && Date.now() - state.heartbeatAt <= 3e4;
|
|
8421
8790
|
}
|
|
8422
8791
|
status() {
|
|
8423
8792
|
const config = this.config;
|
|
@@ -8443,7 +8812,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8443
8812
|
lastError: "the proxy engine is not installed"
|
|
8444
8813
|
};
|
|
8445
8814
|
const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
|
|
8446
|
-
if (state === null || state.serverId !== PROXY_ID || state.nannyPid <= 0 || !isProcessAlive
|
|
8815
|
+
if (state === null || state.serverId !== PROXY_ID || state.nannyPid <= 0 || !isProcessAlive(state.nannyPid)) {
|
|
8447
8816
|
const lastExit = state?.lastExit;
|
|
8448
8817
|
const detail = lastExit === void 0 ? null : `the engine exited with ${lastExit.signal === null ? `code ${lastExit.code}` : `signal ${lastExit.signal}`}`;
|
|
8449
8818
|
const lastError = this.lastError ?? detail;
|
|
@@ -9243,7 +9612,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
9243
9612
|
if (state !== null && state.serverId === PROXY_ID) {
|
|
9244
9613
|
const pids = [state.nannyPid, state.childPid].filter((pid) => pid !== null && pid > 0);
|
|
9245
9614
|
for (const pid of pids) {
|
|
9246
|
-
if (!isProcessAlive
|
|
9615
|
+
if (!isProcessAlive(pid)) continue;
|
|
9247
9616
|
try {
|
|
9248
9617
|
process.kill(pid, "SIGTERM");
|
|
9249
9618
|
} catch (error) {
|
|
@@ -9251,8 +9620,8 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
9251
9620
|
}
|
|
9252
9621
|
}
|
|
9253
9622
|
const deadline = Date.now() + (this.options.stopGraceMs ?? 1e4);
|
|
9254
|
-
while (Date.now() < deadline && pids.some((pid) => isProcessAlive
|
|
9255
|
-
for (const pid of pids.filter(isProcessAlive
|
|
9623
|
+
while (Date.now() < deadline && pids.some((pid) => isProcessAlive(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
9624
|
+
for (const pid of pids.filter(isProcessAlive)) try {
|
|
9256
9625
|
process.kill(pid, "SIGKILL");
|
|
9257
9626
|
} catch {}
|
|
9258
9627
|
fs.rmSync(statePath, { force: true });
|
|
@@ -9410,17 +9779,6 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
9410
9779
|
}
|
|
9411
9780
|
}
|
|
9412
9781
|
};
|
|
9413
|
-
LOCAL_TLDS = [
|
|
9414
|
-
".localhost",
|
|
9415
|
-
".local",
|
|
9416
|
-
".internal",
|
|
9417
|
-
".home.arpa",
|
|
9418
|
-
".lan",
|
|
9419
|
-
".home",
|
|
9420
|
-
".test",
|
|
9421
|
-
".invalid",
|
|
9422
|
-
".example"
|
|
9423
|
-
];
|
|
9424
9782
|
}));
|
|
9425
9783
|
//#endregion
|
|
9426
9784
|
//#region src/api/proxy.ts
|
|
@@ -9631,12 +9989,6 @@ var init_proxy = __esmMin((() => {
|
|
|
9631
9989
|
}));
|
|
9632
9990
|
//#endregion
|
|
9633
9991
|
//#region src/api/servers/$.routes.ts
|
|
9634
|
-
function unknownServer(id) {
|
|
9635
|
-
return new DetailedError(`unknown server "${id}"`, {
|
|
9636
|
-
statusCode: 404,
|
|
9637
|
-
code: "UNKNOWN_SERVER"
|
|
9638
|
-
});
|
|
9639
|
-
}
|
|
9640
9992
|
function createServersRoute(deps) {
|
|
9641
9993
|
return appFactory.createApp().get("/", describeRoute({
|
|
9642
9994
|
tags: ["servers"],
|
|
@@ -9705,7 +10057,7 @@ function createServersRoute(deps) {
|
|
|
9705
10057
|
const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
|
|
9706
10058
|
const { id } = c.req.valid("param");
|
|
9707
10059
|
const server = runtime.supervisor.views().find((entry) => entry.id === id);
|
|
9708
|
-
if (!server) throw
|
|
10060
|
+
if (!server) throw unknownServerError(id);
|
|
9709
10061
|
return c.json({ server });
|
|
9710
10062
|
}).get("/:id/logs", describeRoute({
|
|
9711
10063
|
tags: ["servers"],
|
|
@@ -9717,7 +10069,7 @@ function createServersRoute(deps) {
|
|
|
9717
10069
|
}), validate("query", bufferedLogsQuery), validate("param", idParam$1), (c) => {
|
|
9718
10070
|
const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
|
|
9719
10071
|
const { id } = c.req.valid("param");
|
|
9720
|
-
if (!runtime.store.getServer(id)) throw
|
|
10072
|
+
if (!runtime.store.getServer(id)) throw unknownServerError(id);
|
|
9721
10073
|
const { limit } = c.req.valid("query");
|
|
9722
10074
|
const parsed = limit === void 0 ? NaN : Number.parseInt(limit, 10);
|
|
9723
10075
|
const bounded = Number.isNaN(parsed) ? void 0 : Math.min(Math.max(parsed, 1), 1e5);
|
|
@@ -9732,7 +10084,7 @@ function createServersRoute(deps) {
|
|
|
9732
10084
|
}), validate("query", workspaceQuerySchema), validate("param", idParam$1), (c) => {
|
|
9733
10085
|
const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
|
|
9734
10086
|
const { id } = c.req.valid("param");
|
|
9735
|
-
if (!runtime.store.getServer(id)) throw
|
|
10087
|
+
if (!runtime.store.getServer(id)) throw unknownServerError(id);
|
|
9736
10088
|
return streamSSE(c, async (stream) => {
|
|
9737
10089
|
let closed = false;
|
|
9738
10090
|
let pending = 0;
|
|
@@ -9858,7 +10210,7 @@ function createServersRoute(deps) {
|
|
|
9858
10210
|
return c.json({ server: runtime.store.updateServer(c.req.valid("param").id, c.req.valid("json")) });
|
|
9859
10211
|
} catch (error) {
|
|
9860
10212
|
if (error instanceof ConfigError) {
|
|
9861
|
-
const status = error.
|
|
10213
|
+
const status = error.code === "UNKNOWN_SERVER" ? 404 : 400;
|
|
9862
10214
|
throw new DetailedError(error.message, {
|
|
9863
10215
|
statusCode: status,
|
|
9864
10216
|
code: status === 404 ? "UNKNOWN_SERVER" : "INVALID_SERVER"
|
|
@@ -9884,7 +10236,7 @@ function createServersRoute(deps) {
|
|
|
9884
10236
|
runtime.store.removeServer(id);
|
|
9885
10237
|
return c.json({ ok: true });
|
|
9886
10238
|
} catch (error) {
|
|
9887
|
-
if (error instanceof ConfigError) throw
|
|
10239
|
+
if (error instanceof ConfigError) throw unknownServerError(id);
|
|
9888
10240
|
throw error;
|
|
9889
10241
|
}
|
|
9890
10242
|
});
|
|
@@ -10019,47 +10371,66 @@ function createSettingsRoute(deps) {
|
|
|
10019
10371
|
rebinding: endpointChanged || proxyChanged || tlsChanged,
|
|
10020
10372
|
targetUrl
|
|
10021
10373
|
});
|
|
10022
|
-
}).post(
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
|
|
10044
|
-
|
|
10045
|
-
|
|
10046
|
-
|
|
10047
|
-
|
|
10048
|
-
|
|
10049
|
-
|
|
10374
|
+
}).post(
|
|
10375
|
+
"/settings/ui",
|
|
10376
|
+
describeRoute({
|
|
10377
|
+
tags: ["panel"],
|
|
10378
|
+
summary: "Replace the panel UI with an uploaded static build",
|
|
10379
|
+
responses: {
|
|
10380
|
+
200: { description: "Installed" },
|
|
10381
|
+
400: ERROR_RESPONSES[400],
|
|
10382
|
+
413: ERROR_RESPONSES[413]
|
|
10383
|
+
}
|
|
10384
|
+
}),
|
|
10385
|
+
/**
|
|
10386
|
+
* Bound the body **while it arrives**, not after. The checks below read `Content-Length`, which a
|
|
10387
|
+
* chunked request does not send, and compare `file.size` only after `parseBody()` has buffered
|
|
10388
|
+
* everything — so an upload with no declared length was read into memory in full and then refused.
|
|
10389
|
+
* See the same reasoning in `api/backups.ts`.
|
|
10390
|
+
*/
|
|
10391
|
+
bodyLimit({
|
|
10392
|
+
maxSize: MAX_UI_UPLOAD_BYTES,
|
|
10393
|
+
onError: () => {
|
|
10394
|
+
throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
10395
|
+
statusCode: 413,
|
|
10396
|
+
code: "UPLOAD_TOO_LARGE"
|
|
10397
|
+
});
|
|
10398
|
+
}
|
|
10399
|
+
}),
|
|
10400
|
+
async (c) => {
|
|
10401
|
+
const declared = Number.parseInt(c.req.header("content-length") ?? "0", 10);
|
|
10402
|
+
if (Number.isFinite(declared) && declared > MAX_UI_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
10403
|
+
statusCode: 413,
|
|
10404
|
+
code: "UPLOAD_TOO_LARGE"
|
|
10405
|
+
});
|
|
10406
|
+
const file = (await c.req.parseBody()).file;
|
|
10407
|
+
if (!(file instanceof File)) throw new DetailedError("expected a `file` field with the UI archive", {
|
|
10050
10408
|
statusCode: 400,
|
|
10051
|
-
code: "
|
|
10409
|
+
code: "MISSING_FILE"
|
|
10052
10410
|
});
|
|
10053
|
-
|
|
10054
|
-
|
|
10055
|
-
|
|
10056
|
-
meta: result.meta,
|
|
10057
|
-
ui: deps.ui.status()
|
|
10411
|
+
if (file.size > MAX_UI_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
|
|
10412
|
+
statusCode: 413,
|
|
10413
|
+
code: "UPLOAD_TOO_LARGE"
|
|
10058
10414
|
});
|
|
10059
|
-
|
|
10060
|
-
|
|
10415
|
+
const staging = path.join(path.dirname(deps.ui.directory), `.ui-upload-${Date.now()}.zip`);
|
|
10416
|
+
try {
|
|
10417
|
+
await fs.promises.writeFile(staging, Buffer$1.from(await file.arrayBuffer()));
|
|
10418
|
+
const result = await deps.ui.install(staging, sanitizeName(file.name));
|
|
10419
|
+
if (!result.ok) throw new DetailedError(result.error, {
|
|
10420
|
+
statusCode: 400,
|
|
10421
|
+
code: "INVALID_UI"
|
|
10422
|
+
});
|
|
10423
|
+
logger.info(`UI replaced with ${result.meta.name} (${result.meta.files} files)`);
|
|
10424
|
+
return c.json({
|
|
10425
|
+
ok: true,
|
|
10426
|
+
meta: result.meta,
|
|
10427
|
+
ui: deps.ui.status()
|
|
10428
|
+
});
|
|
10429
|
+
} finally {
|
|
10430
|
+
fs.rmSync(staging, { force: true });
|
|
10431
|
+
}
|
|
10061
10432
|
}
|
|
10062
|
-
|
|
10433
|
+
).delete("/settings/ui", describeRoute({
|
|
10063
10434
|
tags: ["panel"],
|
|
10064
10435
|
summary: "Go back to the stock UI",
|
|
10065
10436
|
responses: { 200: { description: "Reverted" } }
|
|
@@ -10263,7 +10634,9 @@ function createTlsRoute(deps) {
|
|
|
10263
10634
|
}), (c) => {
|
|
10264
10635
|
deps.tls.clear();
|
|
10265
10636
|
if (deps.panel.settings.control.tls.enabled) afterResponse(async () => {
|
|
10266
|
-
await deps.controlServer.restart();
|
|
10637
|
+
const result = await deps.controlServer.restart();
|
|
10638
|
+
if (!result.ok) logger.error(`could not reload TLS after removing the pair: ${result.error ?? "unknown error"}`);
|
|
10639
|
+
else logger.info(`control panel reloaded — ${deps.panel.settings.control.tls.enabled && deps.tls.servable() !== null ? "https" : "http"}`);
|
|
10267
10640
|
}, (error) => logger.error("tls reload failed", error));
|
|
10268
10641
|
return c.json(review());
|
|
10269
10642
|
});
|
|
@@ -10282,8 +10655,8 @@ var init_tls = __esmMin((() => {
|
|
|
10282
10655
|
/** A registry failure is an API failure: unknown ids are 404, conflicts 409, the rest 400. */
|
|
10283
10656
|
function workspaceFailure(error) {
|
|
10284
10657
|
if (error instanceof WorkspaceError) {
|
|
10285
|
-
const unknown = error.
|
|
10286
|
-
const conflict = error.
|
|
10658
|
+
const unknown = error.code === "UNKNOWN_WORKSPACE";
|
|
10659
|
+
const conflict = error.code === "WORKSPACE_EXISTS";
|
|
10287
10660
|
throw new DetailedError(error.message, {
|
|
10288
10661
|
statusCode: unknown ? 404 : conflict ? 409 : 400,
|
|
10289
10662
|
code: unknown ? "UNKNOWN_WORKSPACE" : conflict ? "WORKSPACE_EXISTS" : "INVALID_WORKSPACE"
|
|
@@ -10426,18 +10799,11 @@ var init_error = __esmMin((() => {
|
|
|
10426
10799
|
* UI author has to be able to read it before they can log in.
|
|
10427
10800
|
*/
|
|
10428
10801
|
/** The version the package was built with, so the spec never drifts from it. */
|
|
10429
|
-
function packageVersion$1() {
|
|
10430
|
-
try {
|
|
10431
|
-
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
10432
|
-
} catch {
|
|
10433
|
-
return "0.0.0";
|
|
10434
|
-
}
|
|
10435
|
-
}
|
|
10436
10802
|
function setupOpenAPI(app) {
|
|
10437
10803
|
return appFactory.createApp().get(`${PREFIX}/spec.json`, openAPIRouteHandler(app, { documentation: {
|
|
10438
10804
|
info: {
|
|
10439
10805
|
title: "home-hosted",
|
|
10440
|
-
version:
|
|
10806
|
+
version: appVersion(),
|
|
10441
10807
|
description: "Control plane for the processes you host at home: servers, logs, vitals, backups and settings."
|
|
10442
10808
|
},
|
|
10443
10809
|
tags: [
|
|
@@ -10478,6 +10844,7 @@ function setupOpenAPI(app) {
|
|
|
10478
10844
|
var PREFIX;
|
|
10479
10845
|
var init_openapi = __esmMin((() => {
|
|
10480
10846
|
init_factory();
|
|
10847
|
+
init_version();
|
|
10481
10848
|
PREFIX = "/openapi";
|
|
10482
10849
|
}));
|
|
10483
10850
|
//#endregion
|
|
@@ -10530,7 +10897,7 @@ var layout_exports = /* @__PURE__ */ __exportAll({
|
|
|
10530
10897
|
function readJson(file) {
|
|
10531
10898
|
try {
|
|
10532
10899
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
10533
|
-
return
|
|
10900
|
+
return isRecord(parsed) ? parsed : null;
|
|
10534
10901
|
} catch {
|
|
10535
10902
|
return null;
|
|
10536
10903
|
}
|
|
@@ -10736,6 +11103,7 @@ var EMPTY, KNOWN_TOP_LEVEL;
|
|
|
10736
11103
|
var init_layout = __esmMin((() => {
|
|
10737
11104
|
init_logger();
|
|
10738
11105
|
init_paths();
|
|
11106
|
+
init_shape();
|
|
10739
11107
|
EMPTY = {
|
|
10740
11108
|
migrated: false,
|
|
10741
11109
|
moved: [],
|
|
@@ -10784,15 +11152,6 @@ function clearRuntime() {
|
|
|
10784
11152
|
function newToken() {
|
|
10785
11153
|
return randomBytes(32).toString("base64url");
|
|
10786
11154
|
}
|
|
10787
|
-
/** Signal 0 only probes the pid; `EPERM` still means the process is there. */
|
|
10788
|
-
function isProcessAlive(pid) {
|
|
10789
|
-
try {
|
|
10790
|
-
process.kill(pid, 0);
|
|
10791
|
-
return true;
|
|
10792
|
-
} catch (error) {
|
|
10793
|
-
return error.code === "EPERM";
|
|
10794
|
-
}
|
|
10795
|
-
}
|
|
10796
11155
|
/** A degraded panel is still answering: a 503 must not read as "not running". */
|
|
10797
11156
|
async function probeRuntime(runtime, timeoutMs = 2500) {
|
|
10798
11157
|
const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
|
|
@@ -10872,6 +11231,7 @@ var runtimeSchema;
|
|
|
10872
11231
|
var init_daemon = __esmMin((() => {
|
|
10873
11232
|
init_atomic();
|
|
10874
11233
|
init_paths();
|
|
11234
|
+
init_port();
|
|
10875
11235
|
runtimeSchema = type({
|
|
10876
11236
|
version: "string",
|
|
10877
11237
|
pid: "number.integer >= 1",
|
|
@@ -10932,15 +11292,9 @@ function failure(status, message) {
|
|
|
10932
11292
|
headers: { "content-type": "application/json" }
|
|
10933
11293
|
});
|
|
10934
11294
|
}
|
|
10935
|
-
/** Constant-time, and length-safe: `timingSafeEqual` throws on differing lengths. */
|
|
10936
|
-
function matches(actual, expected) {
|
|
10937
|
-
const left = Buffer$1.from(actual, "utf8");
|
|
10938
|
-
const right = Buffer$1.from(expected, "utf8");
|
|
10939
|
-
if (left.length !== right.length) return false;
|
|
10940
|
-
return crypto.timingSafeEqual(left, right);
|
|
10941
|
-
}
|
|
10942
11295
|
var proxyMessageSchema, AcmeChallengeService;
|
|
10943
11296
|
var init_acme_challenge = __esmMin((() => {
|
|
11297
|
+
init_secret_compare();
|
|
10944
11298
|
init_ddns$2();
|
|
10945
11299
|
init_types();
|
|
10946
11300
|
proxyMessageSchema = type({
|
|
@@ -11005,7 +11359,9 @@ var init_acme_challenge = __esmMin((() => {
|
|
|
11005
11359
|
if (separator < 0) return false;
|
|
11006
11360
|
const user = decoded.slice(0, separator);
|
|
11007
11361
|
const password = decoded.slice(separator + 1);
|
|
11008
|
-
|
|
11362
|
+
const userOk = secretEqual(user, auth.username);
|
|
11363
|
+
const passwordOk = secretEqual(password, auth.password);
|
|
11364
|
+
return userOk && passwordOk;
|
|
11009
11365
|
} catch {
|
|
11010
11366
|
return false;
|
|
11011
11367
|
}
|
|
@@ -11305,13 +11661,19 @@ async function probeHttp(host, port, options) {
|
|
|
11305
11661
|
ms,
|
|
11306
11662
|
detail: `status ${response.status} is >= ${options.expectStatusBelow}`
|
|
11307
11663
|
};
|
|
11308
|
-
|
|
11664
|
+
const bodySkipped = options.expectBody.length > 0 && options.method === "HEAD";
|
|
11665
|
+
if (options.expectBody.length > 0 && !bodySkipped) {
|
|
11309
11666
|
if (!(await response.text()).includes(options.expectBody)) return {
|
|
11310
11667
|
healthy: false,
|
|
11311
11668
|
ms,
|
|
11312
11669
|
detail: `body does not contain ${JSON.stringify(options.expectBody)}`
|
|
11313
11670
|
};
|
|
11314
11671
|
}
|
|
11672
|
+
if (bodySkipped) return {
|
|
11673
|
+
healthy: true,
|
|
11674
|
+
ms,
|
|
11675
|
+
detail: `HTTP ${response.status} (a HEAD carries no body, so the body check was skipped)`
|
|
11676
|
+
};
|
|
11315
11677
|
return {
|
|
11316
11678
|
healthy: true,
|
|
11317
11679
|
ms,
|
|
@@ -11507,7 +11869,16 @@ function orderByDependencies(servers) {
|
|
|
11507
11869
|
for (const server of servers) visit(server);
|
|
11508
11870
|
return ordered;
|
|
11509
11871
|
}
|
|
11510
|
-
/**
|
|
11872
|
+
/**
|
|
11873
|
+
* The transitive dependencies of a server, **deepest first** — the order they have to be started in, so
|
|
11874
|
+
* every dependency is up before something that depends on it.
|
|
11875
|
+
*
|
|
11876
|
+
* A dependency is pushed *after* its own dependencies are walked, which is what makes that hold. Pushing
|
|
11877
|
+
* it before (the shape this used to have) yielded a list that was not topological: for
|
|
11878
|
+
* `a -> [b, c], b -> [x], c -> [b]` it returned `[b, x, c]`, where `b` precedes `x` even though `b`
|
|
11879
|
+
* depends on `x` — so `startDependencies` started `b` without `x` running and awaited a readiness that
|
|
11880
|
+
* could not arrive.
|
|
11881
|
+
*/
|
|
11511
11882
|
function dependenciesOf(server, servers) {
|
|
11512
11883
|
const byId = new Map(servers.map((entry) => [entry.id, entry]));
|
|
11513
11884
|
const found = [];
|
|
@@ -11518,13 +11889,17 @@ function dependenciesOf(server, servers) {
|
|
|
11518
11889
|
seen.add(dependency);
|
|
11519
11890
|
const target = byId.get(dependency);
|
|
11520
11891
|
if (!target) continue;
|
|
11521
|
-
found.push(target);
|
|
11522
11892
|
walk(target);
|
|
11893
|
+
found.push(target);
|
|
11523
11894
|
}
|
|
11524
11895
|
};
|
|
11525
11896
|
walk(server);
|
|
11526
11897
|
return found;
|
|
11527
11898
|
}
|
|
11899
|
+
/** Dependents that must stop before this server does. */
|
|
11900
|
+
function dependentsOf(server, servers) {
|
|
11901
|
+
return servers.filter((entry) => entry.id !== server.id && dependenciesOf(entry, servers).some((d) => d.id === server.id));
|
|
11902
|
+
}
|
|
11528
11903
|
var init_dependencies = __esmMin((() => {}));
|
|
11529
11904
|
//#endregion
|
|
11530
11905
|
//#region src/services/log-buffer.ts
|
|
@@ -11598,10 +11973,11 @@ function parseLogLine(raw) {
|
|
|
11598
11973
|
return null;
|
|
11599
11974
|
}
|
|
11600
11975
|
}
|
|
11601
|
-
var MAX_BYTES_PER_READ,
|
|
11976
|
+
var MAX_BYTES_PER_READ, BACKFILL_INITIAL_BYTES, BACKFILL_MAX_BYTES, MAX_CARRY_CHARS, LOG_STREAMS, LogTailer;
|
|
11602
11977
|
var init_log_tail = __esmMin((() => {
|
|
11603
11978
|
MAX_BYTES_PER_READ = 262144;
|
|
11604
|
-
|
|
11979
|
+
BACKFILL_INITIAL_BYTES = 262144;
|
|
11980
|
+
BACKFILL_MAX_BYTES = 8388608;
|
|
11605
11981
|
MAX_CARRY_CHARS = 1048576;
|
|
11606
11982
|
LOG_STREAMS = /* @__PURE__ */ new Set([
|
|
11607
11983
|
"stdout",
|
|
@@ -11647,7 +12023,6 @@ var init_log_tail = __esmMin((() => {
|
|
|
11647
12023
|
this.offset = size;
|
|
11648
12024
|
this.identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
|
|
11649
12025
|
if (size === 0 || count <= 0) return [];
|
|
11650
|
-
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
|
11651
12026
|
let handle;
|
|
11652
12027
|
try {
|
|
11653
12028
|
handle = fs.openSync(this.file, "r");
|
|
@@ -11655,15 +12030,22 @@ var init_log_tail = __esmMin((() => {
|
|
|
11655
12030
|
return [];
|
|
11656
12031
|
}
|
|
11657
12032
|
try {
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
const
|
|
11664
|
-
partialStart =
|
|
12033
|
+
let window = Math.min(size, BACKFILL_INITIAL_BYTES);
|
|
12034
|
+
for (;;) {
|
|
12035
|
+
const start = size - window;
|
|
12036
|
+
const buffer = Buffer$1.alloc(window);
|
|
12037
|
+
const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
|
|
12038
|
+
const raw = buffer.subarray(0, bytes).toString("utf8").split("\n");
|
|
12039
|
+
let partialStart = false;
|
|
12040
|
+
if (start > 0) {
|
|
12041
|
+
const previous = Buffer$1.alloc(1);
|
|
12042
|
+
partialStart = fs.readSync(handle, previous, 0, 1, start - 1) === 1 && previous[0] !== 10;
|
|
12043
|
+
}
|
|
12044
|
+
const lines = (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0);
|
|
12045
|
+
const grew = window < size && window < BACKFILL_MAX_BYTES;
|
|
12046
|
+
if (lines.length >= count || !grew) return lines.slice(-count).map(parseLogLine).filter((line) => line !== null);
|
|
12047
|
+
window = Math.min(size, window * 4);
|
|
11665
12048
|
}
|
|
11666
|
-
return (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0).slice(-count).map(parseLogLine).filter((line) => line !== null);
|
|
11667
12049
|
} catch {
|
|
11668
12050
|
return [];
|
|
11669
12051
|
} finally {
|
|
@@ -11707,14 +12089,17 @@ var init_log_tail = __esmMin((() => {
|
|
|
11707
12089
|
return [];
|
|
11708
12090
|
}
|
|
11709
12091
|
try {
|
|
11710
|
-
const
|
|
11711
|
-
|
|
11712
|
-
|
|
12092
|
+
const budget = Math.min(MAX_BYTES_PER_READ, stats.size - this.offset);
|
|
12093
|
+
const buffer = Buffer$1.alloc(budget);
|
|
12094
|
+
let consumed = 0;
|
|
12095
|
+
while (consumed < budget && this.offset < stats.size) {
|
|
12096
|
+
const length = Math.min(buffer.length, budget - consumed, stats.size - this.offset);
|
|
11713
12097
|
const bytes = fs.readSync(handle, buffer, 0, length, this.offset);
|
|
11714
12098
|
if (bytes <= 0) break;
|
|
11715
12099
|
this.offset += bytes;
|
|
12100
|
+
consumed += bytes;
|
|
11716
12101
|
this.consume(buffer.subarray(0, bytes).toString("utf8"), lines);
|
|
11717
|
-
if (bytes <
|
|
12102
|
+
if (bytes < length) break;
|
|
11718
12103
|
}
|
|
11719
12104
|
} catch {} finally {
|
|
11720
12105
|
fs.closeSync(handle);
|
|
@@ -11853,6 +12238,7 @@ function serverTemplateVars(config) {
|
|
|
11853
12238
|
}
|
|
11854
12239
|
var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, ECHOED_LINE_LIMIT, Supervisor;
|
|
11855
12240
|
var init_supervisor = __esmMin((() => {
|
|
12241
|
+
init_action_result();
|
|
11856
12242
|
init_backoff();
|
|
11857
12243
|
init_bind();
|
|
11858
12244
|
init_env_file();
|
|
@@ -11947,10 +12333,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
11947
12333
|
}
|
|
11948
12334
|
async start(id, options = {}) {
|
|
11949
12335
|
const entry = this.entries.get(id);
|
|
11950
|
-
if (!entry) return
|
|
11951
|
-
ok: false,
|
|
11952
|
-
error: `unknown server "${id}"`
|
|
11953
|
-
};
|
|
12336
|
+
if (!entry) return missingServer(id);
|
|
11954
12337
|
if (!entry.config.enabled) return {
|
|
11955
12338
|
ok: false,
|
|
11956
12339
|
error: `server "${id}" is disabled`
|
|
@@ -12000,10 +12383,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12000
12383
|
}
|
|
12001
12384
|
async stop(id) {
|
|
12002
12385
|
const entry = this.entries.get(id);
|
|
12003
|
-
if (!entry) return
|
|
12004
|
-
ok: false,
|
|
12005
|
-
error: `unknown server "${id}"`
|
|
12006
|
-
};
|
|
12386
|
+
if (!entry) return missingServer(id);
|
|
12007
12387
|
return this.stopEntry(entry);
|
|
12008
12388
|
}
|
|
12009
12389
|
async restart(id) {
|
|
@@ -12030,7 +12410,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12030
12410
|
};
|
|
12031
12411
|
if (!entry) return {
|
|
12032
12412
|
...empty,
|
|
12033
|
-
|
|
12413
|
+
...missingServer(id)
|
|
12034
12414
|
};
|
|
12035
12415
|
const port = entry.config.port;
|
|
12036
12416
|
if (port === null) return {
|
|
@@ -12209,6 +12589,11 @@ var init_supervisor = __esmMin((() => {
|
|
|
12209
12589
|
this.publishServer(entry);
|
|
12210
12590
|
return { ok: true };
|
|
12211
12591
|
}
|
|
12592
|
+
const orphaned = dependentsOf(entry.config, this.store.servers).filter((dependent) => {
|
|
12593
|
+
const state = this.entries.get(dependent.id);
|
|
12594
|
+
return state !== void 0 && this.isActive(state);
|
|
12595
|
+
});
|
|
12596
|
+
if (orphaned.length > 0) this.log(entry, "system", `still running and depending on this: ${orphaned.map((dependent) => dependent.id).join(", ")}`);
|
|
12212
12597
|
entry.status = "stopping";
|
|
12213
12598
|
this.publishServer(entry);
|
|
12214
12599
|
const nannyState = entry.config.persistent ? readNannyState(nannyStatePath(this.options.nannyDir, entry.config.id)) : null;
|
|
@@ -12222,7 +12607,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12222
12607
|
const leftover = await killPortHolders(port, await this.supervisedPids());
|
|
12223
12608
|
if (leftover.length > 0) this.log(entry, "system", `port ${port} was still held by pid ${leftover.join(", ")} — killed`);
|
|
12224
12609
|
}
|
|
12225
|
-
const survivor = entry.config.persistent && entry.pid !== null && isProcessAlive
|
|
12610
|
+
const survivor = entry.config.persistent && entry.pid !== null && isProcessAlive(entry.pid) ? entry.pid : null;
|
|
12226
12611
|
if (entry.config.persistent) {
|
|
12227
12612
|
if (survivor === null) {
|
|
12228
12613
|
clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
|
|
@@ -12284,6 +12669,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
12284
12669
|
if (!config) {
|
|
12285
12670
|
this.entries.delete(id);
|
|
12286
12671
|
this.relay.unfollow(id);
|
|
12672
|
+
this.options.logFiles.clear(id);
|
|
12673
|
+
this.options.history.forget(id);
|
|
12287
12674
|
this.stopEntry(entry).catch((error) => {
|
|
12288
12675
|
logger.error(`could not stop the removed server ${id}`, error);
|
|
12289
12676
|
});
|
|
@@ -12561,6 +12948,11 @@ var init_supervisor = __esmMin((() => {
|
|
|
12561
12948
|
HHOSTED_CONTROL_PORT: String(this.options.control.port)
|
|
12562
12949
|
};
|
|
12563
12950
|
for (const key of Object.keys(dataEnvs)) env[key] = path.resolve(cwd, env[key]);
|
|
12951
|
+
for (const key of Object.keys(env)) {
|
|
12952
|
+
if (!key.includes("\0")) continue;
|
|
12953
|
+
delete env[key];
|
|
12954
|
+
this.log(entry, "system", "ignored an environment key containing a null byte");
|
|
12955
|
+
}
|
|
12564
12956
|
return {
|
|
12565
12957
|
command,
|
|
12566
12958
|
args: expandEnvList(resolveTemplates(entry.config.args, vars), expansionVars),
|
|
@@ -12856,7 +13248,12 @@ var init_supervisor = __esmMin((() => {
|
|
|
12856
13248
|
entry.exitCode = code;
|
|
12857
13249
|
entry.exitSignal = signal;
|
|
12858
13250
|
const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
|
|
12859
|
-
const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) :
|
|
13251
|
+
const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
|
|
13252
|
+
code,
|
|
13253
|
+
signal,
|
|
13254
|
+
runtimeMs: 0,
|
|
13255
|
+
at: Date.now()
|
|
13256
|
+
});
|
|
12860
13257
|
const ranForMs = nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
|
|
12861
13258
|
this.options.history.record(entry.config.id, {
|
|
12862
13259
|
type: "exit",
|
|
@@ -12913,7 +13310,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12913
13310
|
await this.sampleResources(now);
|
|
12914
13311
|
await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
|
|
12915
13312
|
for (const entry of this.entries.values()) {
|
|
12916
|
-
if (entry.adopted && entry.pid !== null && !isProcessAlive
|
|
13313
|
+
if (entry.adopted && entry.pid !== null && !isProcessAlive(entry.pid)) {
|
|
12917
13314
|
this.handleAdoptedExit(entry);
|
|
12918
13315
|
continue;
|
|
12919
13316
|
}
|
|
@@ -13287,9 +13684,6 @@ function readText(file) {
|
|
|
13287
13684
|
return null;
|
|
13288
13685
|
}
|
|
13289
13686
|
}
|
|
13290
|
-
function isRecord(value) {
|
|
13291
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13292
|
-
}
|
|
13293
13687
|
/** The label of one item, for `applied`; items are built before actions run. */
|
|
13294
13688
|
function labelOf(plan, id) {
|
|
13295
13689
|
return plan.items.find((item) => item.id === id)?.label ?? id;
|
|
@@ -13325,6 +13719,7 @@ var init_backups = __esmMin((() => {
|
|
|
13325
13719
|
init_archive();
|
|
13326
13720
|
init_supervisor();
|
|
13327
13721
|
init_generated();
|
|
13722
|
+
init_shape();
|
|
13328
13723
|
MANIFEST = "manifest.json";
|
|
13329
13724
|
ALLOWED_ROOTS = /* @__PURE__ */ new Set([
|
|
13330
13725
|
"global",
|
|
@@ -14211,13 +14606,27 @@ async function swapUsedPercent(now = Date.now()) {
|
|
|
14211
14606
|
};
|
|
14212
14607
|
return percent;
|
|
14213
14608
|
}
|
|
14609
|
+
/**
|
|
14610
|
+
* `sysctl -n vm.swapusage` as a percentage. Pure, so the arithmetic is testable with the readings
|
|
14611
|
+
* the platform can actually produce rather than only the ones a runner happens to have.
|
|
14612
|
+
*/
|
|
14613
|
+
function swapPercentFromSysctl(stdout) {
|
|
14614
|
+
const total = /total\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
|
|
14615
|
+
const used = /used\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
|
|
14616
|
+
const totalMb = Number.parseFloat(total ?? "0");
|
|
14617
|
+
return usedPercent(Math.max(0, totalMb - Number.parseFloat(used ?? "0")), totalMb);
|
|
14618
|
+
}
|
|
14619
|
+
/** One `Win32_PageFileUsage` CSV row as a percentage; see `swapPercentFromSysctl` for the clamp. */
|
|
14620
|
+
function swapPercentFromPageFile(stdout) {
|
|
14621
|
+
const cells = (stdout.split(/\r?\n/).slice(1).find((entry) => entry.trim().length > 0) ?? "").split(",").map((entry) => entry.replace(/"/g, "").trim());
|
|
14622
|
+
const totalMb = Number.parseFloat(cells[0] ?? "0");
|
|
14623
|
+
const usedMb = Number.parseFloat(cells[1] ?? "0");
|
|
14624
|
+
return usedPercent(Math.max(0, totalMb - usedMb), totalMb);
|
|
14625
|
+
}
|
|
14214
14626
|
async function readSwapUsedPercent() {
|
|
14215
14627
|
if (process.platform === "darwin") try {
|
|
14216
14628
|
const { stdout } = await execFileAsync("sysctl", ["-n", "vm.swapusage"], { timeout: 3e3 });
|
|
14217
|
-
|
|
14218
|
-
const used = /used\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
|
|
14219
|
-
const totalMb = Number.parseFloat(total ?? "0");
|
|
14220
|
-
return totalMb > 0 ? Number.parseFloat(used ?? "0") / totalMb * 100 : 0;
|
|
14629
|
+
return swapPercentFromSysctl(stdout);
|
|
14221
14630
|
} catch {
|
|
14222
14631
|
return 0;
|
|
14223
14632
|
}
|
|
@@ -14228,10 +14637,7 @@ async function readSwapUsedPercent() {
|
|
|
14228
14637
|
"-Command",
|
|
14229
14638
|
"Get-CimInstance Win32_PageFileUsage | Select-Object AllocatedBaseSize,CurrentUsage | ConvertTo-Csv -NoTypeInformation"
|
|
14230
14639
|
], { timeout: 5e3 });
|
|
14231
|
-
|
|
14232
|
-
const totalMb = Number.parseFloat(cells[0] ?? "0");
|
|
14233
|
-
const usedMb = Number.parseFloat(cells[1] ?? "0");
|
|
14234
|
-
return totalMb > 0 ? usedMb / totalMb * 100 : 0;
|
|
14640
|
+
return swapPercentFromPageFile(stdout);
|
|
14235
14641
|
} catch {
|
|
14236
14642
|
return 0;
|
|
14237
14643
|
}
|
|
@@ -14251,23 +14657,33 @@ async function readSwapUsedPercent() {
|
|
|
14251
14657
|
* `vm_stat` and count what macOS itself counts as available.
|
|
14252
14658
|
* - Windows: `os.freemem()` is the free physical page count, which is the ordinary reading.
|
|
14253
14659
|
*/
|
|
14660
|
+
/**
|
|
14661
|
+
* How much of `total` is not `free`, as a percentage clamped into `[0, 100]`; 0 when `total` is
|
|
14662
|
+
* meaningless.
|
|
14663
|
+
*
|
|
14664
|
+
* `MemAvailable` is a kernel *estimate* and can exceed `MemTotal`, and `SwapFree` can exceed
|
|
14665
|
+
* `SwapTotal` — both make the raw ratio negative. That matters more than a strange display: the
|
|
14666
|
+
* alert is `used >= threshold`, so a negative reading silently **suppresses** the memory alert
|
|
14667
|
+
* entirely. The fallback branch already clamped for exactly this reason; the `/proc` branch did not,
|
|
14668
|
+
* so the two disagreed on the same quantity.
|
|
14669
|
+
*/
|
|
14670
|
+
function usedPercent(free, total) {
|
|
14671
|
+
if (!(total > 0)) return 0;
|
|
14672
|
+
return Math.max(0, Math.min(100, (total - free) / total * 100));
|
|
14673
|
+
}
|
|
14254
14674
|
function memoryInfo() {
|
|
14255
14675
|
try {
|
|
14256
14676
|
const info = fs.readFileSync("/proc/meminfo", "utf8");
|
|
14257
14677
|
const read = (key) => Number.parseInt(new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(info)?.[1] ?? "0", 10);
|
|
14258
|
-
const total = read("MemTotal");
|
|
14259
|
-
const available = read("MemAvailable");
|
|
14260
|
-
const swapTotal = read("SwapTotal");
|
|
14261
|
-
const swapFree = read("SwapFree");
|
|
14262
14678
|
return {
|
|
14263
|
-
memoryUsedPercent:
|
|
14264
|
-
swapUsedPercent:
|
|
14679
|
+
memoryUsedPercent: usedPercent(read("MemAvailable"), read("MemTotal")),
|
|
14680
|
+
swapUsedPercent: usedPercent(read("SwapFree"), read("SwapTotal"))
|
|
14265
14681
|
};
|
|
14266
14682
|
} catch {
|
|
14267
14683
|
const total = os.totalmem();
|
|
14268
14684
|
const available = availableMemoryBytes(process.platform, os.freemem());
|
|
14269
14685
|
return {
|
|
14270
|
-
memoryUsedPercent:
|
|
14686
|
+
memoryUsedPercent: available === null ? 0 : usedPercent(available, total),
|
|
14271
14687
|
swapUsedPercent: 0
|
|
14272
14688
|
};
|
|
14273
14689
|
}
|
|
@@ -14374,7 +14790,7 @@ async function diskUsage(target) {
|
|
|
14374
14790
|
path: target,
|
|
14375
14791
|
totalBytes,
|
|
14376
14792
|
freeBytes,
|
|
14377
|
-
usedPercent:
|
|
14793
|
+
usedPercent: usedPercent(freeBytes, totalBytes)
|
|
14378
14794
|
};
|
|
14379
14795
|
} catch {
|
|
14380
14796
|
return null;
|
|
@@ -14509,9 +14925,50 @@ function isPublicAddress(ip, family) {
|
|
|
14509
14925
|
const [a, b] = parts;
|
|
14510
14926
|
return !(a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127);
|
|
14511
14927
|
}
|
|
14512
|
-
const
|
|
14513
|
-
if (
|
|
14514
|
-
|
|
14928
|
+
const words = ipv6Words(ip);
|
|
14929
|
+
if (words === null) return false;
|
|
14930
|
+
const [w0, w1] = words;
|
|
14931
|
+
if (words.every((word) => word === 0) || w0 === 0 && w1 === 0 && words.slice(2, 7).every((word) => word === 0) && words[7] === 1) return false;
|
|
14932
|
+
if (w0 === 0 && w1 === 0 && words[2] === 0 && words[3] === 0 && (words[4] === 65535 || words[4] === 0)) return false;
|
|
14933
|
+
if ((w0 & 65280) === 65280) return false;
|
|
14934
|
+
if ((w0 & 65024) === 64512 || (w0 & 65472) === 65152) return false;
|
|
14935
|
+
if (w0 === 8193 && w1 === 3512) return false;
|
|
14936
|
+
if (w0 === 16383 && (w1 & 61440) === 0) return false;
|
|
14937
|
+
if (w0 === 8194 || w0 === 8193 && w1 <= 511) return false;
|
|
14938
|
+
if (w0 === 100 && w1 === 65435) return false;
|
|
14939
|
+
return true;
|
|
14940
|
+
}
|
|
14941
|
+
/**
|
|
14942
|
+
* An IPv6 address as eight 16-bit words, or null when it cannot be parsed.
|
|
14943
|
+
*
|
|
14944
|
+
* The prefix checks above compare bits, and the same address has many spellings (`::ffff:c000:201`
|
|
14945
|
+
* and `::ffff:192.168.1.5` are one address), so a textual `startsWith` is not enough — that is how
|
|
14946
|
+
* the mapped block slipped through.
|
|
14947
|
+
*/
|
|
14948
|
+
function ipv6Words(ip) {
|
|
14949
|
+
let text = ip.toLowerCase();
|
|
14950
|
+
const dotted = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
|
|
14951
|
+
if (dotted !== null) {
|
|
14952
|
+
const parts = dotted[1].split(".").map((part) => Number.parseInt(part, 10));
|
|
14953
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
14954
|
+
const hi = ((parts[0] << 8 | parts[1]) >>> 0).toString(16);
|
|
14955
|
+
const lo = ((parts[2] << 8 | parts[3]) >>> 0).toString(16);
|
|
14956
|
+
text = `${text.slice(0, dotted.index)}${hi}:${lo}`;
|
|
14957
|
+
}
|
|
14958
|
+
const halves = text.split("::");
|
|
14959
|
+
if (halves.length > 2) return null;
|
|
14960
|
+
const head = halves[0] === "" ? [] : halves[0].split(":");
|
|
14961
|
+
const tail = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
|
|
14962
|
+
const zeros = [];
|
|
14963
|
+
if (halves.length > 1) for (let index = 0; index < 8 - head.length - tail.length; index += 1) zeros.push("0");
|
|
14964
|
+
const words = halves.length === 1 ? head : [
|
|
14965
|
+
...head,
|
|
14966
|
+
...zeros,
|
|
14967
|
+
...tail
|
|
14968
|
+
];
|
|
14969
|
+
if (words.length !== 8) return null;
|
|
14970
|
+
const numbers = words.map((word) => /^[0-9a-f]{1,4}$/.test(word) ? Number.parseInt(word, 16) : NaN);
|
|
14971
|
+
return numbers.some((number) => Number.isNaN(number)) ? null : numbers;
|
|
14515
14972
|
}
|
|
14516
14973
|
/**
|
|
14517
14974
|
* Reads the address out of whatever the endpoint answered: a bare address, a
|
|
@@ -14913,6 +15370,22 @@ var init_history = __esmMin((() => {
|
|
|
14913
15370
|
this.load();
|
|
14914
15371
|
return [...this.events];
|
|
14915
15372
|
}
|
|
15373
|
+
/**
|
|
15374
|
+
* Drops every event for a server that no longer exists.
|
|
15375
|
+
*
|
|
15376
|
+
* An id is only unique *while* it exists. Nothing pruned on removal, so re-adding an id — a
|
|
15377
|
+
* rename-back, a restore, a config edit — inherited the old entry's crash time and event list, and
|
|
15378
|
+
* the card showed "Last crash" for a server that had never been started. Bounded (5000 events) so it
|
|
15379
|
+
* was never a leak, but wrong in the same way the log files were.
|
|
15380
|
+
*/
|
|
15381
|
+
forget(serverId) {
|
|
15382
|
+
this.load();
|
|
15383
|
+
const kept = this.events.filter((event) => event.serverId !== serverId);
|
|
15384
|
+
if (kept.length === this.events.length) return;
|
|
15385
|
+
this.events = kept;
|
|
15386
|
+
this.version += 1;
|
|
15387
|
+
this.scheduleSave();
|
|
15388
|
+
}
|
|
14916
15389
|
/** `runningSince` adds the in-flight up-interval so a long-running server shows its real ratio. */
|
|
14917
15390
|
summarize(serverId, windowMs, now = Date.now(), runningSince = null) {
|
|
14918
15391
|
this.load();
|
|
@@ -14920,7 +15393,11 @@ var init_history = __esmMin((() => {
|
|
|
14920
15393
|
const mine = this.events.filter((event) => event.serverId === serverId);
|
|
14921
15394
|
const recent = mine.filter((event) => event.ts >= since);
|
|
14922
15395
|
let upMs = 0;
|
|
14923
|
-
for (const event of recent)
|
|
15396
|
+
for (const event of recent) {
|
|
15397
|
+
if (event.runtimeMs === void 0) continue;
|
|
15398
|
+
const startedAt = event.ts - event.runtimeMs;
|
|
15399
|
+
upMs += event.ts - Math.max(startedAt, since);
|
|
15400
|
+
}
|
|
14924
15401
|
if (runningSince !== null) upMs += Math.max(0, now - Math.max(runningSince, since));
|
|
14925
15402
|
const lastCrash = [...mine].reverse().find((event) => event.type === "crash");
|
|
14926
15403
|
const lastExit = [...mine].reverse().find((event) => event.type === "exit" || event.type === "crash");
|
|
@@ -14933,7 +15410,7 @@ var init_history = __esmMin((() => {
|
|
|
14933
15410
|
lastCrashAt: lastCrash?.ts ?? null,
|
|
14934
15411
|
lastExitAt: lastExit?.ts ?? null,
|
|
14935
15412
|
lastRuntimeMs: lastExit?.runtimeMs ?? null,
|
|
14936
|
-
events:
|
|
15413
|
+
events: recent.slice(-8)
|
|
14937
15414
|
};
|
|
14938
15415
|
}
|
|
14939
15416
|
dispose() {
|
|
@@ -15090,6 +15567,15 @@ var init_log_files = __esmMin((() => {
|
|
|
15090
15567
|
}
|
|
15091
15568
|
if (fs.existsSync(this.currentPath(serverId))) fs.renameSync(this.currentPath(serverId), this.rotatedPath(serverId, 1));
|
|
15092
15569
|
}
|
|
15570
|
+
/**
|
|
15571
|
+
* The last `tail` parseable lines of one file.
|
|
15572
|
+
*
|
|
15573
|
+
* Reads backwards, growing the window until it has enough lines. It used to read exactly one
|
|
15574
|
+
* fixed chunk, which silently truncated: a JSONL line here is a couple of hundred bytes, so
|
|
15575
|
+
* one 256 KB chunk holds roughly 1500 of them, while the route accepts `tail=5000`. Asking
|
|
15576
|
+
* for 5000 returned 1464 and dropped 3536 lines with no error — the oldest line handed back
|
|
15577
|
+
* was not the oldest asked for.
|
|
15578
|
+
*/
|
|
15093
15579
|
readTailChunk(file, tail) {
|
|
15094
15580
|
let handle;
|
|
15095
15581
|
try {
|
|
@@ -15099,15 +15585,24 @@ var init_log_files = __esmMin((() => {
|
|
|
15099
15585
|
}
|
|
15100
15586
|
try {
|
|
15101
15587
|
const size = fs.fstatSync(handle).size;
|
|
15102
|
-
|
|
15103
|
-
|
|
15104
|
-
|
|
15105
|
-
|
|
15106
|
-
|
|
15107
|
-
|
|
15108
|
-
|
|
15109
|
-
|
|
15110
|
-
|
|
15588
|
+
if (size === 0) return [];
|
|
15589
|
+
let start = Math.max(0, size - READ_CHUNK_BYTES);
|
|
15590
|
+
let parsed = [];
|
|
15591
|
+
while (true) {
|
|
15592
|
+
const length = size - start;
|
|
15593
|
+
const buffer = Buffer$1.alloc(length);
|
|
15594
|
+
fs.readSync(handle, buffer, 0, length, start);
|
|
15595
|
+
const raw = buffer.toString("utf8").split("\n");
|
|
15596
|
+
parsed = [];
|
|
15597
|
+
for (const entry of start > 0 ? raw.slice(1) : raw) {
|
|
15598
|
+
if (entry.trim().length === 0) continue;
|
|
15599
|
+
try {
|
|
15600
|
+
parsed.push(JSON.parse(entry));
|
|
15601
|
+
} catch {}
|
|
15602
|
+
}
|
|
15603
|
+
if (parsed.length >= tail || start === 0) return parsed.slice(-tail);
|
|
15604
|
+
start = Math.max(0, start - READ_CHUNK_BYTES);
|
|
15605
|
+
}
|
|
15111
15606
|
} finally {
|
|
15112
15607
|
fs.closeSync(handle);
|
|
15113
15608
|
}
|
|
@@ -15494,10 +15989,15 @@ var init_panel = __esmMin((() => {
|
|
|
15494
15989
|
const runtime = this.runtimes.get(id);
|
|
15495
15990
|
if (runtime === void 0) throw new WorkspaceError(`unknown workspace "${id}"`);
|
|
15496
15991
|
if (this.runtimes.size <= 1) throw new WorkspaceError("cannot remove the only workspace");
|
|
15497
|
-
for (const server of runtime.store.servers)
|
|
15498
|
-
await runtime.supervisor.stop(server.id)
|
|
15499
|
-
|
|
15500
|
-
|
|
15992
|
+
for (const server of runtime.store.servers) {
|
|
15993
|
+
const stopped = await runtime.supervisor.stop(server.id).catch((error) => {
|
|
15994
|
+
logger.warn(`could not stop ${server.id} while removing workspace "${id}"`, error);
|
|
15995
|
+
return {
|
|
15996
|
+
ok: true,
|
|
15997
|
+
error: void 0
|
|
15998
|
+
};
|
|
15999
|
+
});
|
|
16000
|
+
if (!stopped.ok && stopped.error?.includes("survived the stop")) throw new WorkspaceError(`cannot remove "${id}": ${stopped.error} — stop it first, or the workspace would be deleted while that process is still running and unreachable from here`);
|
|
15501
16001
|
}
|
|
15502
16002
|
await runtime.supervisor.dispose();
|
|
15503
16003
|
this.disposeRuntime(runtime);
|
|
@@ -16456,7 +16956,7 @@ async function runControlPlane(options) {
|
|
|
16456
16956
|
return app.fetch(request);
|
|
16457
16957
|
},
|
|
16458
16958
|
trustProxy: () => settings.control.auth.trustProxy,
|
|
16459
|
-
tls: () => settings.control.tls.enabled ? tls.
|
|
16959
|
+
tls: () => settings.control.tls.enabled ? tls.servable() : null
|
|
16460
16960
|
}, {
|
|
16461
16961
|
host: intended.host,
|
|
16462
16962
|
port: intended.port,
|
|
@@ -16475,12 +16975,18 @@ async function runControlPlane(options) {
|
|
|
16475
16975
|
logger.info("bind the panel back to `local`, or set a password with `home-hosted set-password` and enable auth in Global Settings");
|
|
16476
16976
|
process.exit(1);
|
|
16477
16977
|
}
|
|
16978
|
+
const trustWarning = proxyTrustWarning({
|
|
16979
|
+
...configured,
|
|
16980
|
+
host: intended.host
|
|
16981
|
+
});
|
|
16982
|
+
if (trustWarning !== null) logger.warn(trustWarning);
|
|
16478
16983
|
if (options.printConfig) {
|
|
16479
16984
|
printEffectiveConfig(settings, registry, options);
|
|
16480
16985
|
return;
|
|
16481
16986
|
}
|
|
16482
16987
|
if (!await isPortFree(intended.port)) {
|
|
16483
|
-
|
|
16988
|
+
const holders = await listPortHolders(intended.port);
|
|
16989
|
+
logger.error(`control port ${intended.port} is already in use${holders.length > 0 ? ` by pid ${holders.join(", ")}` : ""} — another home-hosted, or something else on this port`);
|
|
16484
16990
|
process.exit(1);
|
|
16485
16991
|
}
|
|
16486
16992
|
if (intended.host !== configured.host || intended.port !== configured.port) settings.updateControl({
|
|
@@ -16732,7 +17238,8 @@ var up_exports = /* @__PURE__ */ __exportAll({
|
|
|
16732
17238
|
function toUpFlags(args) {
|
|
16733
17239
|
let port;
|
|
16734
17240
|
if (args.port !== void 0) {
|
|
16735
|
-
|
|
17241
|
+
const raw = args.port.trim();
|
|
17242
|
+
port = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : NaN;
|
|
16736
17243
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) fail(`invalid port: ${args.port}`);
|
|
16737
17244
|
}
|
|
16738
17245
|
return {
|
|
@@ -16746,8 +17253,8 @@ function toUpFlags(args) {
|
|
|
16746
17253
|
};
|
|
16747
17254
|
}
|
|
16748
17255
|
async function runUp(flags, entry) {
|
|
16749
|
-
const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
|
|
16750
17256
|
if (flags.foreground || flags.printConfig) {
|
|
17257
|
+
const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
|
|
16751
17258
|
await runControlPlane({
|
|
16752
17259
|
configPath: flags.config,
|
|
16753
17260
|
port: flags.port,
|
|
@@ -16819,10 +17326,7 @@ async function waitForStartup(child, timeoutMs = 2e4) {
|
|
|
16819
17326
|
}
|
|
16820
17327
|
function upCommand(entry) {
|
|
16821
17328
|
return defineCommand({
|
|
16822
|
-
meta: {
|
|
16823
|
-
name: "up",
|
|
16824
|
-
description: "start the panel in the background (detached)"
|
|
16825
|
-
},
|
|
17329
|
+
meta: { name: "up" },
|
|
16826
17330
|
args: upArgs,
|
|
16827
17331
|
run: async ({ args }) => {
|
|
16828
17332
|
await runUp(toUpFlags(args), entry);
|
|
@@ -16972,10 +17476,7 @@ var init_down = __esmMin((() => {
|
|
|
16972
17476
|
init_io();
|
|
16973
17477
|
init_version();
|
|
16974
17478
|
downCommand = defineCommand({
|
|
16975
|
-
meta: {
|
|
16976
|
-
name: "down",
|
|
16977
|
-
description: "stop it, and everything it supervises"
|
|
16978
|
-
},
|
|
17479
|
+
meta: { name: "down" },
|
|
16979
17480
|
run: async () => {
|
|
16980
17481
|
await runDown();
|
|
16981
17482
|
}
|
|
@@ -16984,7 +17485,6 @@ var init_down = __esmMin((() => {
|
|
|
16984
17485
|
//#endregion
|
|
16985
17486
|
//#region src/cli/server.ts
|
|
16986
17487
|
var server_exports = /* @__PURE__ */ __exportAll({
|
|
16987
|
-
restartServerCommand: () => restartServerCommand,
|
|
16988
17488
|
runServerAction: () => runServerAction,
|
|
16989
17489
|
startCommand: () => startCommand,
|
|
16990
17490
|
stopCommand: () => stopCommand
|
|
@@ -17011,10 +17511,7 @@ async function runServerAction(action, id, workspace) {
|
|
|
17011
17511
|
}
|
|
17012
17512
|
function serverCommand(action) {
|
|
17013
17513
|
return defineCommand({
|
|
17014
|
-
meta: {
|
|
17015
|
-
name: action,
|
|
17016
|
-
description: DESCRIPTION[action]
|
|
17017
|
-
},
|
|
17514
|
+
meta: { name: action },
|
|
17018
17515
|
args: {
|
|
17019
17516
|
id: {
|
|
17020
17517
|
type: "positional",
|
|
@@ -17034,17 +17531,11 @@ function serverCommand(action) {
|
|
|
17034
17531
|
}
|
|
17035
17532
|
});
|
|
17036
17533
|
}
|
|
17037
|
-
var
|
|
17534
|
+
var startCommand, stopCommand;
|
|
17038
17535
|
var init_server = __esmMin((() => {
|
|
17039
17536
|
init_io();
|
|
17040
|
-
DESCRIPTION = {
|
|
17041
|
-
start: "start one server (the panel keeps running)",
|
|
17042
|
-
stop: "stop one server (the panel keeps running)",
|
|
17043
|
-
restart: "restart one server (the panel keeps running)"
|
|
17044
|
-
};
|
|
17045
17537
|
startCommand = serverCommand("start");
|
|
17046
17538
|
stopCommand = serverCommand("stop");
|
|
17047
|
-
restartServerCommand = serverCommand("restart");
|
|
17048
17539
|
}));
|
|
17049
17540
|
//#endregion
|
|
17050
17541
|
//#region src/cli/restart.ts
|
|
@@ -17059,10 +17550,7 @@ var restart_exports = /* @__PURE__ */ __exportAll({ restartCommand: () => restar
|
|
|
17059
17550
|
*/
|
|
17060
17551
|
function restartCommand(entry) {
|
|
17061
17552
|
return defineCommand({
|
|
17062
|
-
meta: {
|
|
17063
|
-
name: "restart",
|
|
17064
|
-
description: "down then up — or one server, given an id"
|
|
17065
|
-
},
|
|
17553
|
+
meta: { name: "restart" },
|
|
17066
17554
|
args: {
|
|
17067
17555
|
...upArgs,
|
|
17068
17556
|
id: {
|
|
@@ -17104,11 +17592,16 @@ var status_exports = /* @__PURE__ */ __exportAll({
|
|
|
17104
17592
|
async function runStatus(json) {
|
|
17105
17593
|
const { isProcessAlive, probeRuntime, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
17106
17594
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
17107
|
-
const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
17595
|
+
const { hhDir: dataRoot, workspaceDir, DEFAULT_WORKSPACE_ID, workspacesPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
17108
17596
|
const runtime = readRuntime();
|
|
17109
17597
|
if (runtime === null) {
|
|
17110
|
-
|
|
17111
|
-
|
|
17598
|
+
const initialised = fs.existsSync(workspacesPath);
|
|
17599
|
+
if (json) process.stdout.write(`${JSON.stringify({
|
|
17600
|
+
running: false,
|
|
17601
|
+
initialised
|
|
17602
|
+
}, null, 2)}\n`);
|
|
17603
|
+
else if (initialised) process.stdout.write("home-hosted is not running — start it with `home-hosted up`\n");
|
|
17604
|
+
else process.stdout.write("home-hosted is not running — nothing is set up here yet; `home-hosted init` creates a project, `home-hosted up` starts one here\n");
|
|
17112
17605
|
process.exitCode = 1;
|
|
17113
17606
|
return;
|
|
17114
17607
|
}
|
|
@@ -17117,22 +17610,29 @@ async function runStatus(json) {
|
|
|
17117
17610
|
reachable: false,
|
|
17118
17611
|
degraded: false
|
|
17119
17612
|
};
|
|
17613
|
+
const ui = new UiService({ dataRoot });
|
|
17614
|
+
const uiMeta = ui.status().meta;
|
|
17615
|
+
const uiLabel = ui.custom ? uiMeta?.name ?? "installed" : "stock";
|
|
17120
17616
|
if (json) {
|
|
17121
17617
|
const { token: _token, ...safe } = runtime;
|
|
17122
17618
|
process.stdout.write(`${JSON.stringify({
|
|
17123
17619
|
running,
|
|
17124
17620
|
answering: probe.reachable,
|
|
17125
17621
|
degraded: probe.degraded,
|
|
17126
|
-
...safe
|
|
17622
|
+
...safe,
|
|
17623
|
+
logsDir: path.join(workspaceDir(DEFAULT_WORKSPACE_ID), ".logs"),
|
|
17624
|
+
ui: {
|
|
17625
|
+
custom: ui.custom,
|
|
17626
|
+
name: uiLabel,
|
|
17627
|
+
version: uiMeta?.version ?? null
|
|
17628
|
+
}
|
|
17127
17629
|
}, null, 2)}\n`);
|
|
17128
17630
|
if (!running) process.exitCode = 1;
|
|
17129
17631
|
return;
|
|
17130
17632
|
}
|
|
17131
17633
|
const uptime = formatDuration(Date.now() - runtime.startedAt);
|
|
17132
|
-
const state = !running ? paint("31", "stale (the process is gone)") : probe.degraded ? paint("33", "running — a server needs attention") : probe.reachable ? green("running") : paint("33", "running, but not answering");
|
|
17133
|
-
const ui = new UiService({ dataRoot });
|
|
17134
17634
|
const rows = [
|
|
17135
|
-
["status",
|
|
17635
|
+
["status", !running ? paint("31", "stale (the process is gone)") : probe.degraded ? paint("33", "running — a server needs attention") : probe.reachable ? green("running") : paint("33", "running, but not answering")],
|
|
17136
17636
|
["pid", running ? `${runtime.pid} · up ${uptime}` : String(runtime.pid)],
|
|
17137
17637
|
["url", `${runtime.url} ${dim(`(${runtime.protocol})`)}`],
|
|
17138
17638
|
["version", runtime.version],
|
|
@@ -17140,10 +17640,15 @@ async function runStatus(json) {
|
|
|
17140
17640
|
["state", runtime.dataRoot],
|
|
17141
17641
|
["internal", runtime.configPath],
|
|
17142
17642
|
["log", runtime.logFile],
|
|
17143
|
-
["
|
|
17643
|
+
["serverlogs", (() => {
|
|
17644
|
+
const dir = path.join(workspaceDir(DEFAULT_WORKSPACE_ID), ".logs");
|
|
17645
|
+
return fs.existsSync(dir) ? `${dir} (one file per server: <id>.log)` : dir;
|
|
17646
|
+
})()],
|
|
17647
|
+
["ui", ui.custom ? `custom — ${uiLabel} (revert with \`home-hosted ui-revert\`)` : "stock"]
|
|
17144
17648
|
];
|
|
17145
17649
|
process.stdout.write(`${bold(`home-hosted ${runtime.version}`)}\n`);
|
|
17146
|
-
|
|
17650
|
+
const width = Math.max(...rows.map(([label]) => label.length));
|
|
17651
|
+
for (const [label, value] of rows) process.stdout.write(` ${dim(label.padEnd(width))} ${value}\n`);
|
|
17147
17652
|
if (!running) process.exitCode = 1;
|
|
17148
17653
|
}
|
|
17149
17654
|
function formatDuration(ms) {
|
|
@@ -17163,10 +17668,7 @@ var init_status = __esmMin((() => {
|
|
|
17163
17668
|
description: "print machine-readable JSON"
|
|
17164
17669
|
} };
|
|
17165
17670
|
statusCommand = defineCommand({
|
|
17166
|
-
meta: {
|
|
17167
|
-
name: "status",
|
|
17168
|
-
description: "is it running, where, and how to reach it"
|
|
17169
|
-
},
|
|
17671
|
+
meta: { name: "status" },
|
|
17170
17672
|
args: statusArgs,
|
|
17171
17673
|
run: async ({ args }) => {
|
|
17172
17674
|
await runStatus(args.json === true);
|
|
@@ -17180,12 +17682,39 @@ var logs_exports = /* @__PURE__ */ __exportAll({
|
|
|
17180
17682
|
logsCommand: () => logsCommand,
|
|
17181
17683
|
runLogs: () => runLogs
|
|
17182
17684
|
});
|
|
17183
|
-
/**
|
|
17685
|
+
/**
|
|
17686
|
+
* Whether a path exists but cannot be read — the case `readLog` cannot distinguish from absence,
|
|
17687
|
+
* because it folds every failure into "nothing there".
|
|
17688
|
+
*
|
|
17689
|
+
* A *missing* log is normal (a panel that has not started), so only a real permission or IO
|
|
17690
|
+
* refusal is reported. `R_OK` is the question being asked; existence alone would call an
|
|
17691
|
+
* unreadable file readable.
|
|
17692
|
+
*/
|
|
17693
|
+
function isUnreadable(file) {
|
|
17694
|
+
try {
|
|
17695
|
+
fs.accessSync(file, fs.constants.R_OK);
|
|
17696
|
+
return false;
|
|
17697
|
+
} catch (error) {
|
|
17698
|
+
return error.code !== "ENOENT";
|
|
17699
|
+
}
|
|
17700
|
+
}
|
|
17701
|
+
/**
|
|
17702
|
+
* `all` or a negative count means the whole log; an unreadable count is a typo, not a reason to
|
|
17703
|
+
* fail, so it falls back to the default.
|
|
17704
|
+
*
|
|
17705
|
+
* What it does **not** do is accept a *partly* numeric argument, which is how
|
|
17706
|
+
* `Number.parseInt` behaves and why this is not just a `parseInt`. `--lines 1e3` meant 1 line
|
|
17707
|
+
* (not 1000), and `--lines 0x10` parsed as 0 — which is the "whole log" sentinel, so a bounded
|
|
17708
|
+
* request silently became an unbounded one. Neither is a typo falling back to a default; both are
|
|
17709
|
+
* a wrong answer the caller cannot see. The whole trimmed argument has to be a decimal integer.
|
|
17710
|
+
*/
|
|
17184
17711
|
function parseLines(raw) {
|
|
17185
17712
|
if (raw === void 0) return DEFAULT_LINES;
|
|
17186
|
-
|
|
17187
|
-
|
|
17188
|
-
if (
|
|
17713
|
+
const trimmed = raw.trim();
|
|
17714
|
+
if (trimmed.toLowerCase() === "all") return 0;
|
|
17715
|
+
if (!/^[+-]?\d+$/.test(trimmed)) return DEFAULT_LINES;
|
|
17716
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
17717
|
+
if (!Number.isSafeInteger(parsed)) return DEFAULT_LINES;
|
|
17189
17718
|
return parsed < 0 ? 0 : parsed;
|
|
17190
17719
|
}
|
|
17191
17720
|
async function runLogs(input) {
|
|
@@ -17194,10 +17723,37 @@ async function runLogs(input) {
|
|
|
17194
17723
|
const lines = parseLines(input.lines);
|
|
17195
17724
|
if (input.follow === true) {
|
|
17196
17725
|
const { followLog } = await Promise.resolve().then(() => (init_daemon_log(), daemon_log_exports));
|
|
17197
|
-
|
|
17726
|
+
const count = lines === 0 ? DEFAULT_LINES : lines;
|
|
17727
|
+
if (input.json !== true) {
|
|
17728
|
+
await followLog(daemonLogPath, count, (chunk) => process.stdout.write(chunk));
|
|
17729
|
+
return;
|
|
17730
|
+
}
|
|
17731
|
+
const emit = (line) => {
|
|
17732
|
+
process.stdout.write(`${JSON.stringify({
|
|
17733
|
+
path: daemonLogPath,
|
|
17734
|
+
line
|
|
17735
|
+
})}\n`);
|
|
17736
|
+
};
|
|
17737
|
+
let pending = "";
|
|
17738
|
+
await followLog(daemonLogPath, count, (chunk) => {
|
|
17739
|
+
pending += chunk;
|
|
17740
|
+
const parts = pending.split("\n");
|
|
17741
|
+
pending = parts.pop() ?? "";
|
|
17742
|
+
for (const part of parts) emit(part);
|
|
17743
|
+
});
|
|
17744
|
+
if (pending.length > 0) emit(pending);
|
|
17198
17745
|
return;
|
|
17199
17746
|
}
|
|
17200
17747
|
const output = readLog(daemonLogPath, lines);
|
|
17748
|
+
if (output.length === 0 && isUnreadable(daemonLogPath)) {
|
|
17749
|
+
if (input.json === true) process.stdout.write(`${JSON.stringify({
|
|
17750
|
+
path: daemonLogPath,
|
|
17751
|
+
error: "unreadable"
|
|
17752
|
+
}, null, 2)}\n`);
|
|
17753
|
+
else process.stdout.write(`${paint("31", "error")} cannot read ${daemonLogPath} — check its permissions\n`);
|
|
17754
|
+
process.exitCode = 1;
|
|
17755
|
+
return;
|
|
17756
|
+
}
|
|
17201
17757
|
if (input.json === true) {
|
|
17202
17758
|
process.stdout.write(`${JSON.stringify({
|
|
17203
17759
|
path: daemonLogPath,
|
|
@@ -17231,10 +17787,7 @@ var init_logs = __esmMin((() => {
|
|
|
17231
17787
|
};
|
|
17232
17788
|
DEFAULT_LINES = 50;
|
|
17233
17789
|
logsCommand = defineCommand({
|
|
17234
|
-
meta: {
|
|
17235
|
-
name: "logs",
|
|
17236
|
-
description: "show the panel's own console output"
|
|
17237
|
-
},
|
|
17790
|
+
meta: { name: "logs" },
|
|
17238
17791
|
args: logsArgs,
|
|
17239
17792
|
async run({ args }) {
|
|
17240
17793
|
try {
|
|
@@ -17287,10 +17840,7 @@ var init_set_password = __esmMin((() => {
|
|
|
17287
17840
|
description: "remove the password, which disables authentication"
|
|
17288
17841
|
} };
|
|
17289
17842
|
setPasswordCommand = defineCommand({
|
|
17290
|
-
meta: {
|
|
17291
|
-
name: "set-password",
|
|
17292
|
-
description: "set the panel password without the API"
|
|
17293
|
-
},
|
|
17843
|
+
meta: { name: "set-password" },
|
|
17294
17844
|
args: setPasswordArgs,
|
|
17295
17845
|
run: async ({ args }) => {
|
|
17296
17846
|
await runSetPassword(args.clear === true);
|
|
@@ -17369,10 +17919,7 @@ var init_set_token = __esmMin((() => {
|
|
|
17369
17919
|
}
|
|
17370
17920
|
};
|
|
17371
17921
|
setTokenCommand = defineCommand({
|
|
17372
|
-
meta: {
|
|
17373
|
-
name: "set-token",
|
|
17374
|
-
description: "set the API token that scripts and agents use"
|
|
17375
|
-
},
|
|
17922
|
+
meta: { name: "set-token" },
|
|
17376
17923
|
args: setTokenArgs,
|
|
17377
17924
|
run: async ({ args }) => {
|
|
17378
17925
|
await runSetToken(args.generate === true, args.clear === true);
|
|
@@ -17440,6 +17987,8 @@ async function runMigrate(config, dryRun, yes) {
|
|
|
17440
17987
|
if (plan.tooNew) fail(`${target.file} was written by home-hosted ${meta.writtenBy ?? "a newer release"} (config schema ${from});\n this release understands schema ${plan.to}. Install that version, or edit the file yourself.`);
|
|
17441
17988
|
const problem = target.validate(raw);
|
|
17442
17989
|
if (problem !== null) fail(`${target.file} is not a config this release can read: ${problem}`);
|
|
17990
|
+
if (plan.reached !== plan.to) fail(`${target.file} is schema ${from} and this release can only lift it to schema ${plan.reached} — the migration chain in configMigrations has a gap to schema ${plan.to}.
|
|
17991
|
+
That is a bug in this release: report it rather than editing the file.`);
|
|
17443
17992
|
if (plan.steps.length === 0) {
|
|
17444
17993
|
const stamped = stampConfig(raw);
|
|
17445
17994
|
if (JSON.stringify(stamped) !== JSON.stringify(raw)) stampOnly += 1;
|
|
@@ -17499,10 +18048,7 @@ var init_migrate = __esmMin((() => {
|
|
|
17499
18048
|
}
|
|
17500
18049
|
};
|
|
17501
18050
|
migrateCommand = defineCommand({
|
|
17502
|
-
meta: {
|
|
17503
|
-
name: "migrate",
|
|
17504
|
-
description: "bring the state up to this release's layout and schema"
|
|
17505
|
-
},
|
|
18051
|
+
meta: { name: "migrate" },
|
|
17506
18052
|
args: migrateArgs,
|
|
17507
18053
|
run: async ({ args }) => {
|
|
17508
18054
|
await runMigrate(args.config, args.dryRun === true, args.yes === true);
|
|
@@ -17704,10 +18250,7 @@ var init_init = __esmMin((() => {
|
|
|
17704
18250
|
}
|
|
17705
18251
|
};
|
|
17706
18252
|
initCommand = defineCommand({
|
|
17707
|
-
meta: {
|
|
17708
|
-
name: "init",
|
|
17709
|
-
description: "scaffold a project that keeps its state in the repo"
|
|
17710
|
-
},
|
|
18253
|
+
meta: { name: "init" },
|
|
17711
18254
|
args: initArgs,
|
|
17712
18255
|
run: async ({ args }) => {
|
|
17713
18256
|
await runInit({
|
|
@@ -17879,10 +18422,7 @@ var init_ui_switch = __esmMin((() => {
|
|
|
17879
18422
|
init_ui_release();
|
|
17880
18423
|
init_ui_release();
|
|
17881
18424
|
uiSwitchCommand = defineCommand({
|
|
17882
|
-
meta: {
|
|
17883
|
-
name: "ui-switch",
|
|
17884
|
-
description: "install a UI from a release asset, a zip file or a URL"
|
|
17885
|
-
},
|
|
18425
|
+
meta: { name: "ui-switch" },
|
|
17886
18426
|
run: async ({ rawArgs }) => {
|
|
17887
18427
|
await uiSwitch(rawArgs, {
|
|
17888
18428
|
write: (text) => process.stdout.write(text),
|
|
@@ -18143,10 +18683,7 @@ var init_ui_update = __esmMin((() => {
|
|
|
18143
18683
|
init_io();
|
|
18144
18684
|
init_ui_release();
|
|
18145
18685
|
uiUpdateCommand = defineCommand({
|
|
18146
|
-
meta: {
|
|
18147
|
-
name: "ui-update",
|
|
18148
|
-
description: "update the installed UI to match this panel, or pick a release"
|
|
18149
|
-
},
|
|
18686
|
+
meta: { name: "ui-update" },
|
|
18150
18687
|
run: async ({ rawArgs }) => {
|
|
18151
18688
|
await uiUpdate(rawArgs, {
|
|
18152
18689
|
write: (text) => process.stdout.write(text),
|
|
@@ -18178,10 +18715,7 @@ var init_ui_revert = __esmMin((() => {
|
|
|
18178
18715
|
init_paths();
|
|
18179
18716
|
init_ui();
|
|
18180
18717
|
uiRevertCommand = defineCommand({
|
|
18181
|
-
meta: {
|
|
18182
|
-
name: "ui-revert",
|
|
18183
|
-
description: "go back to the stock control panel UI"
|
|
18184
|
-
},
|
|
18718
|
+
meta: { name: "ui-revert" },
|
|
18185
18719
|
run: async () => {
|
|
18186
18720
|
await runUiRevert();
|
|
18187
18721
|
}
|
|
@@ -18282,7 +18816,7 @@ async function runNanny(spec, statePath) {
|
|
|
18282
18816
|
at: Date.now(),
|
|
18283
18817
|
runtimeMs
|
|
18284
18818
|
};
|
|
18285
|
-
emit("system", note ??
|
|
18819
|
+
emit("system", note ?? `persistent: exited with ${describeNannyExit(lastExit)}`);
|
|
18286
18820
|
persistState();
|
|
18287
18821
|
logFiles.dispose();
|
|
18288
18822
|
exitCode = code ?? (signal === null ? 0 : 1);
|
|
@@ -18339,7 +18873,9 @@ var init_nanny = __esmMin((() => {
|
|
|
18339
18873
|
//#region src/cli.ts
|
|
18340
18874
|
init_args();
|
|
18341
18875
|
init_io();
|
|
18876
|
+
init_nearest();
|
|
18342
18877
|
init_runtime();
|
|
18878
|
+
init_version();
|
|
18343
18879
|
/**
|
|
18344
18880
|
* The command line, and nothing else. Two things happen before citty is asked
|
|
18345
18881
|
* anything: `--home`/`--project` are peeled off and applied (because
|
|
@@ -18418,7 +18954,7 @@ var LOGS_SECTION = {
|
|
|
18418
18954
|
lines: [
|
|
18419
18955
|
["--lines <n>", "how many lines to show (default 50, `all` for everything)"],
|
|
18420
18956
|
["--follow", "keep printing as the panel writes (like tail -f)"],
|
|
18421
|
-
["--json", "print machine-readable JSON"]
|
|
18957
|
+
["--json", "print machine-readable JSON (one object per line with --follow)"]
|
|
18422
18958
|
]
|
|
18423
18959
|
};
|
|
18424
18960
|
var SERVER_SECTION = {
|
|
@@ -18502,6 +19038,14 @@ var ENVIRONMENT_SECTION = {
|
|
|
18502
19038
|
["GITHUB_TOKEN", "a GitHub token for ui-switch (GH_TOKEN also works)"]
|
|
18503
19039
|
]
|
|
18504
19040
|
};
|
|
19041
|
+
/**
|
|
19042
|
+
* The command name a typo most likely meant, or null when nothing is close. The candidates are
|
|
19043
|
+
* `SYNOPSIS`, the same source the reference renders, so a suggestion cannot name a command the CLI
|
|
19044
|
+
* does not have.
|
|
19045
|
+
*/
|
|
19046
|
+
function suggestCommand(input) {
|
|
19047
|
+
return nearestWord(input, Object.keys(SYNOPSIS));
|
|
19048
|
+
}
|
|
18505
19049
|
/** The command list of the full reference, aligned as it always was. */
|
|
18506
19050
|
function renderCommandList() {
|
|
18507
19051
|
const width = Math.max(...Object.values(SYNOPSIS).map((synopsis) => synopsis.length));
|
|
@@ -18582,15 +19126,8 @@ function commandHelp(command) {
|
|
|
18582
19126
|
SHARED_TRAILER
|
|
18583
19127
|
].join("\n");
|
|
18584
19128
|
}
|
|
18585
|
-
function manifestVersion() {
|
|
18586
|
-
try {
|
|
18587
|
-
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
18588
|
-
} catch {
|
|
18589
|
-
return "0.0.0";
|
|
18590
|
-
}
|
|
18591
|
-
}
|
|
18592
19129
|
function version() {
|
|
18593
|
-
process.stdout.write(`${
|
|
19130
|
+
process.stdout.write(`${appVersion()}\n`);
|
|
18594
19131
|
}
|
|
18595
19132
|
/**
|
|
18596
19133
|
* citty owns dispatch and argument parsing. Each entry is a lazy import, so a
|
|
@@ -18615,7 +19152,7 @@ var COMMANDS = {
|
|
|
18615
19152
|
var rootCommand = defineCommand({
|
|
18616
19153
|
meta: {
|
|
18617
19154
|
name: "home-hosted",
|
|
18618
|
-
version:
|
|
19155
|
+
version: appVersion(),
|
|
18619
19156
|
description: "a control panel for the processes on your home server"
|
|
18620
19157
|
},
|
|
18621
19158
|
subCommands: COMMANDS
|
|
@@ -18644,7 +19181,10 @@ async function main() {
|
|
|
18644
19181
|
return;
|
|
18645
19182
|
}
|
|
18646
19183
|
if (invocation.kind === "unknown") {
|
|
18647
|
-
|
|
19184
|
+
const suggestion = suggestCommand(invocation.command);
|
|
19185
|
+
process.stderr.write(`unknown command: ${invocation.command}\n`);
|
|
19186
|
+
if (suggestion !== null) process.stderr.write(`${dim(`did you mean \`${suggestion}\`?`)}\n`);
|
|
19187
|
+
process.stderr.write(`\n${USAGE}`);
|
|
18648
19188
|
process.exit(1);
|
|
18649
19189
|
}
|
|
18650
19190
|
const { ensureLayout } = await Promise.resolve().then(() => (init_layout(), layout_exports));
|
|
@@ -18663,6 +19203,6 @@ main().catch((error) => {
|
|
|
18663
19203
|
fail(error instanceof Error ? error.message : String(error));
|
|
18664
19204
|
});
|
|
18665
19205
|
//#endregion
|
|
18666
|
-
export { commandHelp };
|
|
19206
|
+
export { commandHelp, suggestCommand };
|
|
18667
19207
|
|
|
18668
19208
|
//# sourceMappingURL=cli.js.map
|