home-hosted 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -9
- package/dist/cli.js +456 -63
- package/dist/cli.js.map +1 -1
- package/docs/SERVERS.md +19 -1
- package/docs/UI_CREATION.md +6 -1
- package/docs/branding/logo.svg +33 -0
- package/docs/branding/social-preview.png +0 -0
- package/package.json +4 -1
- package/uis/stock/dist/assets/{de-DQxDtYp2.js → de-CiT_tMnf.js} +1 -1
- package/uis/stock/dist/assets/es-JJDGEbt0.js +1 -0
- package/uis/stock/dist/assets/{fr-DHCvxiys.js → fr-CYIAuJgb.js} +1 -1
- package/uis/stock/dist/assets/{index-C02FUAwz.js → index-BCPp9MUj.js} +4 -4
- package/uis/stock/dist/assets/{ja-DseIeS1D.js → ja-DmykOijW.js} +1 -1
- package/uis/stock/dist/assets/{ko-Dcu2b6c2.js → ko-C4EcOJB0.js} +1 -1
- package/uis/stock/dist/assets/{ru-DaAAVw09.js → ru-Dlbre_Q1.js} +1 -1
- package/uis/stock/dist/assets/{vi-D780h-t9.js → vi-DhBWYY9I.js} +1 -1
- package/uis/stock/dist/assets/{zh-CN-C8OC-N1b.js → zh-CN-BI0ZDO_Y.js} +1 -1
- package/uis/stock/dist/favicon.ico +0 -0
- package/uis/stock/dist/favicon.svg +14 -0
- package/uis/stock/dist/index.html +5 -1
- package/uis/stock/dist/ui.json +3 -3
- package/uis/stock/dist/assets/es-BP0qzCTU.js +0 -1
package/dist/cli.js
CHANGED
|
@@ -709,7 +709,7 @@ function parseBind(value) {
|
|
|
709
709
|
const parsed = bindSchema(value);
|
|
710
710
|
return parsed instanceof type.errors ? null : parsed;
|
|
711
711
|
}
|
|
712
|
-
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, restoreSkipSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
712
|
+
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, entryStateSchema, 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, historyActorSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restoreSkipSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
713
713
|
var init_contracts = __esmMin((() => {
|
|
714
714
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
715
715
|
workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
|
|
@@ -1154,6 +1154,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1154
1154
|
"heartbeatAt": "number",
|
|
1155
1155
|
"lastExit?": nannyExitSchema
|
|
1156
1156
|
}).onUndeclaredKey("reject");
|
|
1157
|
+
entryStateSchema = type({
|
|
1158
|
+
/** Attempts spent on the current crash sequence; reset once the process stays up. */
|
|
1159
|
+
"restarts": "number.integer >= 0",
|
|
1160
|
+
/** The `runOnce` bootstrap already ran for this entry. */
|
|
1161
|
+
"bootstrapDone": "boolean",
|
|
1162
|
+
/**
|
|
1163
|
+
* The last child this panel spawned for a non-persistent entry. A detached child
|
|
1164
|
+
* outlives the panel, so this is the only way a later boot recognizes its own
|
|
1165
|
+
* survivor instead of spawning a second copy beside it.
|
|
1166
|
+
*/
|
|
1167
|
+
"lastPid?": "number | null",
|
|
1168
|
+
"lastStartedAt?": "number | null"
|
|
1169
|
+
});
|
|
1157
1170
|
settingsPatchSchema = type({
|
|
1158
1171
|
control: controlPatchSchema.optional(),
|
|
1159
1172
|
host: hostPatchSchema.optional(),
|
|
@@ -1419,13 +1432,16 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1419
1432
|
stream: logStreamSchema,
|
|
1420
1433
|
text: "string"
|
|
1421
1434
|
});
|
|
1435
|
+
historyActorSchema = type.enumerated("policy", "user", "agent", "boot");
|
|
1422
1436
|
historyEventSchema = type({
|
|
1423
|
-
serverId: "string",
|
|
1424
|
-
ts: "number",
|
|
1425
|
-
type: "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
|
|
1426
|
-
detail: "string",
|
|
1437
|
+
"serverId": "string",
|
|
1438
|
+
"ts": "number",
|
|
1439
|
+
"type": "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
|
|
1440
|
+
"detail": "string",
|
|
1427
1441
|
/** How long the process had been up, recorded on exit and crash. */
|
|
1428
|
-
runtimeMs: "number?"
|
|
1442
|
+
"runtimeMs": "number?",
|
|
1443
|
+
/** Who caused it; absent on events from before this field existed. */
|
|
1444
|
+
"actor?": historyActorSchema
|
|
1429
1445
|
});
|
|
1430
1446
|
serverHistorySchema = type({
|
|
1431
1447
|
windowMs: "number",
|
|
@@ -1497,6 +1513,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1497
1513
|
"labelParams?": "Record<string, string>",
|
|
1498
1514
|
/** `settings`/`servers`/`secrets` for a workspace, `data` for a declared path. */
|
|
1499
1515
|
"kind": "\"settings\" | \"servers\" | \"secrets\" | \"data\"",
|
|
1516
|
+
/** Which secret a `secrets` leaf carries (`password`, `apiToken`, `telegram`, `ddns`, …). */
|
|
1517
|
+
"secret?": "string",
|
|
1500
1518
|
"path?": "string",
|
|
1501
1519
|
/** Who declared a data path: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
|
|
1502
1520
|
"origin?": "string",
|
|
@@ -1546,6 +1564,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1546
1564
|
"encrypted": "boolean",
|
|
1547
1565
|
/** The archive needs a password (none or a wrong one was supplied). */
|
|
1548
1566
|
"needsPassword": "boolean",
|
|
1567
|
+
/**
|
|
1568
|
+
* Where the archive says it came from. Optional: an archive written before this
|
|
1569
|
+
* stamp existed carries neither field, and an older panel never sends the row.
|
|
1570
|
+
*/
|
|
1571
|
+
"archive?": type({
|
|
1572
|
+
"writtenBy?": "string",
|
|
1573
|
+
"schema?": "number"
|
|
1574
|
+
}),
|
|
1549
1575
|
"items": restoreItemSchema.array(),
|
|
1550
1576
|
"applied": type("string[]"),
|
|
1551
1577
|
/**
|
|
@@ -2635,6 +2661,14 @@ function createAuthGuard(deps) {
|
|
|
2635
2661
|
await next();
|
|
2636
2662
|
};
|
|
2637
2663
|
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Who is behind a request, as a history event names them: a cookie is a person at
|
|
2666
|
+
* the panel, a bearer token is a script or an agent. Anonymous callers can only be
|
|
2667
|
+
* loopback requests while auth is unarmed, which is the browser's own origin.
|
|
2668
|
+
*/
|
|
2669
|
+
function requestActor(c, auth) {
|
|
2670
|
+
return requestIdentity(c, auth).method === "token" ? "agent" : "user";
|
|
2671
|
+
}
|
|
2638
2672
|
var PUBLIC_PATHS, AUTH_REQUIRED_CODE;
|
|
2639
2673
|
var init_auth = __esmMin((() => {
|
|
2640
2674
|
init_loopback();
|
|
@@ -3643,7 +3677,7 @@ function createControlRoute(deps) {
|
|
|
3643
3677
|
}
|
|
3644
3678
|
}), validate("query", workspaceQuerySchema), async (c) => {
|
|
3645
3679
|
assertLocalCall(c, "start or stop a server");
|
|
3646
|
-
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"));
|
|
3680
|
+
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"), { actor: "user" });
|
|
3647
3681
|
return c.json(result, statusForAction(result));
|
|
3648
3682
|
}).post("/servers/:id/stop", describeRoute({
|
|
3649
3683
|
tags: ["panel"],
|
|
@@ -3667,7 +3701,7 @@ function createControlRoute(deps) {
|
|
|
3667
3701
|
}
|
|
3668
3702
|
}), validate("query", workspaceQuerySchema), async (c) => {
|
|
3669
3703
|
assertLocalCall(c, "restart a server");
|
|
3670
|
-
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"));
|
|
3704
|
+
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"), { actor: "user" });
|
|
3671
3705
|
return c.json(result, statusForAction(result));
|
|
3672
3706
|
});
|
|
3673
3707
|
}
|
|
@@ -6423,8 +6457,8 @@ var init_events = __esmMin((() => {
|
|
|
6423
6457
|
/**
|
|
6424
6458
|
* Liveness for external monitors. Mounted outside `/api`, so it answers without a
|
|
6425
6459
|
* session: it reports whether the panel itself is serving, and 503 when an
|
|
6426
|
-
* autostart server has crashed
|
|
6427
|
-
* authenticated caller.
|
|
6460
|
+
* autostart server has crashed, or a running one is failing its own health check.
|
|
6461
|
+
* Server details are only included for an authenticated caller.
|
|
6428
6462
|
*/
|
|
6429
6463
|
function createHealthRoute(deps) {
|
|
6430
6464
|
return appFactory.createApp().get("/healthz", describeRoute({
|
|
@@ -6435,12 +6469,12 @@ function createHealthRoute(deps) {
|
|
|
6435
6469
|
description: "Serving",
|
|
6436
6470
|
content: jsonBody(healthResponseSchema)
|
|
6437
6471
|
},
|
|
6438
|
-
503: { description: "An autostart server has crashed" }
|
|
6472
|
+
503: { description: "An autostart server has crashed, or a running one is unhealthy" }
|
|
6439
6473
|
}
|
|
6440
6474
|
}), (c) => {
|
|
6441
6475
|
const state = deps.panel.getState();
|
|
6442
6476
|
const servers = state.workspaces.flatMap((workspace) => workspace.servers);
|
|
6443
|
-
const broken = servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
6477
|
+
const broken = servers.filter((server) => server.config.autostart && server.status === "crashed" || server.status === "running" && server.health === "unhealthy");
|
|
6444
6478
|
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
6445
6479
|
return c.json({
|
|
6446
6480
|
status: broken.length > 0 ? "degraded" : "ok",
|
|
@@ -10361,7 +10395,7 @@ function createServersRoute(deps) {
|
|
|
10361
10395
|
404: ERROR_RESPONSES[404]
|
|
10362
10396
|
}
|
|
10363
10397
|
}), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
|
|
10364
|
-
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.valid("param").id);
|
|
10398
|
+
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.valid("param").id, { actor: requestActor(c, deps.auth) });
|
|
10365
10399
|
return c.json(result, statusForAction(result));
|
|
10366
10400
|
}).post("/:id/stop", describeRoute({
|
|
10367
10401
|
tags: ["servers"],
|
|
@@ -10381,7 +10415,7 @@ function createServersRoute(deps) {
|
|
|
10381
10415
|
404: ERROR_RESPONSES[404]
|
|
10382
10416
|
}
|
|
10383
10417
|
}), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
|
|
10384
|
-
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.valid("param").id);
|
|
10418
|
+
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.valid("param").id, { actor: requestActor(c, deps.auth) });
|
|
10385
10419
|
return c.json(result, statusForAction(result));
|
|
10386
10420
|
}).post("/:id/clear-logs", describeRoute({
|
|
10387
10421
|
tags: ["servers"],
|
|
@@ -10469,6 +10503,7 @@ var init_$_routes$1 = __esmMin((() => {
|
|
|
10469
10503
|
init_openapi$1();
|
|
10470
10504
|
init_validator();
|
|
10471
10505
|
init_workspace();
|
|
10506
|
+
init_auth();
|
|
10472
10507
|
init_events$1();
|
|
10473
10508
|
init_contracts();
|
|
10474
10509
|
idParam$1 = type({ id: "string >= 1" });
|
|
@@ -11427,6 +11462,90 @@ var init_acme_challenge = __esmMin((() => {
|
|
|
11427
11462
|
};
|
|
11428
11463
|
}));
|
|
11429
11464
|
//#endregion
|
|
11465
|
+
//#region src/config/secret-parts.ts
|
|
11466
|
+
/**
|
|
11467
|
+
* The parts of one scope, in a stable order: the known parts first in the fixed order
|
|
11468
|
+
* password, apiToken (global) / telegram, ddns (workspace) — then any other top-level
|
|
11469
|
+
* key present in `present`, sorted, as its own part with `fields: [key]`.
|
|
11470
|
+
*
|
|
11471
|
+
* An unknown key is taken as belonging to the scope being asked about, because the file
|
|
11472
|
+
* it was read from is the only evidence of where it lives: this release does not know the
|
|
11473
|
+
* key, so classifying it by name would drop a future *global* secret on the floor.
|
|
11474
|
+
*/
|
|
11475
|
+
function secretParts(scope, present) {
|
|
11476
|
+
const keys = new Set(present);
|
|
11477
|
+
const known = KNOWN[scope];
|
|
11478
|
+
const parts = known.filter((key) => keys.has(key)).map((key) => ({
|
|
11479
|
+
key,
|
|
11480
|
+
fields: EXTRA_FIELDS[key] ?? [key],
|
|
11481
|
+
scope
|
|
11482
|
+
}));
|
|
11483
|
+
const consumed = new Set(parts.flatMap((part) => part.fields));
|
|
11484
|
+
const rest = [...keys].filter((key) => KNOWN_SCOPES[key] === void 0 && !known.includes(key) && !consumed.has(key)).sort();
|
|
11485
|
+
return [...parts, ...rest.map((key) => ({
|
|
11486
|
+
key,
|
|
11487
|
+
fields: [key],
|
|
11488
|
+
scope
|
|
11489
|
+
}))];
|
|
11490
|
+
}
|
|
11491
|
+
/** The top-level keys of a raw secrets document that hold a non-null value. */
|
|
11492
|
+
function presentSecretKeys(source) {
|
|
11493
|
+
return Object.entries(source).filter(([, value]) => value !== null && value !== void 0).map(([key]) => key);
|
|
11494
|
+
}
|
|
11495
|
+
/**
|
|
11496
|
+
* A JSON string carrying only `fields`, taken from `source`. Returns null when `source`
|
|
11497
|
+
* carries none of them. Always includes a field whose value is `null` ONLY if it is
|
|
11498
|
+
* present as a key and non-null; a null/absent field is omitted.
|
|
11499
|
+
*/
|
|
11500
|
+
function pickSecrets(source, fields) {
|
|
11501
|
+
const picked = {};
|
|
11502
|
+
for (const field of fields) {
|
|
11503
|
+
const value = source[field];
|
|
11504
|
+
if (value !== null && value !== void 0) picked[field] = value;
|
|
11505
|
+
}
|
|
11506
|
+
return Object.keys(picked).length === 0 ? null : JSON.stringify(picked);
|
|
11507
|
+
}
|
|
11508
|
+
/** Parses a secrets document, tolerating anything: not-an-object, or broken JSON returns {}. */
|
|
11509
|
+
function parseSecrets(text) {
|
|
11510
|
+
try {
|
|
11511
|
+
const parsed = JSON.parse(text);
|
|
11512
|
+
return isRecord(parsed) ? parsed : {};
|
|
11513
|
+
} catch {
|
|
11514
|
+
return {};
|
|
11515
|
+
}
|
|
11516
|
+
}
|
|
11517
|
+
/**
|
|
11518
|
+
* Merges `piece` into `current`, changing ONLY the fields in `fields` — every other key
|
|
11519
|
+
* of `current` is preserved (this is what stops restoring the Telegram token from wiping
|
|
11520
|
+
* the DDNS credentials). A field that is absent from `piece` is also DELETED from the
|
|
11521
|
+
* result, so restoring an older archive that predates a secret clears it rather than
|
|
11522
|
+
* leaving a stale value. Returns a new object; does not mutate its arguments.
|
|
11523
|
+
*/
|
|
11524
|
+
function mergeSecrets(current, piece, fields) {
|
|
11525
|
+
const merged = { ...current };
|
|
11526
|
+
for (const field of fields) {
|
|
11527
|
+
const value = piece[field];
|
|
11528
|
+
if (value === null || value === void 0) delete merged[field];
|
|
11529
|
+
else merged[field] = value;
|
|
11530
|
+
}
|
|
11531
|
+
return merged;
|
|
11532
|
+
}
|
|
11533
|
+
var KNOWN, KNOWN_SCOPES, EXTRA_FIELDS;
|
|
11534
|
+
var init_secret_parts = __esmMin((() => {
|
|
11535
|
+
init_shape();
|
|
11536
|
+
KNOWN = {
|
|
11537
|
+
global: ["password", "apiToken"],
|
|
11538
|
+
workspace: ["telegram", "ddns"]
|
|
11539
|
+
};
|
|
11540
|
+
KNOWN_SCOPES = {
|
|
11541
|
+
password: "global",
|
|
11542
|
+
apiToken: "global",
|
|
11543
|
+
telegram: "workspace",
|
|
11544
|
+
ddns: "workspace"
|
|
11545
|
+
};
|
|
11546
|
+
EXTRA_FIELDS = { ddns: ["ddns", "ddnsKdf"] };
|
|
11547
|
+
}));
|
|
11548
|
+
//#endregion
|
|
11430
11549
|
//#region src/helpers/env-file.ts
|
|
11431
11550
|
/** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
|
|
11432
11551
|
function parseEnvFile(text) {
|
|
@@ -11684,6 +11803,41 @@ function computeBackoff(attempt, options) {
|
|
|
11684
11803
|
}
|
|
11685
11804
|
var init_backoff = __esmMin((() => {}));
|
|
11686
11805
|
//#endregion
|
|
11806
|
+
//#region src/providers/entry-state.ts
|
|
11807
|
+
/**
|
|
11808
|
+
* The small slice of a server's runtime the panel has to remember across its own
|
|
11809
|
+
* restarts: how much of the retry budget is spent, and whether a `runOnce` bootstrap
|
|
11810
|
+
* already ran.
|
|
11811
|
+
*
|
|
11812
|
+
* Both lived only in memory, so a panel restart — a `restart`, a crash, a reboot —
|
|
11813
|
+
* handed a crash-looping server a fresh `maxRetries` and re-ran its bootstrap. The
|
|
11814
|
+
* retry counter has to outlive the process that increments it to bound anything.
|
|
11815
|
+
*/
|
|
11816
|
+
/** One JSON file per entry, beside the nanny's own state. */
|
|
11817
|
+
function entryStatePath(dir, id) {
|
|
11818
|
+
return path.join(dir, `${id}.run.json`);
|
|
11819
|
+
}
|
|
11820
|
+
function readEntryState(file) {
|
|
11821
|
+
try {
|
|
11822
|
+
const parsed = entryStateSchema(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
11823
|
+
return parsed instanceof type.errors ? null : parsed;
|
|
11824
|
+
} catch {
|
|
11825
|
+
return null;
|
|
11826
|
+
}
|
|
11827
|
+
}
|
|
11828
|
+
function writeEntryState(file, state) {
|
|
11829
|
+
try {
|
|
11830
|
+
writeFileAtomic(file, `${JSON.stringify(state)}\n`);
|
|
11831
|
+
} catch {}
|
|
11832
|
+
}
|
|
11833
|
+
function clearEntryState(file) {
|
|
11834
|
+
fs.rmSync(file, { force: true });
|
|
11835
|
+
}
|
|
11836
|
+
var init_entry_state = __esmMin((() => {
|
|
11837
|
+
init_atomic();
|
|
11838
|
+
init_contracts();
|
|
11839
|
+
}));
|
|
11840
|
+
//#endregion
|
|
11687
11841
|
//#region src/providers/health-check.ts
|
|
11688
11842
|
/** TCP connect timing, used for the default `port` mode and readiness. */
|
|
11689
11843
|
async function probeTcp(host, port, timeoutMs) {
|
|
@@ -12304,6 +12458,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12304
12458
|
init_paths();
|
|
12305
12459
|
init_runtime();
|
|
12306
12460
|
init_template();
|
|
12461
|
+
init_entry_state();
|
|
12307
12462
|
init_health_check();
|
|
12308
12463
|
init_identity();
|
|
12309
12464
|
init_nanny$2();
|
|
@@ -12371,7 +12526,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12371
12526
|
}
|
|
12372
12527
|
}
|
|
12373
12528
|
const targets = [...this.entries.values()].filter((entry) => !options.autostartOnly || entry.config.autostart).map((entry) => entry.config);
|
|
12374
|
-
for (const config of orderByDependencies(targets)) await this.start(config.id);
|
|
12529
|
+
for (const config of orderByDependencies(targets)) await this.start(config.id, { actor: "boot" });
|
|
12375
12530
|
}
|
|
12376
12531
|
/**
|
|
12377
12532
|
* Stops every entry this command owns. A persistent entry is deliberately not among
|
|
@@ -12402,11 +12557,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
12402
12557
|
error: `server "${id}" is stopping`
|
|
12403
12558
|
};
|
|
12404
12559
|
entry.starting = true;
|
|
12560
|
+
entry.actor = options.retry ? "policy" : options.actor ?? "policy";
|
|
12405
12561
|
this.clearRetry(entry);
|
|
12406
12562
|
if (!options.retry) {
|
|
12407
12563
|
entry.restarts = 0;
|
|
12408
12564
|
entry.healthFailures = 0;
|
|
12409
12565
|
entry.unhealthySince = null;
|
|
12566
|
+
this.persistEntryState(entry);
|
|
12410
12567
|
}
|
|
12411
12568
|
try {
|
|
12412
12569
|
await this.startDependencies(entry);
|
|
@@ -12419,6 +12576,14 @@ var init_supervisor = __esmMin((() => {
|
|
|
12419
12576
|
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
12420
12577
|
this.publishServer(entry);
|
|
12421
12578
|
if (entry.config.persistent && await this.resumePersistent(entry)) return { ok: true };
|
|
12579
|
+
if (!entry.config.persistent) {
|
|
12580
|
+
const recorded = await this.resolveRecordedChild(entry);
|
|
12581
|
+
if (recorded.kind === "blocked") return {
|
|
12582
|
+
ok: false,
|
|
12583
|
+
error: recorded.error
|
|
12584
|
+
};
|
|
12585
|
+
if (recorded.kind === "adopt") return this.adoptEntry(entry, recorded.pid, "the child this panel started before it died is still running");
|
|
12586
|
+
}
|
|
12422
12587
|
await this.runBootstrap(entry);
|
|
12423
12588
|
if (entry.stopping) return {
|
|
12424
12589
|
ok: false,
|
|
@@ -12433,7 +12598,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12433
12598
|
ok: false,
|
|
12434
12599
|
error: conflict.error
|
|
12435
12600
|
};
|
|
12436
|
-
if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid);
|
|
12601
|
+
if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid, `a detached restart of this entry is already serving port ${entry.config.port}`);
|
|
12437
12602
|
return this.spawnEntry(entry);
|
|
12438
12603
|
} finally {
|
|
12439
12604
|
entry.starting = false;
|
|
@@ -12444,9 +12609,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
12444
12609
|
if (!entry) return missingServer(id);
|
|
12445
12610
|
return this.stopEntry(entry);
|
|
12446
12611
|
}
|
|
12447
|
-
async restart(id) {
|
|
12612
|
+
async restart(id, options = {}) {
|
|
12448
12613
|
await this.stop(id);
|
|
12449
|
-
return this.start(id);
|
|
12614
|
+
return this.start(id, options);
|
|
12450
12615
|
}
|
|
12451
12616
|
/**
|
|
12452
12617
|
* Frees the port this entry wants, by asking whatever listens on it to leave.
|
|
@@ -12678,6 +12843,10 @@ var init_supervisor = __esmMin((() => {
|
|
|
12678
12843
|
entry.pid = null;
|
|
12679
12844
|
entry.adopted = false;
|
|
12680
12845
|
entry.nannyExit = null;
|
|
12846
|
+
if (entry.lastChild !== null && !isProcessAlive(entry.lastChild.pid)) {
|
|
12847
|
+
entry.lastChild = null;
|
|
12848
|
+
this.persistEntryState(entry);
|
|
12849
|
+
}
|
|
12681
12850
|
entry.status = survivor === null ? "stopped" : "conflict";
|
|
12682
12851
|
if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
|
|
12683
12852
|
this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
|
|
@@ -12688,6 +12857,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
12688
12857
|
};
|
|
12689
12858
|
}
|
|
12690
12859
|
createEntry(config) {
|
|
12860
|
+
const persisted = readEntryState(entryStatePath(this.options.nannyDir, config.id));
|
|
12861
|
+
const lastPid = persisted?.lastPid ?? null;
|
|
12691
12862
|
return {
|
|
12692
12863
|
config,
|
|
12693
12864
|
status: "stopped",
|
|
@@ -12696,9 +12867,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
12696
12867
|
child: null,
|
|
12697
12868
|
pid: null,
|
|
12698
12869
|
startedAt: null,
|
|
12870
|
+
lastChild: !config.persistent && lastPid !== null ? {
|
|
12871
|
+
pid: lastPid,
|
|
12872
|
+
startedAt: persisted?.lastStartedAt ?? 0
|
|
12873
|
+
} : null,
|
|
12699
12874
|
exitCode: null,
|
|
12700
12875
|
exitSignal: null,
|
|
12701
|
-
restarts: 0,
|
|
12876
|
+
restarts: persisted?.restarts ?? 0,
|
|
12702
12877
|
lastError: null,
|
|
12703
12878
|
nextRetryAt: null,
|
|
12704
12879
|
retryTimer: null,
|
|
@@ -12711,8 +12886,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
12711
12886
|
nannyExit: null,
|
|
12712
12887
|
starting: false,
|
|
12713
12888
|
stopping: false,
|
|
12889
|
+
actor: "policy",
|
|
12714
12890
|
removed: false,
|
|
12715
|
-
bootstrapDone: !config.bootstrap,
|
|
12891
|
+
bootstrapDone: persisted?.bootstrapDone ?? !config.bootstrap,
|
|
12716
12892
|
logs: new LogBuffer(config.logBufferLines),
|
|
12717
12893
|
echoed: /* @__PURE__ */ new Set(),
|
|
12718
12894
|
responseMs: null,
|
|
@@ -12730,6 +12906,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12730
12906
|
this.relay.unfollow(id);
|
|
12731
12907
|
this.options.logFiles.clear(id);
|
|
12732
12908
|
this.options.history.forget(id);
|
|
12909
|
+
clearEntryState(entryStatePath(this.options.nannyDir, id));
|
|
12733
12910
|
entry.removed = true;
|
|
12734
12911
|
this.stopEntry(entry).catch((error) => {
|
|
12735
12912
|
logger.error(`could not stop the removed server ${id}`, error);
|
|
@@ -12853,16 +13030,6 @@ var init_supervisor = __esmMin((() => {
|
|
|
12853
13030
|
return { kind: "free" };
|
|
12854
13031
|
}
|
|
12855
13032
|
/**
|
|
12856
|
-
* Takes over a detached successor: no spawn, no duplicate. The pid is supervised
|
|
12857
|
-
* from here on (liveness, health probe, resources, stop), while its output stays
|
|
12858
|
-
* wherever it was redirected.
|
|
12859
|
-
*
|
|
12860
|
-
* For a persistent entry that output is not a mystery: its nanny is still writing
|
|
12861
|
-
* the entry's log file, so the relay is started here too. Without this, adopting a
|
|
12862
|
-
* persistent entry whose state file was lost would show its history but never
|
|
12863
|
-
* another live line.
|
|
12864
|
-
*/
|
|
12865
|
-
/**
|
|
12866
13033
|
* Stops the listed port holders and reports whether the port came free.
|
|
12867
13034
|
*
|
|
12868
13035
|
* `kill` and `reclaim` are the same sequence over a different pid list — the foreign
|
|
@@ -12887,7 +13054,21 @@ var init_supervisor = __esmMin((() => {
|
|
|
12887
13054
|
error: entry.lastError
|
|
12888
13055
|
};
|
|
12889
13056
|
}
|
|
12890
|
-
|
|
13057
|
+
/**
|
|
13058
|
+
* Takes over a detached successor: no spawn, no duplicate. The pid is supervised
|
|
13059
|
+
* from here on (liveness, health probe, resources, stop), while its output stays
|
|
13060
|
+
* wherever it was redirected.
|
|
13061
|
+
*
|
|
13062
|
+
* For a persistent entry that output is not a mystery: its nanny is still writing
|
|
13063
|
+
* the entry's log file, so the relay is started here too. Without this, adopting a
|
|
13064
|
+
* persistent entry whose state file was lost would show its history but never
|
|
13065
|
+
* another live line.
|
|
13066
|
+
*
|
|
13067
|
+
* `reason` is the caller's sentence for *why* this pid is ours: a port holder and a
|
|
13068
|
+
* recorded child that survived a hard panel death are the same adoption, said
|
|
13069
|
+
* differently, and the log line is the only place a person sees which happened.
|
|
13070
|
+
*/
|
|
13071
|
+
adoptEntry(entry, pid, reason) {
|
|
12891
13072
|
entry.adopted = true;
|
|
12892
13073
|
entry.pid = pid;
|
|
12893
13074
|
entry.child = null;
|
|
@@ -12896,12 +13077,60 @@ var init_supervisor = __esmMin((() => {
|
|
|
12896
13077
|
entry.startedAt = Date.now();
|
|
12897
13078
|
entry.lastError = null;
|
|
12898
13079
|
if (entry.config.persistent) this.followPersistent(entry, true);
|
|
12899
|
-
this.log(entry, "system", `adopted pid ${pid}:
|
|
13080
|
+
this.log(entry, "system", `adopted pid ${pid}: ${reason}`);
|
|
12900
13081
|
this.publishServer(entry);
|
|
12901
13082
|
return { ok: true };
|
|
12902
13083
|
}
|
|
13084
|
+
/**
|
|
13085
|
+
* The child this entry's state file recorded, when it is still that same process.
|
|
13086
|
+
*
|
|
13087
|
+
* A non-persistent child is spawned detached, so it survives a panel killed hard while
|
|
13088
|
+
* nothing else remembers it — `preflight` cannot help a portless entry, which is how a
|
|
13089
|
+
* portless worker got a second copy beside the running one. A pid is recycled, so
|
|
13090
|
+
* liveness alone proves nothing: the birth time and the marker decide, and a pid this
|
|
13091
|
+
* platform cannot place is never adopted on a guess.
|
|
13092
|
+
*
|
|
13093
|
+
* The record is cleared only once its process is known to be gone or to be somebody
|
|
13094
|
+
* else, so a stale pid is never chased and a live one is never forgotten into a twin.
|
|
13095
|
+
*/
|
|
13096
|
+
async resolveRecordedChild(entry) {
|
|
13097
|
+
const recorded = entry.lastChild;
|
|
13098
|
+
if (recorded === null) return { kind: "none" };
|
|
13099
|
+
if (recorded.pid === process.pid) {
|
|
13100
|
+
entry.lastChild = null;
|
|
13101
|
+
this.persistEntryState(entry);
|
|
13102
|
+
return { kind: "none" };
|
|
13103
|
+
}
|
|
13104
|
+
const liveness = recorded.startedAt > 0 ? await processLiveness(recorded.pid, recorded.startedAt) : isProcessAlive(recorded.pid) ? "unknown" : "gone";
|
|
13105
|
+
if (liveness === "gone" || liveness === "recycled") {
|
|
13106
|
+
entry.lastChild = null;
|
|
13107
|
+
this.persistEntryState(entry);
|
|
13108
|
+
return { kind: "none" };
|
|
13109
|
+
}
|
|
13110
|
+
if (await processCarriesServerId(recorded.pid, entry.config.id)) return {
|
|
13111
|
+
kind: "adopt",
|
|
13112
|
+
pid: recorded.pid
|
|
13113
|
+
};
|
|
13114
|
+
if (entry.config.port !== null) return { kind: "none" };
|
|
13115
|
+
const error = `pid ${recorded.pid} may be a child this panel started for "${entry.config.id}" before it died, and this platform cannot confirm whose it is — not starting a second copy, stop pid ${recorded.pid} to clear it`;
|
|
13116
|
+
entry.status = "conflict";
|
|
13117
|
+
entry.lastError = error;
|
|
13118
|
+
this.log(entry, "system", error);
|
|
13119
|
+
this.publishServer(entry);
|
|
13120
|
+
return {
|
|
13121
|
+
kind: "blocked",
|
|
13122
|
+
error
|
|
13123
|
+
};
|
|
13124
|
+
}
|
|
13125
|
+
/** Clears the durable record of the last child, once that pid is known to be gone. */
|
|
13126
|
+
forgetRecordedChild(entry, pid) {
|
|
13127
|
+
if (pid === null || entry.lastChild === null || entry.lastChild.pid !== pid) return;
|
|
13128
|
+
entry.lastChild = null;
|
|
13129
|
+
this.persistEntryState(entry);
|
|
13130
|
+
}
|
|
12903
13131
|
/** An adopted successor disappeared: fall back to the normal lifecycle. */
|
|
12904
13132
|
handleAdoptedExit(entry) {
|
|
13133
|
+
const adoptedPid = entry.pid;
|
|
12905
13134
|
if (entry.pid !== null) this.sampler.forget(entry.pid);
|
|
12906
13135
|
if (entry.config.persistent) this.consumeNannyExit(entry);
|
|
12907
13136
|
const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
|
|
@@ -12909,6 +13138,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12909
13138
|
entry.nannyExit = null;
|
|
12910
13139
|
entry.adopted = false;
|
|
12911
13140
|
entry.pid = null;
|
|
13141
|
+
this.forgetRecordedChild(entry, adoptedPid);
|
|
12912
13142
|
entry.resources = null;
|
|
12913
13143
|
entry.responseMs = null;
|
|
12914
13144
|
entry.exitCode = null;
|
|
@@ -12949,6 +13179,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12949
13179
|
} catch (error) {
|
|
12950
13180
|
this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
|
|
12951
13181
|
entry.bootstrapDone = true;
|
|
13182
|
+
this.persistEntryState(entry);
|
|
12952
13183
|
return;
|
|
12953
13184
|
}
|
|
12954
13185
|
child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
|
|
@@ -12971,6 +13202,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12971
13202
|
});
|
|
12972
13203
|
});
|
|
12973
13204
|
entry.bootstrapDone = true;
|
|
13205
|
+
this.persistEntryState(entry);
|
|
12974
13206
|
if (code === 0) this.log(entry, "system", "bootstrap finished");
|
|
12975
13207
|
else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
|
|
12976
13208
|
}
|
|
@@ -13112,9 +13344,17 @@ var init_supervisor = __esmMin((() => {
|
|
|
13112
13344
|
entry.child = child;
|
|
13113
13345
|
entry.pid = child.pid ?? null;
|
|
13114
13346
|
entry.startedAt = Date.now();
|
|
13347
|
+
if (!entry.config.persistent && child.pid !== void 0) {
|
|
13348
|
+
entry.lastChild = {
|
|
13349
|
+
pid: child.pid,
|
|
13350
|
+
startedAt: entry.startedAt
|
|
13351
|
+
};
|
|
13352
|
+
this.persistEntryState(entry);
|
|
13353
|
+
}
|
|
13115
13354
|
this.options.history.record(entry.config.id, {
|
|
13116
13355
|
type: "start",
|
|
13117
|
-
detail
|
|
13356
|
+
detail,
|
|
13357
|
+
actor: entry.actor
|
|
13118
13358
|
});
|
|
13119
13359
|
entry.exitCode = null;
|
|
13120
13360
|
entry.exitSignal = null;
|
|
@@ -13304,6 +13544,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
13304
13544
|
entry.responseMs = null;
|
|
13305
13545
|
entry.exitCode = code;
|
|
13306
13546
|
entry.exitSignal = signal;
|
|
13547
|
+
this.forgetRecordedChild(entry, child.pid ?? null);
|
|
13307
13548
|
const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
|
|
13308
13549
|
const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
|
|
13309
13550
|
code,
|
|
@@ -13332,10 +13573,12 @@ var init_supervisor = __esmMin((() => {
|
|
|
13332
13573
|
}
|
|
13333
13574
|
const restart = entry.config.restart;
|
|
13334
13575
|
if (ranForMs >= restart.resetAfterMs) entry.restarts = 0;
|
|
13576
|
+
this.persistEntryState(entry);
|
|
13335
13577
|
this.log(entry, "system", neverStarted ? `did not start: ${detail}` : `exited with ${detail} after ${ranFor}`);
|
|
13336
13578
|
entry.lastError = neverStarted ? detail : `exited with ${detail}`;
|
|
13337
13579
|
if (restart.enabled && entry.restarts < restart.maxRetries) {
|
|
13338
13580
|
entry.restarts += 1;
|
|
13581
|
+
this.persistEntryState(entry);
|
|
13339
13582
|
const backoffMs = computeBackoff(entry.restarts, restart);
|
|
13340
13583
|
entry.status = "backoff";
|
|
13341
13584
|
entry.nextRetryAt = Date.now() + backoffMs;
|
|
@@ -13471,6 +13714,15 @@ var init_supervisor = __esmMin((() => {
|
|
|
13471
13714
|
entry.retryTimer = null;
|
|
13472
13715
|
}
|
|
13473
13716
|
}
|
|
13717
|
+
/** The decisions that have to survive this panel, and nothing else. */
|
|
13718
|
+
persistEntryState(entry) {
|
|
13719
|
+
writeEntryState(entryStatePath(this.options.nannyDir, entry.config.id), {
|
|
13720
|
+
restarts: entry.restarts,
|
|
13721
|
+
bootstrapDone: entry.bootstrapDone,
|
|
13722
|
+
lastPid: entry.config.persistent ? null : entry.lastChild?.pid ?? null,
|
|
13723
|
+
lastStartedAt: entry.config.persistent ? null : entry.lastChild?.startedAt ?? null
|
|
13724
|
+
});
|
|
13725
|
+
}
|
|
13474
13726
|
buildVars(entry) {
|
|
13475
13727
|
return serverTemplateVars(entry.config);
|
|
13476
13728
|
}
|
|
@@ -13754,6 +14006,28 @@ function labelOf(plan, id) {
|
|
|
13754
14006
|
return plan.items.find((item) => item.id === id)?.label ?? id;
|
|
13755
14007
|
}
|
|
13756
14008
|
/**
|
|
14009
|
+
* Restores only `fields` from an archived secrets document into a live one.
|
|
14010
|
+
*
|
|
14011
|
+
* The archive may predate a secret the live file holds, and the live file may hold one
|
|
14012
|
+
* the archive never carried: merging by field is what makes restoring the Telegram token
|
|
14013
|
+
* leave the DDNS credentials alone, and what clears a secret the archive does not carry
|
|
14014
|
+
* rather than leaving the newer value behind as if it had been restored.
|
|
14015
|
+
*/
|
|
14016
|
+
function mergeIntoFile(target, archived, fields) {
|
|
14017
|
+
const merged = mergeSecrets(parseSecrets(readText(target) ?? ""), parseSecrets(archived), fields);
|
|
14018
|
+
writeFileAtomic(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
|
|
14019
|
+
}
|
|
14020
|
+
/**
|
|
14021
|
+
* What an archive says about where it came from. Both fields are absent on an archive
|
|
14022
|
+
* written before they existed, so a UI shows the row only when there is something to show.
|
|
14023
|
+
*/
|
|
14024
|
+
function manifestInfo(manifest) {
|
|
14025
|
+
return {
|
|
14026
|
+
...manifest.writtenBy === void 0 ? {} : { writtenBy: manifest.writtenBy },
|
|
14027
|
+
...manifest.schema === void 0 ? {} : { schema: manifest.schema }
|
|
14028
|
+
};
|
|
14029
|
+
}
|
|
14030
|
+
/**
|
|
13757
14031
|
* `servers.config.json` is only a readability gate here: the store applies the
|
|
13758
14032
|
* workspace's own defaults on the next load, and an archive from another machine
|
|
13759
14033
|
* need not match this one's default shapes exactly.
|
|
@@ -13774,13 +14048,16 @@ function readableWorkspaceSettings(text) {
|
|
|
13774
14048
|
return false;
|
|
13775
14049
|
}
|
|
13776
14050
|
}
|
|
13777
|
-
var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, BackupService;
|
|
14051
|
+
var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, SECRET_LABELS, BackupService;
|
|
13778
14052
|
var init_backups = __esmMin((() => {
|
|
14053
|
+
init_migrations();
|
|
13779
14054
|
init_parse();
|
|
14055
|
+
init_secret_parts();
|
|
13780
14056
|
init_atomic();
|
|
13781
14057
|
init_env_file();
|
|
13782
14058
|
init_paths();
|
|
13783
14059
|
init_template();
|
|
14060
|
+
init_version();
|
|
13784
14061
|
init_archive();
|
|
13785
14062
|
init_supervisor();
|
|
13786
14063
|
init_error_message();
|
|
@@ -13794,6 +14071,24 @@ var init_backups = __esmMin((() => {
|
|
|
13794
14071
|
]);
|
|
13795
14072
|
SUFFIX = ".zip";
|
|
13796
14073
|
RELOADABLE = /^global:settings$|^workspace:[^:]+:(?:settings|servers)$/;
|
|
14074
|
+
SECRET_LABELS = {
|
|
14075
|
+
password: {
|
|
14076
|
+
en: "Password",
|
|
14077
|
+
key: "settings.backup.secretPassword"
|
|
14078
|
+
},
|
|
14079
|
+
apiToken: {
|
|
14080
|
+
en: "API token",
|
|
14081
|
+
key: "settings.backup.secretApiToken"
|
|
14082
|
+
},
|
|
14083
|
+
telegram: {
|
|
14084
|
+
en: "Telegram bot token",
|
|
14085
|
+
key: "settings.backup.secretTelegram"
|
|
14086
|
+
},
|
|
14087
|
+
ddns: {
|
|
14088
|
+
en: "DDNS credentials",
|
|
14089
|
+
key: "settings.backup.secretDdns"
|
|
14090
|
+
}
|
|
14091
|
+
};
|
|
13797
14092
|
BackupService = class {
|
|
13798
14093
|
options;
|
|
13799
14094
|
/**
|
|
@@ -13856,6 +14151,16 @@ var init_backups = __esmMin((() => {
|
|
|
13856
14151
|
const wanted = options.include === void 0 ? null : new Set(options.include);
|
|
13857
14152
|
const capturesGlobal = (id) => wanted === null || wanted.has(id);
|
|
13858
14153
|
const capturesWorkspace = (id, suffix) => wanted === null || wanted.has(`workspace:${id}`) || wanted.has(`workspace:${id}:${suffix}`);
|
|
14154
|
+
/**
|
|
14155
|
+
* The secrets of one scope the request asked for; `null` means "every one of them".
|
|
14156
|
+
* `workspaceId` is how a bare `workspace:<id>` selection covers all of its leaves.
|
|
14157
|
+
*/
|
|
14158
|
+
const selectedSecretFields = (entryId, path, scope, workspaceId) => {
|
|
14159
|
+
const parts = secretParts(scope, presentSecretKeys(parseSecrets(readText(path) ?? "")));
|
|
14160
|
+
const coversEntry = wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`);
|
|
14161
|
+
const picked = parts.filter((part) => coversEntry || wanted.has(`${entryId}:${part.key}`) || part.fields.some((field) => wanted.has(`${entryId}:${field}`)));
|
|
14162
|
+
return picked.length === parts.length ? null : picked.flatMap((part) => part.fields);
|
|
14163
|
+
};
|
|
13859
14164
|
const dir = this.resolveDir();
|
|
13860
14165
|
const sources = this.options.getSources();
|
|
13861
14166
|
const staging = path.join(dir, `.staging-${Date.now()}`);
|
|
@@ -13868,15 +14173,41 @@ var init_backups = __esmMin((() => {
|
|
|
13868
14173
|
const copy = (relative, source, ignoreGenerated = false) => {
|
|
13869
14174
|
if (this.copyInto(staging, relative, source, ignoreGenerated)) captured++;
|
|
13870
14175
|
};
|
|
14176
|
+
/**
|
|
14177
|
+
* Captures one secrets file, narrowed to the picked secrets. Copying the whole file
|
|
14178
|
+
* would capture the ones that were *not* picked, so a per-secret selection writes a
|
|
14179
|
+
* staged document holding only those — and the restore side merges by field, which
|
|
14180
|
+
* is what keeps a restore from wiping a secret the archive never carried.
|
|
14181
|
+
*/
|
|
14182
|
+
const copySecrets = (relative, source, entryId, scope, workspaceId) => {
|
|
14183
|
+
const text = readText(source);
|
|
14184
|
+
if (text === null) return;
|
|
14185
|
+
const fields = selectedSecretFields(entryId, source, scope, workspaceId);
|
|
14186
|
+
if (fields === null) {
|
|
14187
|
+
copy(relative, source);
|
|
14188
|
+
return;
|
|
14189
|
+
}
|
|
14190
|
+
const chosen = pickSecrets(parseSecrets(text), fields);
|
|
14191
|
+
if (chosen === null) return;
|
|
14192
|
+
const target = path.join(staging, relative);
|
|
14193
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
14194
|
+
fs.writeFileSync(target, `${chosen}\n`);
|
|
14195
|
+
captured++;
|
|
14196
|
+
};
|
|
14197
|
+
/**
|
|
14198
|
+
* True when the request touches a secrets entry at all: the entry itself, one of its
|
|
14199
|
+
* leaves, or — for a workspace — the workspace id, which names every leaf it has.
|
|
14200
|
+
*/
|
|
14201
|
+
const capturesSecrets = (entryId, workspaceId) => wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`) || [...wanted].some((id) => id.startsWith(`${entryId}:`));
|
|
13871
14202
|
if (capturesGlobal("global:settings")) copy("global/settings.json", sources.globalSettingsPath);
|
|
13872
|
-
if (
|
|
14203
|
+
if (capturesSecrets("global:secrets")) copySecrets("global/secrets.json", sources.globalSecretsPath, "global:secrets", "global");
|
|
13873
14204
|
if (capturesGlobal("global:tls")) copy("global/tls", sources.tlsDir);
|
|
13874
14205
|
const data = [];
|
|
13875
14206
|
for (const workspace of sources.workspaces) {
|
|
13876
14207
|
const root = `workspaces/${workspace.id}`;
|
|
13877
14208
|
if (capturesWorkspace(workspace.id, "settings")) copy(`${root}/settings.json`, workspace.settingsPath);
|
|
13878
14209
|
if (capturesWorkspace(workspace.id, "servers")) copy(`${root}/servers.config.json`, workspace.serversPath);
|
|
13879
|
-
if (
|
|
14210
|
+
if (capturesSecrets(`workspace:${workspace.id}:secrets`, workspace.id)) copySecrets(`${root}/secrets.json`, workspace.secretsPath, `workspace:${workspace.id}:secrets`, "workspace", workspace.id);
|
|
13880
14211
|
for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
|
|
13881
14212
|
if (!declared.included) continue;
|
|
13882
14213
|
if (!capturesWorkspace(workspace.id, `data:${declared.path}`)) continue;
|
|
@@ -13907,6 +14238,8 @@ var init_backups = __esmMin((() => {
|
|
|
13907
14238
|
version: 1,
|
|
13908
14239
|
createdAt,
|
|
13909
14240
|
hostname: os.hostname(),
|
|
14241
|
+
writtenBy: appVersion(),
|
|
14242
|
+
schema: 1,
|
|
13910
14243
|
data
|
|
13911
14244
|
};
|
|
13912
14245
|
fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -14021,6 +14354,11 @@ var init_backups = __esmMin((() => {
|
|
|
14021
14354
|
error: "the archive has no manifest"
|
|
14022
14355
|
};
|
|
14023
14356
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
14357
|
+
plan.archive = manifestInfo(manifest);
|
|
14358
|
+
if (manifest.schema !== void 0 && manifest.schema > 1) return {
|
|
14359
|
+
...plan,
|
|
14360
|
+
error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 1`
|
|
14361
|
+
};
|
|
14024
14362
|
const sources = this.options.getSources();
|
|
14025
14363
|
const selectedIds = options.include === void 0 ? null : new Set(options.include);
|
|
14026
14364
|
const actions = /* @__PURE__ */ new Map();
|
|
@@ -14077,16 +14415,32 @@ var init_backups = __esmMin((() => {
|
|
|
14077
14415
|
writeFileAtomic(sources.globalSettingsPath, settingsText);
|
|
14078
14416
|
}, ["config"]);
|
|
14079
14417
|
const globalSecrets = readText(path.join(staging, "global", "secrets.json"));
|
|
14080
|
-
if (globalSecrets !== null)
|
|
14081
|
-
|
|
14082
|
-
|
|
14083
|
-
|
|
14084
|
-
|
|
14085
|
-
|
|
14086
|
-
|
|
14087
|
-
|
|
14088
|
-
|
|
14089
|
-
|
|
14418
|
+
if (globalSecrets !== null) {
|
|
14419
|
+
addItem({
|
|
14420
|
+
id: "global:secrets",
|
|
14421
|
+
label: "Global secrets (password, API token)",
|
|
14422
|
+
labelKey: "settings.backup.entryGlobalSecrets",
|
|
14423
|
+
kind: "secrets",
|
|
14424
|
+
restorable: true,
|
|
14425
|
+
note: null
|
|
14426
|
+
}, () => {
|
|
14427
|
+
writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
|
|
14428
|
+
}, ["secrets"]);
|
|
14429
|
+
for (const part of secretParts("global", presentSecretKeys(parseSecrets(globalSecrets)))) addItem({
|
|
14430
|
+
id: `global:secrets:${part.key}`,
|
|
14431
|
+
label: `Global secrets: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
|
|
14432
|
+
labelKey: "settings.backup.restoreItemLabel",
|
|
14433
|
+
labelParams: {
|
|
14434
|
+
workspace: "global",
|
|
14435
|
+
item: part.key
|
|
14436
|
+
},
|
|
14437
|
+
kind: "secrets",
|
|
14438
|
+
restorable: true,
|
|
14439
|
+
note: null
|
|
14440
|
+
}, () => {
|
|
14441
|
+
mergeIntoFile(sources.globalSecretsPath, globalSecrets, part.fields);
|
|
14442
|
+
});
|
|
14443
|
+
}
|
|
14090
14444
|
const tlsInArchive = path.join(staging, "global", "tls");
|
|
14091
14445
|
if (fs.existsSync(tlsInArchive)) {
|
|
14092
14446
|
const files = fs.readdirSync(tlsInArchive).filter((file) => fs.statSync(path.join(tlsInArchive, file)).isFile());
|
|
@@ -14214,21 +14568,38 @@ var init_backups = __esmMin((() => {
|
|
|
14214
14568
|
});
|
|
14215
14569
|
}
|
|
14216
14570
|
}
|
|
14217
|
-
if (secrets !== null)
|
|
14218
|
-
|
|
14219
|
-
|
|
14220
|
-
|
|
14221
|
-
|
|
14222
|
-
|
|
14223
|
-
|
|
14224
|
-
|
|
14225
|
-
|
|
14226
|
-
|
|
14227
|
-
|
|
14228
|
-
|
|
14229
|
-
|
|
14230
|
-
|
|
14231
|
-
|
|
14571
|
+
if (secrets !== null) {
|
|
14572
|
+
addItem({
|
|
14573
|
+
id: `workspace:${id}:secrets`,
|
|
14574
|
+
label: `${workspace.label}: Secrets`,
|
|
14575
|
+
labelKey: "settings.backup.restoreItemLabel",
|
|
14576
|
+
labelParams: {
|
|
14577
|
+
workspace: workspace.label,
|
|
14578
|
+
item: "secrets"
|
|
14579
|
+
},
|
|
14580
|
+
kind: "secrets",
|
|
14581
|
+
workspaceId: id,
|
|
14582
|
+
restorable: true,
|
|
14583
|
+
note: null
|
|
14584
|
+
}, () => {
|
|
14585
|
+
writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
|
|
14586
|
+
});
|
|
14587
|
+
for (const part of secretParts("workspace", presentSecretKeys(parseSecrets(secrets)))) addItem({
|
|
14588
|
+
id: `workspace:${id}:secrets:${part.key}`,
|
|
14589
|
+
label: `${workspace.label}: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
|
|
14590
|
+
labelKey: "settings.backup.restoreItemLabel",
|
|
14591
|
+
labelParams: {
|
|
14592
|
+
workspace: workspace.label,
|
|
14593
|
+
item: part.key
|
|
14594
|
+
},
|
|
14595
|
+
kind: "secrets",
|
|
14596
|
+
workspaceId: id,
|
|
14597
|
+
restorable: true,
|
|
14598
|
+
note: null
|
|
14599
|
+
}, () => {
|
|
14600
|
+
mergeIntoFile(workspace.secretsPath, secrets, part.fields);
|
|
14601
|
+
});
|
|
14602
|
+
}
|
|
14232
14603
|
}
|
|
14233
14604
|
const instanceTargets = [];
|
|
14234
14605
|
for (const workspace of sources.workspaces) for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
|
|
@@ -14381,7 +14752,7 @@ var init_backups = __esmMin((() => {
|
|
|
14381
14752
|
labelKey: "settings.backup.entryGlobalSecrets",
|
|
14382
14753
|
kind: "secrets",
|
|
14383
14754
|
...missing(sources.globalSecretsPath, "settings.backup.noteMissing"),
|
|
14384
|
-
items:
|
|
14755
|
+
items: this.secretItems("global", "global:secrets", sources.globalSecretsPath)
|
|
14385
14756
|
},
|
|
14386
14757
|
{
|
|
14387
14758
|
id: "global:tls",
|
|
@@ -14402,6 +14773,27 @@ var init_backups = __esmMin((() => {
|
|
|
14402
14773
|
});
|
|
14403
14774
|
return entries;
|
|
14404
14775
|
}
|
|
14776
|
+
/**
|
|
14777
|
+
* One leaf per secret the file actually holds, so a restore can bring back the
|
|
14778
|
+
* password without the API token, or the Telegram token without the DDNS credentials.
|
|
14779
|
+
*
|
|
14780
|
+
* The set comes from the file's own top-level keys rather than a list here, so a
|
|
14781
|
+
* secret added later appears on its own — the failure mode of a second list is a
|
|
14782
|
+
* secret that can be captured but never restored individually.
|
|
14783
|
+
*/
|
|
14784
|
+
secretItems(scope, entryId, path) {
|
|
14785
|
+
const present = presentSecretKeys(parseSecrets(readText(path) ?? ""));
|
|
14786
|
+
if (present.length === 0) return [];
|
|
14787
|
+
return secretParts(scope, present).map((part) => ({
|
|
14788
|
+
id: `${entryId}:${part.key}`,
|
|
14789
|
+
label: SECRET_LABELS[part.key]?.en ?? part.key,
|
|
14790
|
+
...SECRET_LABELS[part.key] === void 0 ? {} : { labelKey: SECRET_LABELS[part.key].key },
|
|
14791
|
+
kind: "secrets",
|
|
14792
|
+
secret: part.key,
|
|
14793
|
+
included: true,
|
|
14794
|
+
note: null
|
|
14795
|
+
}));
|
|
14796
|
+
}
|
|
14405
14797
|
workspaceItems(workspace, includePaths) {
|
|
14406
14798
|
const id = workspace.id;
|
|
14407
14799
|
const note = (path) => {
|
|
@@ -14435,7 +14827,8 @@ var init_backups = __esmMin((() => {
|
|
|
14435
14827
|
kind: "secrets",
|
|
14436
14828
|
included: true,
|
|
14437
14829
|
...note(workspace.secretsPath)
|
|
14438
|
-
}
|
|
14830
|
+
},
|
|
14831
|
+
...this.secretItems("workspace", `workspace:${id}:secrets`, workspace.secretsPath)
|
|
14439
14832
|
];
|
|
14440
14833
|
for (const declared of this.declaredData(workspace.servers, includePaths)) items.push({
|
|
14441
14834
|
id: `workspace:${id}:data:${declared.path}`,
|