home-hosted 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -5
- package/README.md +50 -14
- package/dist/cli.js +1331 -282
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +2 -1
- package/docs/NOTIFICATIONS.md +72 -20
- package/docs/SERVERS.md +20 -1
- package/docs/UI_CREATION.md +22 -7
- package/docs/branding/logo.svg +33 -0
- package/docs/branding/social-preview.png +0 -0
- package/package.json +27 -24
- package/uis/stock/dist/assets/de-DL4xkOYj.js +1 -0
- package/uis/stock/dist/assets/es-CKj-v3FY.js +1 -0
- package/uis/stock/dist/assets/fr-72WremqT.js +1 -0
- package/uis/stock/dist/assets/index-BNSSU69y.js +47 -0
- package/uis/stock/dist/assets/index-GxTN2fYn.css +2 -0
- package/uis/stock/dist/assets/ja-BU4XyMbC.js +1 -0
- package/uis/stock/dist/assets/ko-Db8xtJ0N.js +1 -0
- package/uis/stock/dist/assets/ru-Bs8KXP2O.js +1 -0
- package/uis/stock/dist/assets/vi-DdQD4h7B.js +1 -0
- package/uis/stock/dist/assets/zh-CN-DIrw2Ubv.js +1 -0
- package/uis/stock/dist/favicon.ico +0 -0
- package/uis/stock/dist/favicon.svg +14 -0
- package/uis/stock/dist/index.html +6 -2
- package/uis/stock/dist/ui.json +3 -3
- package/uis/stock/dist/assets/de-DQxDtYp2.js +0 -1
- package/uis/stock/dist/assets/es-BP0qzCTU.js +0 -1
- package/uis/stock/dist/assets/fr-DHCvxiys.js +0 -1
- package/uis/stock/dist/assets/index-C02FUAwz.js +0 -46
- package/uis/stock/dist/assets/index-Skf2FzpK.css +0 -2
- package/uis/stock/dist/assets/ja-DseIeS1D.js +0 -1
- package/uis/stock/dist/assets/ko-Dcu2b6c2.js +0 -1
- package/uis/stock/dist/assets/ru-DaAAVw09.js +0 -1
- package/uis/stock/dist/assets/vi-D780h-t9.js +0 -1
- package/uis/stock/dist/assets/zh-CN-C8OC-N1b.js +0 -1
package/dist/cli.js
CHANGED
|
@@ -709,7 +709,22 @@ function parseBind(value) {
|
|
|
709
709
|
const parsed = bindSchema(value);
|
|
710
710
|
return parsed instanceof type.errors ? null : parsed;
|
|
711
711
|
}
|
|
712
|
-
|
|
712
|
+
/**
|
|
713
|
+
* The ids that appear more than once, so a write can refuse them.
|
|
714
|
+
*
|
|
715
|
+
* A schema-level check is not available here (see above), and this is the one shape that has to
|
|
716
|
+
* be told apart from a plain validation error: the list is valid, but its identity is not.
|
|
717
|
+
*/
|
|
718
|
+
function duplicateWebhookIds(targets) {
|
|
719
|
+
const seen = /* @__PURE__ */ new Set();
|
|
720
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
721
|
+
for (const target of targets) {
|
|
722
|
+
if (seen.has(target.id)) duplicates.add(target.id);
|
|
723
|
+
seen.add(target.id);
|
|
724
|
+
}
|
|
725
|
+
return [...duplicates];
|
|
726
|
+
}
|
|
727
|
+
var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, notificationPolicyShape, notificationDefaultsSchema, useOwnSettingsSchema, telegramDestinationSchema, webhookTargetSchema, notificationTargetSchema, destinationListSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupScheduleSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, notificationPolicyPatchShape, destinationPatchSchema, notificationsPatchSchema, hostPatchSchema, backupSchedulePatchSchema, 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, notificationPolicyStatusShape, destinationStatusSchema, 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, destinationIdParamSchema, destinationTestSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
713
728
|
var init_contracts = __esmMin((() => {
|
|
714
729
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
715
730
|
workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
|
|
@@ -735,6 +750,10 @@ var init_contracts = __esmMin((() => {
|
|
|
735
750
|
resetAfterMs: "number >= 0 = 60000"
|
|
736
751
|
}).onUndeclaredKey("reject");
|
|
737
752
|
httpCheckSchema = type({
|
|
753
|
+
/** `https` is for a backend that serves TLS itself; pair it with `allowInvalidCert` when it is self-signed. */
|
|
754
|
+
scheme: "\"http\" | \"https\" = \"http\"",
|
|
755
|
+
/** Accept any certificate. A self-signed pair is the normal case here, and the probe is local. */
|
|
756
|
+
allowInvalidCert: "boolean = false",
|
|
738
757
|
/** Path on the server's own port, e.g. `/healthz`. */
|
|
739
758
|
path: "string = \"/\"",
|
|
740
759
|
method: "\"GET\" | \"HEAD\" = \"GET\"",
|
|
@@ -831,9 +850,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
831
850
|
maxLoginAttempts: "number.integer >= 1 = 5",
|
|
832
851
|
lockoutMs: "number >= 1000 = 60000"
|
|
833
852
|
}).onUndeclaredKey("reject");
|
|
834
|
-
|
|
835
|
-
enabled: "boolean = false",
|
|
836
|
-
chatId: "string = \"\"",
|
|
853
|
+
notificationPolicyShape = {
|
|
837
854
|
onCrash: "boolean = true",
|
|
838
855
|
onUnhealthy: "boolean = true",
|
|
839
856
|
onForcedRestart: "boolean = true",
|
|
@@ -844,8 +861,44 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
844
861
|
onDdns: "boolean = true",
|
|
845
862
|
/** Per server *and* reason, so a flapping server cannot spam the chat. */
|
|
846
863
|
cooldownMs: "number >= 0 = 120000"
|
|
864
|
+
};
|
|
865
|
+
notificationDefaultsSchema = type({ ...notificationPolicyShape }).onUndeclaredKey("reject");
|
|
866
|
+
useOwnSettingsSchema = "boolean = true";
|
|
867
|
+
telegramDestinationSchema = type({
|
|
868
|
+
kind: "\"telegram\"",
|
|
869
|
+
id: "/^[a-z0-9][a-z0-9_-]*$/",
|
|
870
|
+
/** Off until configured: a bare `enabled: true` with no chat would fail every send. */
|
|
871
|
+
enabled: "boolean = false",
|
|
872
|
+
chatId: "string = \"\"",
|
|
873
|
+
useOwnSettings: useOwnSettingsSchema,
|
|
874
|
+
...notificationPolicyShape
|
|
875
|
+
}).onUndeclaredKey("reject");
|
|
876
|
+
webhookTargetSchema = type({
|
|
877
|
+
kind: "\"webhook\"",
|
|
878
|
+
id: "/^[a-z0-9][a-z0-9_-]*$/",
|
|
879
|
+
/** Empty only while the target is a draft; the page will not save one without it. */
|
|
880
|
+
url: "string = \"\"",
|
|
881
|
+
enabled: "boolean = true",
|
|
882
|
+
method: "\"POST\" | \"PUT\" = \"POST\"",
|
|
883
|
+
/** Payload format: `json` sends the event as JSON, `text` sends the plain message. */
|
|
884
|
+
format: "\"json\" | \"text\" = \"json\"",
|
|
885
|
+
/**
|
|
886
|
+
* Extra headers, `Name: value` per line. The one field that lets this transport talk to
|
|
887
|
+
* ntfy (`Title`, `Priority`), a bearer-token endpoint (`Authorization`) or anything else
|
|
888
|
+
* with a header-shaped contract.
|
|
889
|
+
*/
|
|
890
|
+
headers: "string = \"\"",
|
|
891
|
+
useOwnSettings: useOwnSettingsSchema,
|
|
892
|
+
...notificationPolicyShape
|
|
893
|
+
}).onUndeclaredKey("reject");
|
|
894
|
+
notificationTargetSchema = telegramDestinationSchema.or(webhookTargetSchema);
|
|
895
|
+
destinationListSchema = notificationTargetSchema.array();
|
|
896
|
+
notificationsSchema = type({
|
|
897
|
+
/** What every destination inherits unless it carries its own settings. */
|
|
898
|
+
defaults: notificationDefaultsSchema.default(() => ({})),
|
|
899
|
+
/** Replaced by a patch, the same way `ddns.accounts` is: a merge cannot express removing one. */
|
|
900
|
+
destinations: destinationListSchema.default(() => [])
|
|
847
901
|
}).onUndeclaredKey("reject");
|
|
848
|
-
notificationsSchema = type({ telegram: telegramSchema.default(() => ({})) }).onUndeclaredKey("reject");
|
|
849
902
|
logsSchema = type({
|
|
850
903
|
persist: "boolean = true",
|
|
851
904
|
/** Per server, before rotating to `.1`, `.2`, ... */
|
|
@@ -970,12 +1023,20 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
970
1023
|
accounts: ddnsAccountSchema.array().optional(),
|
|
971
1024
|
domains: ddnsDomainSchema.array().optional()
|
|
972
1025
|
}).onUndeclaredKey("reject");
|
|
1026
|
+
backupScheduleSchema = type({
|
|
1027
|
+
/** 0 turns the schedule off; otherwise the gap between automatic archives. */
|
|
1028
|
+
intervalMs: "number.integer >= 0 = 0",
|
|
1029
|
+
/** How many automatic archives to keep. Manual ones are never pruned by it. */
|
|
1030
|
+
keep: "number.integer >= 1 = 7"
|
|
1031
|
+
}).onUndeclaredKey("reject");
|
|
973
1032
|
backupsSchema = type({
|
|
974
1033
|
enabled: "boolean = true",
|
|
975
1034
|
dir: "string = \".backups\"",
|
|
1035
|
+
/** How many archives to keep in total, newest first. */
|
|
976
1036
|
keep: "number.integer >= 1 = 5",
|
|
977
1037
|
/** Extra paths in every backup, in addition to each server's `backupPaths`. */
|
|
978
|
-
includePaths: type("string[]").default(() => [])
|
|
1038
|
+
includePaths: type("string[]").default(() => []),
|
|
1039
|
+
schedule: backupScheduleSchema.default(() => ({}))
|
|
979
1040
|
}).onUndeclaredKey("reject");
|
|
980
1041
|
controlSchema = type({
|
|
981
1042
|
/** What the panel calls itself; the stock UI shows it in the sidebar. */
|
|
@@ -1006,6 +1067,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1006
1067
|
resetAfterMs: "number >= 0?"
|
|
1007
1068
|
}).onUndeclaredKey("reject");
|
|
1008
1069
|
httpCheckPatchSchema = type({
|
|
1070
|
+
scheme: "\"http\" | \"https\"?",
|
|
1071
|
+
allowInvalidCert: "boolean?",
|
|
1009
1072
|
path: "string?",
|
|
1010
1073
|
method: "\"GET\" | \"HEAD\"?",
|
|
1011
1074
|
expectStatus: "number.integer | null?",
|
|
@@ -1037,9 +1100,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1037
1100
|
maxLoginAttempts: "number.integer >= 1?",
|
|
1038
1101
|
lockoutMs: "number >= 1000?"
|
|
1039
1102
|
}).onUndeclaredKey("reject");
|
|
1040
|
-
|
|
1041
|
-
enabled: "boolean?",
|
|
1042
|
-
chatId: "string?",
|
|
1103
|
+
notificationPolicyPatchShape = {
|
|
1043
1104
|
onCrash: "boolean?",
|
|
1044
1105
|
onUnhealthy: "boolean?",
|
|
1045
1106
|
onForcedRestart: "boolean?",
|
|
@@ -1047,8 +1108,36 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1047
1108
|
onHost: "boolean?",
|
|
1048
1109
|
onDdns: "boolean?",
|
|
1049
1110
|
cooldownMs: "number >= 0?"
|
|
1111
|
+
};
|
|
1112
|
+
destinationPatchSchema = type({
|
|
1113
|
+
kind: "\"telegram\" | \"webhook\"",
|
|
1114
|
+
id: "/^[a-z0-9][a-z0-9_-]*$/",
|
|
1115
|
+
enabled: "boolean?",
|
|
1116
|
+
useOwnSettings: "boolean?",
|
|
1117
|
+
/** Telegram only. */
|
|
1118
|
+
chatId: "string?",
|
|
1119
|
+
/** Webhook only. */
|
|
1120
|
+
url: "string?",
|
|
1121
|
+
method: "\"POST\" | \"PUT\"?",
|
|
1122
|
+
format: "\"json\" | \"text\"?",
|
|
1123
|
+
headers: "string?",
|
|
1124
|
+
...notificationPolicyPatchShape
|
|
1125
|
+
}).onUndeclaredKey("reject");
|
|
1126
|
+
notificationsPatchSchema = type({
|
|
1127
|
+
/** Merged key by key, like the other nested groups. */
|
|
1128
|
+
/**
|
|
1129
|
+
* A merge key-by-key, and **every field optional** — `notificationPolicyPatchShape`, not a
|
|
1130
|
+
* `.partial()` of the defaults schema.
|
|
1131
|
+
*
|
|
1132
|
+
* ArkType's `.partial()` leaves a field's `= false` / `= 120000` default in place, so a body of
|
|
1133
|
+
* `{ onHost: false }` validated into all seven keys and one toggle overwrote the rest. A patch
|
|
1134
|
+
* schema here carries no defaults (the rule for every patch), and the store merges it over the
|
|
1135
|
+
* stored block.
|
|
1136
|
+
*/
|
|
1137
|
+
defaults: type({ ...notificationPolicyPatchShape }).onUndeclaredKey("reject").optional(),
|
|
1138
|
+
/** Replaced whole, so a removed destination stays removed. */
|
|
1139
|
+
destinations: destinationPatchSchema.array().optional()
|
|
1050
1140
|
}).onUndeclaredKey("reject");
|
|
1051
|
-
notificationsPatchSchema = type({ telegram: telegramPatchSchema.optional() }).onUndeclaredKey("reject");
|
|
1052
1141
|
hostPatchSchema = type({
|
|
1053
1142
|
enabled: "boolean?",
|
|
1054
1143
|
intervalMs: "number >= 5000?",
|
|
@@ -1059,11 +1148,16 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1059
1148
|
loadPerCpu: "number >= 0?",
|
|
1060
1149
|
tempCelsius: "number >= 0?"
|
|
1061
1150
|
}).onUndeclaredKey("reject");
|
|
1151
|
+
backupSchedulePatchSchema = type({
|
|
1152
|
+
intervalMs: "number.integer >= 0?",
|
|
1153
|
+
keep: "number.integer >= 1?"
|
|
1154
|
+
}).onUndeclaredKey("reject");
|
|
1062
1155
|
backupsPatchSchema = type({
|
|
1063
1156
|
enabled: "boolean?",
|
|
1064
1157
|
dir: "string?",
|
|
1065
1158
|
keep: "number.integer >= 1?",
|
|
1066
|
-
includePaths: type("string[]").optional()
|
|
1159
|
+
includePaths: type("string[]").optional(),
|
|
1160
|
+
schedule: backupSchedulePatchSchema.optional()
|
|
1067
1161
|
}).onUndeclaredKey("reject");
|
|
1068
1162
|
logsPatchSchema = type({
|
|
1069
1163
|
persist: "boolean?",
|
|
@@ -1154,6 +1248,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1154
1248
|
"heartbeatAt": "number",
|
|
1155
1249
|
"lastExit?": nannyExitSchema
|
|
1156
1250
|
}).onUndeclaredKey("reject");
|
|
1251
|
+
entryStateSchema = type({
|
|
1252
|
+
/** Attempts spent on the current crash sequence; reset once the process stays up. */
|
|
1253
|
+
"restarts": "number.integer >= 0",
|
|
1254
|
+
/** The `runOnce` bootstrap already ran for this entry. */
|
|
1255
|
+
"bootstrapDone": "boolean",
|
|
1256
|
+
/**
|
|
1257
|
+
* The last child this panel spawned for a non-persistent entry. A detached child
|
|
1258
|
+
* outlives the panel, so this is the only way a later boot recognizes its own
|
|
1259
|
+
* survivor instead of spawning a second copy beside it.
|
|
1260
|
+
*/
|
|
1261
|
+
"lastPid?": "number | null",
|
|
1262
|
+
"lastStartedAt?": "number | null"
|
|
1263
|
+
});
|
|
1157
1264
|
settingsPatchSchema = type({
|
|
1158
1265
|
control: controlPatchSchema.optional(),
|
|
1159
1266
|
host: hostPatchSchema.optional(),
|
|
@@ -1371,10 +1478,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1371
1478
|
privateKey: "string >= 1"
|
|
1372
1479
|
}).onUndeclaredKey("reject");
|
|
1373
1480
|
proxyEngineInstallSchema = type({ version: "string = \"\"" }).onUndeclaredKey("reject");
|
|
1374
|
-
|
|
1375
|
-
enabled: "boolean",
|
|
1376
|
-
tokenSet: "boolean",
|
|
1377
|
-
chatId: "string",
|
|
1481
|
+
notificationPolicyStatusShape = {
|
|
1378
1482
|
onCrash: "boolean",
|
|
1379
1483
|
onUnhealthy: "boolean",
|
|
1380
1484
|
onForcedRestart: "boolean",
|
|
@@ -1383,8 +1487,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1383
1487
|
onHost: "boolean",
|
|
1384
1488
|
/** Dynamic DNS, when this panel knows about it (optional for an older panel). */
|
|
1385
1489
|
onDdns: "boolean?",
|
|
1386
|
-
cooldownMs: "number"
|
|
1387
|
-
|
|
1490
|
+
cooldownMs: "number"
|
|
1491
|
+
};
|
|
1492
|
+
destinationStatusSchema = type({
|
|
1493
|
+
id: "string",
|
|
1494
|
+
kind: "\"telegram\" | \"webhook\"",
|
|
1495
|
+
enabled: "boolean",
|
|
1496
|
+
useOwnSettings: "boolean",
|
|
1497
|
+
...notificationPolicyStatusShape,
|
|
1498
|
+
/** Telegram only, null on a webhook. */
|
|
1499
|
+
chatId: "string | null",
|
|
1500
|
+
/** Telegram only: whether a bot token is stored for this destination. */
|
|
1501
|
+
tokenSet: "boolean",
|
|
1502
|
+
/** Webhook only, null on a Telegram destination. */
|
|
1503
|
+
url: "string | null",
|
|
1504
|
+
method: "\"POST\" | \"PUT\" | null",
|
|
1505
|
+
format: "\"json\" | \"text\" | null",
|
|
1506
|
+
headers: "string | null",
|
|
1507
|
+
/** Last delivery outcome, for the page. */
|
|
1388
1508
|
lastResult: "string | null",
|
|
1389
1509
|
lastResultAt: "number | null"
|
|
1390
1510
|
});
|
|
@@ -1392,7 +1512,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1392
1512
|
id: "string | number",
|
|
1393
1513
|
title: "string"
|
|
1394
1514
|
}).array() });
|
|
1395
|
-
notificationViewSchema = type({
|
|
1515
|
+
notificationViewSchema = type({
|
|
1516
|
+
/** The general block: what a destination inherits unless it overrides it. Optional for an older panel. */
|
|
1517
|
+
"defaults?": notificationDefaultsSchema,
|
|
1518
|
+
/** Every destination, in config order. Optional for an older panel. */
|
|
1519
|
+
"destinations?": destinationStatusSchema.array()
|
|
1520
|
+
});
|
|
1396
1521
|
sessionViewSchema = type({
|
|
1397
1522
|
authenticated: "boolean",
|
|
1398
1523
|
authRequired: "boolean",
|
|
@@ -1419,13 +1544,16 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1419
1544
|
stream: logStreamSchema,
|
|
1420
1545
|
text: "string"
|
|
1421
1546
|
});
|
|
1547
|
+
historyActorSchema = type.enumerated("policy", "user", "agent", "boot");
|
|
1422
1548
|
historyEventSchema = type({
|
|
1423
|
-
serverId: "string",
|
|
1424
|
-
ts: "number",
|
|
1425
|
-
type: "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
|
|
1426
|
-
detail: "string",
|
|
1549
|
+
"serverId": "string",
|
|
1550
|
+
"ts": "number",
|
|
1551
|
+
"type": "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
|
|
1552
|
+
"detail": "string",
|
|
1427
1553
|
/** How long the process had been up, recorded on exit and crash. */
|
|
1428
|
-
runtimeMs: "number?"
|
|
1554
|
+
"runtimeMs": "number?",
|
|
1555
|
+
/** Who caused it; absent on events from before this field existed. */
|
|
1556
|
+
"actor?": historyActorSchema
|
|
1429
1557
|
});
|
|
1430
1558
|
serverHistorySchema = type({
|
|
1431
1559
|
windowMs: "number",
|
|
@@ -1497,6 +1625,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1497
1625
|
"labelParams?": "Record<string, string>",
|
|
1498
1626
|
/** `settings`/`servers`/`secrets` for a workspace, `data` for a declared path. */
|
|
1499
1627
|
"kind": "\"settings\" | \"servers\" | \"secrets\" | \"data\"",
|
|
1628
|
+
/** Which secret a `secrets` leaf carries (`password`, `apiToken`, `telegram`, `ddns`, …). */
|
|
1629
|
+
"secret?": "string",
|
|
1500
1630
|
"path?": "string",
|
|
1501
1631
|
/** Who declared a data path: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
|
|
1502
1632
|
"origin?": "string",
|
|
@@ -1508,14 +1638,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1508
1638
|
}).array()
|
|
1509
1639
|
});
|
|
1510
1640
|
backupsViewSchema = type({
|
|
1511
|
-
enabled: "boolean",
|
|
1512
|
-
dir: "string",
|
|
1513
|
-
keep: "number",
|
|
1641
|
+
"enabled": "boolean",
|
|
1642
|
+
"dir": "string",
|
|
1643
|
+
"keep": "number",
|
|
1644
|
+
/**
|
|
1645
|
+
* The scheduled half, so the page can show and edit it.
|
|
1646
|
+
*
|
|
1647
|
+
* Optional because the panel and the UI upgrade separately: an older panel does not send it,
|
|
1648
|
+
* and a required field would blank the settings page for that panel. The page reads it as
|
|
1649
|
+
* "off, keep 7" when absent — the schema defaults — so nothing changes for it.
|
|
1650
|
+
*/
|
|
1651
|
+
"schedule?": backupScheduleSchema,
|
|
1652
|
+
/** When the last automatic archive was written; null until one has run. Optional for the same reason. */
|
|
1653
|
+
"lastAutomaticAt?": "number | null",
|
|
1514
1654
|
/** Extra paths from the global config, in addition to each server's own. */
|
|
1515
|
-
includePaths: type("string[]"),
|
|
1655
|
+
"includePaths": type("string[]"),
|
|
1516
1656
|
/** Every selectable entry, global first, then each workspace. */
|
|
1517
|
-
entries: backupEntrySchema.array(),
|
|
1518
|
-
files: backupFileSchema.array()
|
|
1657
|
+
"entries": backupEntrySchema.array(),
|
|
1658
|
+
"files": backupFileSchema.array()
|
|
1519
1659
|
});
|
|
1520
1660
|
restoreItemSchema = type({
|
|
1521
1661
|
/** A backup entry or leaf id (`workspace:<id>:servers`, `data:<path>`, ...). */
|
|
@@ -1546,6 +1686,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1546
1686
|
"encrypted": "boolean",
|
|
1547
1687
|
/** The archive needs a password (none or a wrong one was supplied). */
|
|
1548
1688
|
"needsPassword": "boolean",
|
|
1689
|
+
/**
|
|
1690
|
+
* Where the archive says it came from. Optional: an archive written before this
|
|
1691
|
+
* stamp existed carries neither field, and an older panel never sends the row.
|
|
1692
|
+
*/
|
|
1693
|
+
"archive?": type({
|
|
1694
|
+
"writtenBy?": "string",
|
|
1695
|
+
"schema?": "number"
|
|
1696
|
+
}),
|
|
1549
1697
|
"items": restoreItemSchema.array(),
|
|
1550
1698
|
"applied": type("string[]"),
|
|
1551
1699
|
/**
|
|
@@ -1636,11 +1784,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1636
1784
|
"files?": "number.integer >= 1",
|
|
1637
1785
|
/** `owner/name` of the UI's own repository, for `ui-update`. */
|
|
1638
1786
|
"repo?": "string",
|
|
1639
|
-
/** The release tag this build came from, e.g. `v0.6.0`. */
|
|
1787
|
+
/** The release tag this build came from, e.g. `v0.6.0`. Stamped by the build, never committed. */
|
|
1640
1788
|
"tag?": "string",
|
|
1641
1789
|
/** The release asset name, e.g. `home-hosted-ui-noc-console`. */
|
|
1642
1790
|
"asset?": "string",
|
|
1643
|
-
/** When the UI was built, in unix epoch seconds. */
|
|
1791
|
+
/** When the UI was built, in unix epoch seconds. Stamped by the build, never committed. */
|
|
1644
1792
|
"unix?": "number.integer >= 0"
|
|
1645
1793
|
});
|
|
1646
1794
|
uiStatusSchema = type({
|
|
@@ -1740,10 +1888,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1740
1888
|
lines: logLineSchema.array()
|
|
1741
1889
|
});
|
|
1742
1890
|
notificationActionSchema = type({
|
|
1743
|
-
/** Optional override, so the token can be
|
|
1891
|
+
/** Optional override, so the token can be tried before it is saved. */
|
|
1744
1892
|
botToken: "string?",
|
|
1745
1893
|
chatId: "string?"
|
|
1746
1894
|
}).onUndeclaredKey("reject");
|
|
1895
|
+
destinationIdParamSchema = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
|
|
1896
|
+
destinationTestSchema = type({
|
|
1897
|
+
target: webhookTargetSchema.omit("kind", "id").and(type({
|
|
1898
|
+
id: "string?",
|
|
1899
|
+
kind: "\"webhook\"?"
|
|
1900
|
+
})),
|
|
1901
|
+
/** Echoed so the result is recorded against the right row; absent for a draft. */
|
|
1902
|
+
id: "string?"
|
|
1903
|
+
}).onUndeclaredKey("reject");
|
|
1747
1904
|
telegramTokenSchema = type({ botToken: "string >= 1" }).onUndeclaredKey("reject");
|
|
1748
1905
|
apiErrorSchema = type({
|
|
1749
1906
|
"message": "string",
|
|
@@ -1941,6 +2098,20 @@ function isPlainDdns(value) {
|
|
|
1941
2098
|
return "plain" in value;
|
|
1942
2099
|
}
|
|
1943
2100
|
/**
|
|
2101
|
+
* The bot tokens by destination id.
|
|
2102
|
+
*
|
|
2103
|
+
* Two shapes read: the map this release writes, and the single `{ botToken }` written before a
|
|
2104
|
+
* workspace could have more than one Telegram destination. The old one arrives under the id the
|
|
2105
|
+
* config migration gave the destination it came from, so an upgrade keeps a working Telegram.
|
|
2106
|
+
*/
|
|
2107
|
+
function readTelegramTokens(value) {
|
|
2108
|
+
if (!isRecord(value)) return {};
|
|
2109
|
+
if (typeof value.botToken === "string") return value.botToken.trim().length > 0 ? { [LEGACY_TELEGRAM_ID$1]: value.botToken.trim() } : {};
|
|
2110
|
+
const tokens = {};
|
|
2111
|
+
for (const [id, token] of Object.entries(value)) if (typeof token === "string" && token.trim().length > 0) tokens[id] = token.trim();
|
|
2112
|
+
return tokens;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
1944
2115
|
* Sealed on disk, or `{ provider, values }` waiting for the sealing write. A bare
|
|
1945
2116
|
* field map — written before the provider was recorded — no longer reads, because
|
|
1946
2117
|
* there is nothing to say which provider those fields belong to.
|
|
@@ -2022,7 +2193,7 @@ function openDdns(accountId, sealed, key) {
|
|
|
2022
2193
|
return null;
|
|
2023
2194
|
}
|
|
2024
2195
|
}
|
|
2025
|
-
var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_SEALED_ALGO, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
|
|
2196
|
+
var COST, KEYLEN, SALT_BYTES, LEGACY_TELEGRAM_ID$1, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_SEALED_ALGO, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
|
|
2026
2197
|
var init_secrets = __esmMin((() => {
|
|
2027
2198
|
init_atomic();
|
|
2028
2199
|
init_logger();
|
|
@@ -2035,6 +2206,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2035
2206
|
};
|
|
2036
2207
|
KEYLEN = 64;
|
|
2037
2208
|
SALT_BYTES = 16;
|
|
2209
|
+
LEGACY_TELEGRAM_ID$1 = "telegram";
|
|
2038
2210
|
DDNS_KEYLEN = 32;
|
|
2039
2211
|
DDNS_IV_BYTES = 12;
|
|
2040
2212
|
DDNS_AAD_PREFIX = "hh-ddns-v1:";
|
|
@@ -2112,11 +2284,17 @@ var init_secrets = __esmMin((() => {
|
|
|
2112
2284
|
get apiTokenHint() {
|
|
2113
2285
|
return this.apiToken?.hint ?? null;
|
|
2114
2286
|
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2287
|
+
/** The bot token for one destination, or null when it has none. */
|
|
2288
|
+
telegramToken(id) {
|
|
2289
|
+
const token = this.load().telegram[id] ?? "";
|
|
2290
|
+
return token.length > 0 ? token : null;
|
|
2291
|
+
}
|
|
2292
|
+
telegramTokenSet(id) {
|
|
2293
|
+
return this.telegramToken(id) !== null;
|
|
2117
2294
|
}
|
|
2118
|
-
|
|
2119
|
-
|
|
2295
|
+
/** Every destination id a token is stored for — including one the config no longer declares. */
|
|
2296
|
+
get telegramTokenIds() {
|
|
2297
|
+
return Object.keys(this.load().telegram).sort();
|
|
2120
2298
|
}
|
|
2121
2299
|
setPassword(password, options = {}) {
|
|
2122
2300
|
const record = hashPassword(password, options);
|
|
@@ -2151,11 +2329,24 @@ var init_secrets = __esmMin((() => {
|
|
|
2151
2329
|
apiToken: null
|
|
2152
2330
|
});
|
|
2153
2331
|
}
|
|
2154
|
-
setTelegramToken(token) {
|
|
2332
|
+
setTelegramToken(id, token) {
|
|
2155
2333
|
const trimmed = token?.trim() ?? "";
|
|
2334
|
+
const current = { ...this.load().telegram };
|
|
2335
|
+
if (trimmed.length > 0) current[id] = trimmed;
|
|
2336
|
+
else delete current[id];
|
|
2156
2337
|
this.save({
|
|
2157
2338
|
...this.load(),
|
|
2158
|
-
telegram:
|
|
2339
|
+
telegram: current
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
/** Drops every token whose destination is gone, so a deleted target leaves nothing behind. */
|
|
2343
|
+
forgetTelegramTokens(keep) {
|
|
2344
|
+
const allowed = new Set(keep);
|
|
2345
|
+
const current = this.load().telegram;
|
|
2346
|
+
const next = Object.fromEntries(Object.entries(current).filter(([id]) => allowed.has(id)));
|
|
2347
|
+
if (Object.keys(next).length !== Object.keys(current).length) this.save({
|
|
2348
|
+
...this.load(),
|
|
2349
|
+
telegram: next
|
|
2159
2350
|
});
|
|
2160
2351
|
}
|
|
2161
2352
|
/** Ids that have something stored, whether or not a config still declares them. */
|
|
@@ -2260,7 +2451,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2260
2451
|
const empty = {
|
|
2261
2452
|
password: null,
|
|
2262
2453
|
apiToken: null,
|
|
2263
|
-
telegram:
|
|
2454
|
+
telegram: {},
|
|
2264
2455
|
ddns: {},
|
|
2265
2456
|
ddnsKdf: null
|
|
2266
2457
|
};
|
|
@@ -2277,7 +2468,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2277
2468
|
return this.inScope({
|
|
2278
2469
|
password: parsed?.password ?? null,
|
|
2279
2470
|
apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
|
|
2280
|
-
telegram: parsed?.telegram
|
|
2471
|
+
telegram: readTelegramTokens(parsed?.telegram),
|
|
2281
2472
|
ddns: readStoredDdns(parsed?.ddns),
|
|
2282
2473
|
ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
|
|
2283
2474
|
});
|
|
@@ -2290,7 +2481,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2290
2481
|
inScope(contents) {
|
|
2291
2482
|
if (this.scope === "global") return {
|
|
2292
2483
|
...contents,
|
|
2293
|
-
telegram:
|
|
2484
|
+
telegram: {},
|
|
2294
2485
|
ddns: {},
|
|
2295
2486
|
ddnsKdf: null
|
|
2296
2487
|
};
|
|
@@ -2635,6 +2826,14 @@ function createAuthGuard(deps) {
|
|
|
2635
2826
|
await next();
|
|
2636
2827
|
};
|
|
2637
2828
|
}
|
|
2829
|
+
/**
|
|
2830
|
+
* Who is behind a request, as a history event names them: a cookie is a person at
|
|
2831
|
+
* the panel, a bearer token is a script or an agent. Anonymous callers can only be
|
|
2832
|
+
* loopback requests while auth is unarmed, which is the browser's own origin.
|
|
2833
|
+
*/
|
|
2834
|
+
function requestActor(c, auth) {
|
|
2835
|
+
return requestIdentity(c, auth).method === "token" ? "agent" : "user";
|
|
2836
|
+
}
|
|
2638
2837
|
var PUBLIC_PATHS, AUTH_REQUIRED_CODE;
|
|
2639
2838
|
var init_auth = __esmMin((() => {
|
|
2640
2839
|
init_loopback();
|
|
@@ -3087,7 +3286,10 @@ function buildWorkspaceView(source) {
|
|
|
3087
3286
|
logsDir: source.logsDir,
|
|
3088
3287
|
defaults: source.store.defaults,
|
|
3089
3288
|
logs: source.store.logs,
|
|
3090
|
-
notifications: {
|
|
3289
|
+
notifications: {
|
|
3290
|
+
defaults: source.notifications.defaults(),
|
|
3291
|
+
destinations: source.notifications.destinations()
|
|
3292
|
+
},
|
|
3091
3293
|
ddns: source.ddns.view,
|
|
3092
3294
|
serverCount: servers.length,
|
|
3093
3295
|
runningCount: servers.filter((server) => server.status === "running").length,
|
|
@@ -3643,7 +3845,7 @@ function createControlRoute(deps) {
|
|
|
3643
3845
|
}
|
|
3644
3846
|
}), validate("query", workspaceQuerySchema), async (c) => {
|
|
3645
3847
|
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"));
|
|
3848
|
+
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"), { actor: "user" });
|
|
3647
3849
|
return c.json(result, statusForAction(result));
|
|
3648
3850
|
}).post("/servers/:id/stop", describeRoute({
|
|
3649
3851
|
tags: ["panel"],
|
|
@@ -3667,7 +3869,7 @@ function createControlRoute(deps) {
|
|
|
3667
3869
|
}
|
|
3668
3870
|
}), validate("query", workspaceQuerySchema), async (c) => {
|
|
3669
3871
|
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"));
|
|
3872
|
+
const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"), { actor: "user" });
|
|
3671
3873
|
return c.json(result, statusForAction(result));
|
|
3672
3874
|
});
|
|
3673
3875
|
}
|
|
@@ -3683,9 +3885,55 @@ var init_control = __esmMin((() => {
|
|
|
3683
3885
|
}));
|
|
3684
3886
|
//#endregion
|
|
3685
3887
|
//#region src/config/migrations.ts
|
|
3888
|
+
/**
|
|
3889
|
+
* Schema 1 → 2: `notifications.{telegram, webhooks}` → `notifications.{defaults, destinations}`.
|
|
3890
|
+
*
|
|
3891
|
+
* The old singleton becomes one `telegram` destination, and each webhook keeps its id, so a
|
|
3892
|
+
* destination's cooldown, last-result record and Telegram token all still point at the same thing.
|
|
3893
|
+
* `useOwnSettings` is set **true** on every migrated destination: before this schema each one
|
|
3894
|
+
* carried its own toggles, and reading one as "inherit" would silently change what it delivers.
|
|
3895
|
+
* The old webhook `enabled` default (true) and the Telegram one (false) are preserved as they were.
|
|
3896
|
+
*/
|
|
3897
|
+
function notificationsToDestinations(config) {
|
|
3898
|
+
const notifications = config.notifications;
|
|
3899
|
+
if (notifications === null || typeof notifications !== "object" || Array.isArray(notifications)) return config;
|
|
3900
|
+
const group = notifications;
|
|
3901
|
+
if (group.destinations !== void 0 || group.telegram === void 0 && group.webhooks === void 0) return config;
|
|
3902
|
+
const destinations = [];
|
|
3903
|
+
const telegram = group.telegram;
|
|
3904
|
+
if (telegram !== null && typeof telegram === "object") {
|
|
3905
|
+
const { enabled, chatId, ...policy } = telegram;
|
|
3906
|
+
destinations.push({
|
|
3907
|
+
kind: "telegram",
|
|
3908
|
+
id: LEGACY_TELEGRAM_ID,
|
|
3909
|
+
enabled: enabled ?? false,
|
|
3910
|
+
chatId: chatId ?? "",
|
|
3911
|
+
useOwnSettings: true,
|
|
3912
|
+
...policy
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
if (Array.isArray(group.webhooks)) for (const entry of group.webhooks) {
|
|
3916
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
3917
|
+
destinations.push({
|
|
3918
|
+
kind: "webhook",
|
|
3919
|
+
useOwnSettings: true,
|
|
3920
|
+
...entry
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3923
|
+
const next = {
|
|
3924
|
+
...group,
|
|
3925
|
+
destinations
|
|
3926
|
+
};
|
|
3927
|
+
delete next.telegram;
|
|
3928
|
+
delete next.webhooks;
|
|
3929
|
+
return {
|
|
3930
|
+
...config,
|
|
3931
|
+
notifications: next
|
|
3932
|
+
};
|
|
3933
|
+
}
|
|
3686
3934
|
/** What would have to run to bring `from` up to `to`. */
|
|
3687
3935
|
function planConfigMigrations(from, options = {}) {
|
|
3688
|
-
const to = options.to ??
|
|
3936
|
+
const to = options.to ?? 2;
|
|
3689
3937
|
const steps = (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to);
|
|
3690
3938
|
return {
|
|
3691
3939
|
from,
|
|
@@ -3695,19 +3943,42 @@ function planConfigMigrations(from, options = {}) {
|
|
|
3695
3943
|
reached: steps.at(-1)?.to ?? from
|
|
3696
3944
|
};
|
|
3697
3945
|
}
|
|
3698
|
-
/**
|
|
3946
|
+
/**
|
|
3947
|
+
* Applies the plan in order; the caller owns writing the result.
|
|
3948
|
+
*
|
|
3949
|
+
* The result carries `meta.schema` set to the schema the steps reached, so it is readable by this
|
|
3950
|
+
* release's own parser — which refuses a file whose stamp still says an older schema. Without that,
|
|
3951
|
+
* `migrate` validated its own output as "needs 1 migration" and refused every config it exists to
|
|
3952
|
+
* lift.
|
|
3953
|
+
*/
|
|
3699
3954
|
function applyConfigMigrations(config, from, options = {}) {
|
|
3700
|
-
const
|
|
3955
|
+
const plan = planConfigMigrations(from, options);
|
|
3701
3956
|
let current = config;
|
|
3702
|
-
for (const step of steps) current = step.apply(current);
|
|
3703
|
-
return {
|
|
3957
|
+
for (const step of plan.steps) current = step.apply(current);
|
|
3958
|
+
if (plan.steps.length === 0) return {
|
|
3704
3959
|
config: current,
|
|
3705
|
-
applied: steps
|
|
3960
|
+
applied: plan.steps
|
|
3961
|
+
};
|
|
3962
|
+
const meta = current.meta !== null && typeof current.meta === "object" && !Array.isArray(current.meta) ? current.meta : {};
|
|
3963
|
+
return {
|
|
3964
|
+
config: {
|
|
3965
|
+
...current,
|
|
3966
|
+
meta: {
|
|
3967
|
+
...meta,
|
|
3968
|
+
schema: plan.reached
|
|
3969
|
+
}
|
|
3970
|
+
},
|
|
3971
|
+
applied: plan.steps
|
|
3706
3972
|
};
|
|
3707
3973
|
}
|
|
3708
|
-
var configMigrations;
|
|
3974
|
+
var LEGACY_TELEGRAM_ID, configMigrations;
|
|
3709
3975
|
var init_migrations = __esmMin((() => {
|
|
3710
|
-
|
|
3976
|
+
LEGACY_TELEGRAM_ID = "telegram";
|
|
3977
|
+
configMigrations = [{
|
|
3978
|
+
to: 2,
|
|
3979
|
+
describe: "notifications: fold the Telegram singleton and the webhook list into one `destinations` list, with a shared `defaults` block",
|
|
3980
|
+
apply: notificationsToDestinations
|
|
3981
|
+
}];
|
|
3711
3982
|
}));
|
|
3712
3983
|
//#endregion
|
|
3713
3984
|
//#region src/config/parse.ts
|
|
@@ -3771,10 +4042,10 @@ function readStamp(raw, options) {
|
|
|
3771
4042
|
const meta = isRecord(raw.meta) ? raw.meta : null;
|
|
3772
4043
|
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
3773
4044
|
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
3774
|
-
if (schemaVersion >
|
|
4045
|
+
if (schemaVersion > 2) return {
|
|
3775
4046
|
schemaVersion,
|
|
3776
4047
|
writtenBy,
|
|
3777
|
-
error: `written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema
|
|
4048
|
+
error: `written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 2`
|
|
3778
4049
|
};
|
|
3779
4050
|
const { steps } = planConfigMigrations(schemaVersion, options);
|
|
3780
4051
|
if (steps.length > 0) return {
|
|
@@ -3931,6 +4202,7 @@ function validateCrossField(servers) {
|
|
|
3931
4202
|
for (const server of servers) {
|
|
3932
4203
|
const { health } = server;
|
|
3933
4204
|
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`);
|
|
4205
|
+
if (health.mode === "http" && health.http.scheme === "http" && health.http.allowInvalidCert) errors.push(`"${server.id}" sets allowInvalidCert on an http probe, where there is no certificate to check`);
|
|
3934
4206
|
for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
3935
4207
|
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
3936
4208
|
}
|
|
@@ -3958,7 +4230,7 @@ function stampConfig(draft) {
|
|
|
3958
4230
|
...$schema === void 0 ? {} : { $schema },
|
|
3959
4231
|
meta: {
|
|
3960
4232
|
writtenBy: appVersion(),
|
|
3961
|
-
schema:
|
|
4233
|
+
schema: 2
|
|
3962
4234
|
},
|
|
3963
4235
|
...rest
|
|
3964
4236
|
};
|
|
@@ -4009,7 +4281,7 @@ function mergeGroup(target, patch) {
|
|
|
4009
4281
|
}
|
|
4010
4282
|
return merged;
|
|
4011
4283
|
}
|
|
4012
|
-
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, EMPTY_MERGE_KEYS;
|
|
4284
|
+
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, BACKUP_MERGE_KEYS, EMPTY_MERGE_KEYS;
|
|
4013
4285
|
var init_patch = __esmMin((() => {
|
|
4014
4286
|
init_shape();
|
|
4015
4287
|
SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -4018,9 +4290,10 @@ var init_patch = __esmMin((() => {
|
|
|
4018
4290
|
"stop"
|
|
4019
4291
|
]);
|
|
4020
4292
|
CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
|
|
4021
|
-
NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["
|
|
4293
|
+
NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["defaults"]);
|
|
4022
4294
|
DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
|
|
4023
4295
|
PROXY_MERGE_KEYS = /* @__PURE__ */ new Set(["dns01"]);
|
|
4296
|
+
BACKUP_MERGE_KEYS = /* @__PURE__ */ new Set(["schedule"]);
|
|
4024
4297
|
EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
|
|
4025
4298
|
}));
|
|
4026
4299
|
//#endregion
|
|
@@ -4210,7 +4483,7 @@ var init_settings$1 = __esmMin((() => {
|
|
|
4210
4483
|
updateBackups(patch) {
|
|
4211
4484
|
const draft = structuredClone(this.raw);
|
|
4212
4485
|
draft.backups = { ...draft.backups ?? {} };
|
|
4213
|
-
applyPatch(draft.backups, patch,
|
|
4486
|
+
applyPatch(draft.backups, patch, BACKUP_MERGE_KEYS);
|
|
4214
4487
|
const backups = this.parseGroup(backupsSchema, draft.backups, "backups");
|
|
4215
4488
|
this.commit(draft);
|
|
4216
4489
|
return backups;
|
|
@@ -5772,6 +6045,171 @@ var init_ddns$2 = __esmMin((() => {
|
|
|
5772
6045
|
BY_ID$1 = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
|
|
5773
6046
|
}));
|
|
5774
6047
|
//#endregion
|
|
6048
|
+
//#region src/helpers/env-file.ts
|
|
6049
|
+
/** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
|
|
6050
|
+
function parseEnvFile(text) {
|
|
6051
|
+
const env = {};
|
|
6052
|
+
for (const raw of text.split("\n")) {
|
|
6053
|
+
const line = raw.trim();
|
|
6054
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
6055
|
+
const assignment = line.startsWith("export ") ? line.slice(7) : line;
|
|
6056
|
+
const separator = assignment.indexOf("=");
|
|
6057
|
+
if (separator <= 0) continue;
|
|
6058
|
+
const key = assignment.slice(0, separator).trim();
|
|
6059
|
+
let value = assignment.slice(separator + 1).trim();
|
|
6060
|
+
if (value.length > 1 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
6061
|
+
env[key] = value;
|
|
6062
|
+
}
|
|
6063
|
+
return env;
|
|
6064
|
+
}
|
|
6065
|
+
/** Missing files are not an error: an env file is an optional override layer. */
|
|
6066
|
+
function loadEnvFile(file) {
|
|
6067
|
+
try {
|
|
6068
|
+
return {
|
|
6069
|
+
env: parseEnvFile(fs.readFileSync(file, "utf8")),
|
|
6070
|
+
path: file,
|
|
6071
|
+
error: null
|
|
6072
|
+
};
|
|
6073
|
+
} catch (error) {
|
|
6074
|
+
if (error.code === "ENOENT") return {
|
|
6075
|
+
env: {},
|
|
6076
|
+
path: file,
|
|
6077
|
+
error: null
|
|
6078
|
+
};
|
|
6079
|
+
return {
|
|
6080
|
+
env: {},
|
|
6081
|
+
path: file,
|
|
6082
|
+
error: errorMessage(error)
|
|
6083
|
+
};
|
|
6084
|
+
}
|
|
6085
|
+
}
|
|
6086
|
+
/** Expands `${VAR}` from the given vars; unknown references are left visible. */
|
|
6087
|
+
function expandEnv(value, vars) {
|
|
6088
|
+
return value.replace(VARIABLE, (match, name) => vars[name] ?? match);
|
|
6089
|
+
}
|
|
6090
|
+
function expandEnvRecord(record, vars) {
|
|
6091
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expandEnv(value, vars)]));
|
|
6092
|
+
}
|
|
6093
|
+
function expandEnvList(values, vars) {
|
|
6094
|
+
return values.map((value) => expandEnv(value, vars));
|
|
6095
|
+
}
|
|
6096
|
+
function resolveEnvFilePath(file, cwd) {
|
|
6097
|
+
if (path.isAbsolute(file)) return file;
|
|
6098
|
+
return path.resolve(cwd, file);
|
|
6099
|
+
}
|
|
6100
|
+
var VARIABLE;
|
|
6101
|
+
var init_env_file = __esmMin((() => {
|
|
6102
|
+
init_error_message();
|
|
6103
|
+
VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
|
|
6104
|
+
}));
|
|
6105
|
+
//#endregion
|
|
6106
|
+
//#region src/providers/webhook.ts
|
|
6107
|
+
/** The JSON body for one event. Exported so a test can pin the shape without a round trip. */
|
|
6108
|
+
function webhookPayload(event, now = Date.now()) {
|
|
6109
|
+
const text = `${event.title}\n${event.message}`;
|
|
6110
|
+
return {
|
|
6111
|
+
text,
|
|
6112
|
+
content: text,
|
|
6113
|
+
title: event.title,
|
|
6114
|
+
message: event.message,
|
|
6115
|
+
reason: event.reason,
|
|
6116
|
+
server: event.serverId.length > 0 ? event.serverId : null,
|
|
6117
|
+
label: event.label.length > 0 ? event.label : null,
|
|
6118
|
+
at: now
|
|
6119
|
+
};
|
|
6120
|
+
}
|
|
6121
|
+
/**
|
|
6122
|
+
* `Name: value` lines into a header map.
|
|
6123
|
+
*
|
|
6124
|
+
* The format is line-based rather than a JSON object because it is edited in a textarea, and
|
|
6125
|
+
* a value with a colon in it (`Authorization: Bearer a:b`) must survive: only the **first**
|
|
6126
|
+
* colon separates the two.
|
|
6127
|
+
*
|
|
6128
|
+
* `vars` expands `${ENV}` in the values, so a credential does not have to be written into the
|
|
6129
|
+
* config file. A name the panel's environment does not define is left as written, the same rule
|
|
6130
|
+
* `expandEnv` follows everywhere else.
|
|
6131
|
+
*/
|
|
6132
|
+
function parseHeaders(text, vars = process.env) {
|
|
6133
|
+
const headers = {};
|
|
6134
|
+
const errors = [];
|
|
6135
|
+
for (const line of text.split("\n")) {
|
|
6136
|
+
const trimmed = line.trim();
|
|
6137
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
6138
|
+
const at = trimmed.indexOf(":");
|
|
6139
|
+
if (at <= 0) {
|
|
6140
|
+
errors.push(`"${trimmed}" is not a "Name: value" header`);
|
|
6141
|
+
continue;
|
|
6142
|
+
}
|
|
6143
|
+
headers[trimmed.slice(0, at).trim()] = expandEnv(trimmed.slice(at + 1).trim(), vars);
|
|
6144
|
+
}
|
|
6145
|
+
return {
|
|
6146
|
+
headers,
|
|
6147
|
+
errors
|
|
6148
|
+
};
|
|
6149
|
+
}
|
|
6150
|
+
/** Why a target cannot be sent to, or null when it can. Also used to validate a save. */
|
|
6151
|
+
function targetProblem(target, options = {}) {
|
|
6152
|
+
const url = target.url.trim();
|
|
6153
|
+
if (url.length === 0) return options.requireUrl === false ? null : "no URL configured";
|
|
6154
|
+
let parsed;
|
|
6155
|
+
try {
|
|
6156
|
+
parsed = new URL(url);
|
|
6157
|
+
} catch {
|
|
6158
|
+
return "the URL is not valid";
|
|
6159
|
+
}
|
|
6160
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return `the URL must be http or https, not ${parsed.protocol}`;
|
|
6161
|
+
const { errors } = parseHeaders(target.headers);
|
|
6162
|
+
return errors.length > 0 ? errors.join("; ") : null;
|
|
6163
|
+
}
|
|
6164
|
+
async function sendWebhook(target, event, fetchImpl) {
|
|
6165
|
+
const problem = targetProblem(target);
|
|
6166
|
+
if (problem !== null) return {
|
|
6167
|
+
ok: false,
|
|
6168
|
+
error: problem
|
|
6169
|
+
};
|
|
6170
|
+
const { headers, errors } = parseHeaders(target.headers);
|
|
6171
|
+
if (errors.length > 0) return {
|
|
6172
|
+
ok: false,
|
|
6173
|
+
error: errors.join("; ")
|
|
6174
|
+
};
|
|
6175
|
+
const payload = webhookPayload(event);
|
|
6176
|
+
const asText = target.format === "text";
|
|
6177
|
+
const body = asText ? payload.message : JSON.stringify(payload);
|
|
6178
|
+
const url = expandEnv(target.url.trim(), process.env);
|
|
6179
|
+
if (url.includes("${")) return {
|
|
6180
|
+
ok: false,
|
|
6181
|
+
error: `the URL still holds an unexpanded placeholder: ${url}`
|
|
6182
|
+
};
|
|
6183
|
+
try {
|
|
6184
|
+
const response = await (fetchImpl ?? fetch)(url, {
|
|
6185
|
+
method: target.method,
|
|
6186
|
+
headers: {
|
|
6187
|
+
"content-type": asText ? "text/plain; charset=utf-8" : "application/json",
|
|
6188
|
+
...headers
|
|
6189
|
+
},
|
|
6190
|
+
body,
|
|
6191
|
+
signal: AbortSignal.timeout(1e4)
|
|
6192
|
+
});
|
|
6193
|
+
if (!response.ok) {
|
|
6194
|
+
const trimmed = (await response.text().catch(() => "")).trim().slice(0, 200);
|
|
6195
|
+
return {
|
|
6196
|
+
ok: false,
|
|
6197
|
+
error: `answered ${response.status}${trimmed.length > 0 ? `: ${trimmed}` : ""}`
|
|
6198
|
+
};
|
|
6199
|
+
}
|
|
6200
|
+
return { ok: true };
|
|
6201
|
+
} catch (error) {
|
|
6202
|
+
return {
|
|
6203
|
+
ok: false,
|
|
6204
|
+
error: errorMessage(error)
|
|
6205
|
+
};
|
|
6206
|
+
}
|
|
6207
|
+
}
|
|
6208
|
+
var init_webhook = __esmMin((() => {
|
|
6209
|
+
init_env_file();
|
|
6210
|
+
init_error_message();
|
|
6211
|
+
}));
|
|
6212
|
+
//#endregion
|
|
5775
6213
|
//#region src/config/store.ts
|
|
5776
6214
|
function readFile(file) {
|
|
5777
6215
|
let text;
|
|
@@ -5817,6 +6255,8 @@ var init_store = __esmMin((() => {
|
|
|
5817
6255
|
init_settings$1();
|
|
5818
6256
|
init_atomic();
|
|
5819
6257
|
init_ddns$2();
|
|
6258
|
+
init_webhook();
|
|
6259
|
+
init_contracts();
|
|
5820
6260
|
WorkspaceStore = class {
|
|
5821
6261
|
workspaceId;
|
|
5822
6262
|
settingsFile;
|
|
@@ -6036,6 +6476,13 @@ var init_store = __esmMin((() => {
|
|
|
6036
6476
|
draft.notifications = { ...draft.notifications ?? {} };
|
|
6037
6477
|
applyPatch(draft.notifications, patch, NOTIFICATION_MERGE_KEYS);
|
|
6038
6478
|
const notifications = this.parseGroup(notificationsSchema, draft.notifications, "notifications");
|
|
6479
|
+
const duplicates = duplicateWebhookIds(notifications.destinations);
|
|
6480
|
+
if (duplicates.length > 0) throw new ConfigError(`notifications: duplicate webhook id ${duplicates.map((id) => `"${id}"`).join(", ")}`);
|
|
6481
|
+
for (const target of notifications.destinations) {
|
|
6482
|
+
if (target.kind !== "webhook") continue;
|
|
6483
|
+
const problem = targetProblem(target, { requireUrl: true });
|
|
6484
|
+
if (problem !== null) throw new ConfigError(`notifications: webhook "${target.id}" ${problem}`);
|
|
6485
|
+
}
|
|
6039
6486
|
this.commitSettings(draft);
|
|
6040
6487
|
return notifications;
|
|
6041
6488
|
}
|
|
@@ -6423,8 +6870,8 @@ var init_events = __esmMin((() => {
|
|
|
6423
6870
|
/**
|
|
6424
6871
|
* Liveness for external monitors. Mounted outside `/api`, so it answers without a
|
|
6425
6872
|
* session: it reports whether the panel itself is serving, and 503 when an
|
|
6426
|
-
* autostart server has crashed
|
|
6427
|
-
* authenticated caller.
|
|
6873
|
+
* autostart server has crashed, or a running one is failing its own health check.
|
|
6874
|
+
* Server details are only included for an authenticated caller.
|
|
6428
6875
|
*/
|
|
6429
6876
|
function createHealthRoute(deps) {
|
|
6430
6877
|
return appFactory.createApp().get("/healthz", describeRoute({
|
|
@@ -6435,12 +6882,12 @@ function createHealthRoute(deps) {
|
|
|
6435
6882
|
description: "Serving",
|
|
6436
6883
|
content: jsonBody(healthResponseSchema)
|
|
6437
6884
|
},
|
|
6438
|
-
503: { description: "An autostart server has crashed" }
|
|
6885
|
+
503: { description: "An autostart server has crashed, or a running one is unhealthy" }
|
|
6439
6886
|
}
|
|
6440
6887
|
}), (c) => {
|
|
6441
6888
|
const state = deps.panel.getState();
|
|
6442
6889
|
const servers = state.workspaces.flatMap((workspace) => workspace.servers);
|
|
6443
|
-
const broken = servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
6890
|
+
const broken = servers.filter((server) => server.config.autostart && server.status === "crashed" || server.status === "running" && server.health === "unhealthy");
|
|
6444
6891
|
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
6445
6892
|
return c.json({
|
|
6446
6893
|
status: broken.length > 0 ? "degraded" : "ok",
|
|
@@ -6735,58 +7182,77 @@ var init_telegram = __esmMin((() => {
|
|
|
6735
7182
|
//#endregion
|
|
6736
7183
|
//#region src/api/notifications.ts
|
|
6737
7184
|
/**
|
|
6738
|
-
*
|
|
7185
|
+
* A destination's credential is written straight to a workspace's secrets file and never into a
|
|
6739
7186
|
* servers config, so notification *policy* and the *credential* stay separate.
|
|
6740
7187
|
*
|
|
6741
|
-
* Notifications belong to a workspace
|
|
7188
|
+
* Notifications belong to a workspace, and every route is scoped to one. The token is keyed by the
|
|
7189
|
+
* **destination id**, which is what lets a workspace point at more than one Telegram chat.
|
|
6742
7190
|
*/
|
|
6743
7191
|
function createNotificationsRoute(deps) {
|
|
6744
|
-
|
|
7192
|
+
const runtimeFor = (workspace) => requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), workspace);
|
|
7193
|
+
return appFactory.createApp().put("/notifications/destinations/:id/token", describeRoute({
|
|
6745
7194
|
tags: ["notifications"],
|
|
6746
|
-
summary: "Store
|
|
7195
|
+
summary: "Store a Telegram destination's bot token (verified first)",
|
|
6747
7196
|
responses: {
|
|
6748
7197
|
200: { description: "Stored" },
|
|
6749
7198
|
400: ERROR_RESPONSES[400]
|
|
6750
7199
|
}
|
|
6751
|
-
}), validate("query", workspaceQuerySchema), validate("json", telegramTokenSchema), async (c) => {
|
|
6752
|
-
const runtime =
|
|
7200
|
+
}), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), validate("json", telegramTokenSchema), async (c) => {
|
|
7201
|
+
const runtime = runtimeFor(c.req.valid("query").workspace);
|
|
7202
|
+
const id = c.req.valid("param").id;
|
|
6753
7203
|
const { botToken } = c.req.valid("json");
|
|
6754
7204
|
const verified = await runtime.notifications.verifyToken(botToken);
|
|
6755
7205
|
if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
|
|
6756
7206
|
statusCode: 400,
|
|
6757
7207
|
code: "TELEGRAM_TOKEN_REJECTED"
|
|
6758
7208
|
});
|
|
6759
|
-
runtime.secrets.setTelegramToken(botToken);
|
|
7209
|
+
runtime.secrets.setTelegramToken(id, botToken);
|
|
6760
7210
|
forgetBots();
|
|
6761
7211
|
return c.json({
|
|
6762
7212
|
ok: true,
|
|
6763
7213
|
username: verified.username ?? null
|
|
6764
7214
|
});
|
|
6765
|
-
}).delete("/notifications/token", describeRoute({
|
|
7215
|
+
}).delete("/notifications/destinations/:id/token", describeRoute({
|
|
6766
7216
|
tags: ["notifications"],
|
|
6767
|
-
summary: "Forget
|
|
7217
|
+
summary: "Forget a Telegram destination's bot token",
|
|
6768
7218
|
responses: { 200: { description: "Removed" } }
|
|
6769
|
-
}), validate("query", workspaceQuerySchema), (c) => {
|
|
6770
|
-
|
|
7219
|
+
}), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), (c) => {
|
|
7220
|
+
runtimeFor(c.req.valid("query").workspace).secrets.setTelegramToken(c.req.valid("param").id, null);
|
|
6771
7221
|
forgetBots();
|
|
6772
7222
|
return c.json({ ok: true });
|
|
6773
|
-
}).post("/notifications/test", describeRoute({
|
|
7223
|
+
}).post("/notifications/destinations/:id/test", describeRoute({
|
|
6774
7224
|
tags: ["notifications"],
|
|
6775
|
-
summary: "Send a test message",
|
|
7225
|
+
summary: "Send a test message to one destination",
|
|
6776
7226
|
responses: {
|
|
6777
7227
|
200: { description: "Sent or refused" },
|
|
6778
7228
|
400: ERROR_RESPONSES[400]
|
|
6779
7229
|
}
|
|
6780
|
-
}), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
|
|
6781
|
-
const result = await
|
|
7230
|
+
}), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), validate("json", notificationActionSchema), async (c) => {
|
|
7231
|
+
const result = await runtimeFor(c.req.valid("query").workspace).notifications.sendTest(c.req.valid("param").id, c.req.valid("json"));
|
|
6782
7232
|
if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
|
|
6783
7233
|
statusCode: 400,
|
|
6784
7234
|
code: "TELEGRAM_SEND_FAILED"
|
|
6785
7235
|
});
|
|
6786
7236
|
return c.json(result);
|
|
6787
|
-
}).post("/notifications/
|
|
7237
|
+
}).post("/notifications/test-webhook", describeRoute({
|
|
7238
|
+
tags: ["notifications"],
|
|
7239
|
+
summary: "Send a test to one webhook destination",
|
|
7240
|
+
responses: {
|
|
7241
|
+
200: { description: "Sent or refused" },
|
|
7242
|
+
400: ERROR_RESPONSES[400]
|
|
7243
|
+
}
|
|
7244
|
+
}), validate("query", workspaceQuerySchema), validate("json", destinationTestSchema), async (c) => {
|
|
7245
|
+
const runtime = runtimeFor(c.req.valid("query").workspace);
|
|
7246
|
+
const { target, id } = c.req.valid("json");
|
|
7247
|
+
const result = await runtime.notifications.testWebhook(target, id);
|
|
7248
|
+
if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
|
|
7249
|
+
statusCode: 400,
|
|
7250
|
+
code: "WEBHOOK_SEND_FAILED"
|
|
7251
|
+
});
|
|
7252
|
+
return c.json(result);
|
|
7253
|
+
}).post("/notifications/destinations/:id/detect-chats", describeRoute({
|
|
6788
7254
|
tags: ["notifications"],
|
|
6789
|
-
summary: "List the chats
|
|
7255
|
+
summary: "List the chats a Telegram destination's bot can see",
|
|
6790
7256
|
responses: {
|
|
6791
7257
|
200: {
|
|
6792
7258
|
description: "Chats",
|
|
@@ -6794,8 +7260,8 @@ function createNotificationsRoute(deps) {
|
|
|
6794
7260
|
},
|
|
6795
7261
|
400: ERROR_RESPONSES[400]
|
|
6796
7262
|
}
|
|
6797
|
-
}), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
|
|
6798
|
-
const result = await
|
|
7263
|
+
}), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), validate("json", notificationActionSchema), async (c) => {
|
|
7264
|
+
const result = await runtimeFor(c.req.valid("query").workspace).notifications.detectChats(c.req.valid("param").id, c.req.valid("json"));
|
|
6799
7265
|
if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
|
|
6800
7266
|
statusCode: 400,
|
|
6801
7267
|
code: "TELEGRAM_LIST_FAILED"
|
|
@@ -10361,7 +10827,7 @@ function createServersRoute(deps) {
|
|
|
10361
10827
|
404: ERROR_RESPONSES[404]
|
|
10362
10828
|
}
|
|
10363
10829
|
}), 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);
|
|
10830
|
+
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
10831
|
return c.json(result, statusForAction(result));
|
|
10366
10832
|
}).post("/:id/stop", describeRoute({
|
|
10367
10833
|
tags: ["servers"],
|
|
@@ -10381,7 +10847,7 @@ function createServersRoute(deps) {
|
|
|
10381
10847
|
404: ERROR_RESPONSES[404]
|
|
10382
10848
|
}
|
|
10383
10849
|
}), 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);
|
|
10850
|
+
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
10851
|
return c.json(result, statusForAction(result));
|
|
10386
10852
|
}).post("/:id/clear-logs", describeRoute({
|
|
10387
10853
|
tags: ["servers"],
|
|
@@ -10469,6 +10935,7 @@ var init_$_routes$1 = __esmMin((() => {
|
|
|
10469
10935
|
init_openapi$1();
|
|
10470
10936
|
init_validator();
|
|
10471
10937
|
init_workspace();
|
|
10938
|
+
init_auth();
|
|
10472
10939
|
init_events$1();
|
|
10473
10940
|
init_contracts();
|
|
10474
10941
|
idParam$1 = type({ id: "string >= 1" });
|
|
@@ -10496,7 +10963,10 @@ function buildWorkspaceSettings(runtime) {
|
|
|
10496
10963
|
configError: runtime.store.configError,
|
|
10497
10964
|
defaults: runtime.store.defaults,
|
|
10498
10965
|
logs: runtime.store.logs,
|
|
10499
|
-
notifications: {
|
|
10966
|
+
notifications: {
|
|
10967
|
+
defaults: runtime.notifications.defaults(),
|
|
10968
|
+
destinations: runtime.notifications.destinations()
|
|
10969
|
+
}
|
|
10500
10970
|
};
|
|
10501
10971
|
}
|
|
10502
10972
|
function createSettingsRoute(deps) {
|
|
@@ -10688,7 +11158,10 @@ function createSettingsRoute(deps) {
|
|
|
10688
11158
|
try {
|
|
10689
11159
|
if (patch.defaults !== void 0) runtime.store.updateDefaults(patch.defaults);
|
|
10690
11160
|
if (patch.logs !== void 0) runtime.store.updateLogs(patch.logs);
|
|
10691
|
-
if (patch.notifications !== void 0)
|
|
11161
|
+
if (patch.notifications !== void 0) {
|
|
11162
|
+
const saved = runtime.store.updateNotifications(patch.notifications);
|
|
11163
|
+
runtime.secrets.forgetTelegramTokens(saved.destinations.map((destination) => destination.id));
|
|
11164
|
+
}
|
|
10692
11165
|
} catch (error) {
|
|
10693
11166
|
if (error instanceof ConfigError) throw new DetailedError(error.message, {
|
|
10694
11167
|
statusCode: 400,
|
|
@@ -11427,62 +11900,88 @@ var init_acme_challenge = __esmMin((() => {
|
|
|
11427
11900
|
};
|
|
11428
11901
|
}));
|
|
11429
11902
|
//#endregion
|
|
11430
|
-
//#region src/
|
|
11431
|
-
/**
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
|
|
11442
|
-
|
|
11443
|
-
|
|
11903
|
+
//#region src/config/secret-parts.ts
|
|
11904
|
+
/**
|
|
11905
|
+
* The parts of one scope, in a stable order: the known parts first in the fixed order
|
|
11906
|
+
* password, apiToken (global) / telegram, ddns (workspace) — then any other top-level
|
|
11907
|
+
* key present in `present`, sorted, as its own part with `fields: [key]`.
|
|
11908
|
+
*
|
|
11909
|
+
* An unknown key is taken as belonging to the scope being asked about, because the file
|
|
11910
|
+
* it was read from is the only evidence of where it lives: this release does not know the
|
|
11911
|
+
* key, so classifying it by name would drop a future *global* secret on the floor.
|
|
11912
|
+
*/
|
|
11913
|
+
function secretParts(scope, present) {
|
|
11914
|
+
const keys = new Set(present);
|
|
11915
|
+
const known = KNOWN[scope];
|
|
11916
|
+
const parts = known.filter((key) => keys.has(key)).map((key) => ({
|
|
11917
|
+
key,
|
|
11918
|
+
fields: EXTRA_FIELDS[key] ?? [key],
|
|
11919
|
+
scope
|
|
11920
|
+
}));
|
|
11921
|
+
const consumed = new Set(parts.flatMap((part) => part.fields));
|
|
11922
|
+
const rest = [...keys].filter((key) => KNOWN_SCOPES[key] === void 0 && !known.includes(key) && !consumed.has(key)).sort();
|
|
11923
|
+
return [...parts, ...rest.map((key) => ({
|
|
11924
|
+
key,
|
|
11925
|
+
fields: [key],
|
|
11926
|
+
scope
|
|
11927
|
+
}))];
|
|
11928
|
+
}
|
|
11929
|
+
/** The top-level keys of a raw secrets document that hold a non-null value. */
|
|
11930
|
+
function presentSecretKeys(source) {
|
|
11931
|
+
return Object.entries(source).filter(([, value]) => value !== null && value !== void 0).map(([key]) => key);
|
|
11932
|
+
}
|
|
11933
|
+
/**
|
|
11934
|
+
* A JSON string carrying only `fields`, taken from `source`. Returns null when `source`
|
|
11935
|
+
* carries none of them. Always includes a field whose value is `null` ONLY if it is
|
|
11936
|
+
* present as a key and non-null; a null/absent field is omitted.
|
|
11937
|
+
*/
|
|
11938
|
+
function pickSecrets(source, fields) {
|
|
11939
|
+
const picked = {};
|
|
11940
|
+
for (const field of fields) {
|
|
11941
|
+
const value = source[field];
|
|
11942
|
+
if (value !== null && value !== void 0) picked[field] = value;
|
|
11444
11943
|
}
|
|
11445
|
-
return
|
|
11944
|
+
return Object.keys(picked).length === 0 ? null : JSON.stringify(picked);
|
|
11446
11945
|
}
|
|
11447
|
-
/**
|
|
11448
|
-
function
|
|
11946
|
+
/** Parses a secrets document, tolerating anything: not-an-object, or broken JSON returns {}. */
|
|
11947
|
+
function parseSecrets(text) {
|
|
11449
11948
|
try {
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
11453
|
-
|
|
11454
|
-
};
|
|
11455
|
-
} catch (error) {
|
|
11456
|
-
if (error.code === "ENOENT") return {
|
|
11457
|
-
env: {},
|
|
11458
|
-
path: file,
|
|
11459
|
-
error: null
|
|
11460
|
-
};
|
|
11461
|
-
return {
|
|
11462
|
-
env: {},
|
|
11463
|
-
path: file,
|
|
11464
|
-
error: errorMessage(error)
|
|
11465
|
-
};
|
|
11949
|
+
const parsed = JSON.parse(text);
|
|
11950
|
+
return isRecord(parsed) ? parsed : {};
|
|
11951
|
+
} catch {
|
|
11952
|
+
return {};
|
|
11466
11953
|
}
|
|
11467
11954
|
}
|
|
11468
|
-
/**
|
|
11469
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
11472
|
-
|
|
11473
|
-
|
|
11474
|
-
|
|
11475
|
-
function
|
|
11476
|
-
|
|
11477
|
-
|
|
11478
|
-
|
|
11479
|
-
|
|
11480
|
-
|
|
11955
|
+
/**
|
|
11956
|
+
* Merges `piece` into `current`, changing ONLY the fields in `fields` — every other key
|
|
11957
|
+
* of `current` is preserved (this is what stops restoring the Telegram token from wiping
|
|
11958
|
+
* the DDNS credentials). A field that is absent from `piece` is also DELETED from the
|
|
11959
|
+
* result, so restoring an older archive that predates a secret clears it rather than
|
|
11960
|
+
* leaving a stale value. Returns a new object; does not mutate its arguments.
|
|
11961
|
+
*/
|
|
11962
|
+
function mergeSecrets(current, piece, fields) {
|
|
11963
|
+
const merged = { ...current };
|
|
11964
|
+
for (const field of fields) {
|
|
11965
|
+
const value = piece[field];
|
|
11966
|
+
if (value === null || value === void 0) delete merged[field];
|
|
11967
|
+
else merged[field] = value;
|
|
11968
|
+
}
|
|
11969
|
+
return merged;
|
|
11481
11970
|
}
|
|
11482
|
-
var
|
|
11483
|
-
var
|
|
11484
|
-
|
|
11485
|
-
|
|
11971
|
+
var KNOWN, KNOWN_SCOPES, EXTRA_FIELDS;
|
|
11972
|
+
var init_secret_parts = __esmMin((() => {
|
|
11973
|
+
init_shape();
|
|
11974
|
+
KNOWN = {
|
|
11975
|
+
global: ["password", "apiToken"],
|
|
11976
|
+
workspace: ["telegram", "ddns"]
|
|
11977
|
+
};
|
|
11978
|
+
KNOWN_SCOPES = {
|
|
11979
|
+
password: "global",
|
|
11980
|
+
apiToken: "global",
|
|
11981
|
+
telegram: "workspace",
|
|
11982
|
+
ddns: "workspace"
|
|
11983
|
+
};
|
|
11984
|
+
EXTRA_FIELDS = { ddns: ["ddns", "ddnsKdf"] };
|
|
11486
11985
|
}));
|
|
11487
11986
|
//#endregion
|
|
11488
11987
|
//#region src/providers/archive.ts
|
|
@@ -11684,6 +12183,41 @@ function computeBackoff(attempt, options) {
|
|
|
11684
12183
|
}
|
|
11685
12184
|
var init_backoff = __esmMin((() => {}));
|
|
11686
12185
|
//#endregion
|
|
12186
|
+
//#region src/providers/entry-state.ts
|
|
12187
|
+
/**
|
|
12188
|
+
* The small slice of a server's runtime the panel has to remember across its own
|
|
12189
|
+
* restarts: how much of the retry budget is spent, and whether a `runOnce` bootstrap
|
|
12190
|
+
* already ran.
|
|
12191
|
+
*
|
|
12192
|
+
* Both lived only in memory, so a panel restart — a `restart`, a crash, a reboot —
|
|
12193
|
+
* handed a crash-looping server a fresh `maxRetries` and re-ran its bootstrap. The
|
|
12194
|
+
* retry counter has to outlive the process that increments it to bound anything.
|
|
12195
|
+
*/
|
|
12196
|
+
/** One JSON file per entry, beside the nanny's own state. */
|
|
12197
|
+
function entryStatePath(dir, id) {
|
|
12198
|
+
return path.join(dir, `${id}.run.json`);
|
|
12199
|
+
}
|
|
12200
|
+
function readEntryState(file) {
|
|
12201
|
+
try {
|
|
12202
|
+
const parsed = entryStateSchema(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
12203
|
+
return parsed instanceof type.errors ? null : parsed;
|
|
12204
|
+
} catch {
|
|
12205
|
+
return null;
|
|
12206
|
+
}
|
|
12207
|
+
}
|
|
12208
|
+
function writeEntryState(file, state) {
|
|
12209
|
+
try {
|
|
12210
|
+
writeFileAtomic(file, `${JSON.stringify(state)}\n`);
|
|
12211
|
+
} catch {}
|
|
12212
|
+
}
|
|
12213
|
+
function clearEntryState(file) {
|
|
12214
|
+
fs.rmSync(file, { force: true });
|
|
12215
|
+
}
|
|
12216
|
+
var init_entry_state = __esmMin((() => {
|
|
12217
|
+
init_atomic();
|
|
12218
|
+
init_contracts();
|
|
12219
|
+
}));
|
|
12220
|
+
//#endregion
|
|
11687
12221
|
//#region src/providers/health-check.ts
|
|
11688
12222
|
/** TCP connect timing, used for the default `port` mode and readiness. */
|
|
11689
12223
|
async function probeTcp(host, port, timeoutMs) {
|
|
@@ -11696,18 +12230,99 @@ async function probeTcp(host, port, timeoutMs) {
|
|
|
11696
12230
|
};
|
|
11697
12231
|
}
|
|
11698
12232
|
/**
|
|
12233
|
+
* One request/response, capped at `timeoutMs` and `MAX_BODY_BYTES`.
|
|
12234
|
+
*
|
|
12235
|
+
* Node's `fetch` cannot be told to accept a certificate it does not trust, which is the normal
|
|
12236
|
+
* case for a backend on a home network: its own self-signed pair, or one from the engine's local
|
|
12237
|
+
* CA. So this speaks http/https directly, the same way `helpers/daemon.ts` reaches a panel with an
|
|
12238
|
+
* uploaded pair. A `HEAD` gets no body, which the caller already accounts for.
|
|
12239
|
+
*/
|
|
12240
|
+
function request(url, method, allowInvalidCert, timeoutMs) {
|
|
12241
|
+
return new Promise((resolve, reject) => {
|
|
12242
|
+
const secure = url.protocol === "https:";
|
|
12243
|
+
let req;
|
|
12244
|
+
let settled = false;
|
|
12245
|
+
let deadline;
|
|
12246
|
+
const finish = (value) => {
|
|
12247
|
+
if (settled) return;
|
|
12248
|
+
settled = true;
|
|
12249
|
+
clearTimeout(deadline);
|
|
12250
|
+
resolve(value);
|
|
12251
|
+
};
|
|
12252
|
+
const fail = (error) => {
|
|
12253
|
+
if (settled) return;
|
|
12254
|
+
settled = true;
|
|
12255
|
+
clearTimeout(deadline);
|
|
12256
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
12257
|
+
};
|
|
12258
|
+
/**
|
|
12259
|
+
* The whole exchange, not just the socket.
|
|
12260
|
+
*
|
|
12261
|
+
* `timeout` on the request is an **idle** timeout, so a server dripping one byte every half
|
|
12262
|
+
* second keeps resetting it and the probe never returns — which hangs the supervisor's tick,
|
|
12263
|
+
* since the probe is awaited. The old `fetch` path had `AbortSignal.timeout`, an absolute
|
|
12264
|
+
* cap; this restores that guarantee.
|
|
12265
|
+
*/
|
|
12266
|
+
deadline = setTimeout(() => {
|
|
12267
|
+
fail(/* @__PURE__ */ new Error(`no complete response within ${timeoutMs}ms`));
|
|
12268
|
+
req.destroy();
|
|
12269
|
+
}, timeoutMs);
|
|
12270
|
+
deadline.unref();
|
|
12271
|
+
req = (secure ? https : http).request({
|
|
12272
|
+
hostname: url.hostname,
|
|
12273
|
+
port: url.port,
|
|
12274
|
+
path: `${url.pathname}${url.search}`,
|
|
12275
|
+
method,
|
|
12276
|
+
...secure && allowInvalidCert ? { rejectUnauthorized: false } : {},
|
|
12277
|
+
timeout: timeoutMs
|
|
12278
|
+
}, (response) => {
|
|
12279
|
+
let body = "";
|
|
12280
|
+
let bytes = 0;
|
|
12281
|
+
let truncated = false;
|
|
12282
|
+
response.setEncoding("utf8");
|
|
12283
|
+
response.on("data", (chunk) => {
|
|
12284
|
+
if (truncated) return;
|
|
12285
|
+
bytes += Buffer$1.byteLength(chunk);
|
|
12286
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
12287
|
+
truncated = true;
|
|
12288
|
+
body += chunk.slice(0, Math.max(0, MAX_BODY_BYTES - (bytes - Buffer$1.byteLength(chunk))));
|
|
12289
|
+
response.destroy();
|
|
12290
|
+
finish({
|
|
12291
|
+
status: response.statusCode ?? 0,
|
|
12292
|
+
body,
|
|
12293
|
+
truncated
|
|
12294
|
+
});
|
|
12295
|
+
return;
|
|
12296
|
+
}
|
|
12297
|
+
body += chunk;
|
|
12298
|
+
});
|
|
12299
|
+
response.once("end", () => {
|
|
12300
|
+
if (!truncated) finish({
|
|
12301
|
+
status: response.statusCode ?? 0,
|
|
12302
|
+
body,
|
|
12303
|
+
truncated
|
|
12304
|
+
});
|
|
12305
|
+
});
|
|
12306
|
+
response.once("error", (error) => {
|
|
12307
|
+
if (!truncated) fail(error);
|
|
12308
|
+
});
|
|
12309
|
+
});
|
|
12310
|
+
req.once("error", (error) => fail(error));
|
|
12311
|
+
req.once("timeout", () => {
|
|
12312
|
+
fail(/* @__PURE__ */ new Error(`no response within ${timeoutMs}ms`));
|
|
12313
|
+
req.destroy();
|
|
12314
|
+
});
|
|
12315
|
+
req.end();
|
|
12316
|
+
});
|
|
12317
|
+
}
|
|
12318
|
+
/**
|
|
11699
12319
|
* Fetches the configured path and asserts the response, so "listening" is not
|
|
11700
12320
|
* mistaken for "working".
|
|
11701
12321
|
*/
|
|
11702
12322
|
async function probeHttp(host, port, options) {
|
|
11703
|
-
const url = `http://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`;
|
|
11704
12323
|
const started = Date.now();
|
|
11705
12324
|
try {
|
|
11706
|
-
const response = await
|
|
11707
|
-
method: options.method,
|
|
11708
|
-
redirect: "manual",
|
|
11709
|
-
signal: AbortSignal.timeout(options.timeoutMs)
|
|
11710
|
-
});
|
|
12325
|
+
const response = await request(new URL(`${options.scheme}://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`), options.method, options.allowInvalidCert, options.timeoutMs);
|
|
11711
12326
|
const ms = Date.now() - started;
|
|
11712
12327
|
const expected = options.expectStatus ?? null;
|
|
11713
12328
|
if (expected !== null && response.status !== expected) return {
|
|
@@ -11722,11 +12337,14 @@ async function probeHttp(host, port, options) {
|
|
|
11722
12337
|
};
|
|
11723
12338
|
const bodySkipped = options.expectBody.length > 0 && options.method === "HEAD";
|
|
11724
12339
|
if (options.expectBody.length > 0 && !bodySkipped) {
|
|
11725
|
-
if (!
|
|
11726
|
-
|
|
11727
|
-
|
|
11728
|
-
|
|
11729
|
-
|
|
12340
|
+
if (!response.body.includes(options.expectBody)) {
|
|
12341
|
+
const scope = response.truncated ? ` (only the first ${MAX_BODY_BYTES} bytes were read)` : "";
|
|
12342
|
+
return {
|
|
12343
|
+
healthy: false,
|
|
12344
|
+
ms,
|
|
12345
|
+
detail: `body does not contain ${JSON.stringify(options.expectBody)}${scope}`
|
|
12346
|
+
};
|
|
12347
|
+
}
|
|
11730
12348
|
}
|
|
11731
12349
|
if (bodySkipped) return {
|
|
11732
12350
|
healthy: true,
|
|
@@ -11762,9 +12380,11 @@ async function probeHealth(options) {
|
|
|
11762
12380
|
}
|
|
11763
12381
|
return last;
|
|
11764
12382
|
}
|
|
12383
|
+
var MAX_BODY_BYTES;
|
|
11765
12384
|
var init_health_check = __esmMin((() => {
|
|
11766
12385
|
init_port();
|
|
11767
12386
|
init_error_message();
|
|
12387
|
+
MAX_BODY_BYTES = 1e6;
|
|
11768
12388
|
}));
|
|
11769
12389
|
//#endregion
|
|
11770
12390
|
//#region src/providers/process.ts
|
|
@@ -12304,6 +12924,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12304
12924
|
init_paths();
|
|
12305
12925
|
init_runtime();
|
|
12306
12926
|
init_template();
|
|
12927
|
+
init_entry_state();
|
|
12307
12928
|
init_health_check();
|
|
12308
12929
|
init_identity();
|
|
12309
12930
|
init_nanny$2();
|
|
@@ -12371,7 +12992,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12371
12992
|
}
|
|
12372
12993
|
}
|
|
12373
12994
|
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);
|
|
12995
|
+
for (const config of orderByDependencies(targets)) await this.start(config.id, { actor: "boot" });
|
|
12375
12996
|
}
|
|
12376
12997
|
/**
|
|
12377
12998
|
* Stops every entry this command owns. A persistent entry is deliberately not among
|
|
@@ -12402,11 +13023,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
12402
13023
|
error: `server "${id}" is stopping`
|
|
12403
13024
|
};
|
|
12404
13025
|
entry.starting = true;
|
|
13026
|
+
entry.actor = options.retry ? "policy" : options.actor ?? "policy";
|
|
12405
13027
|
this.clearRetry(entry);
|
|
12406
13028
|
if (!options.retry) {
|
|
12407
13029
|
entry.restarts = 0;
|
|
12408
13030
|
entry.healthFailures = 0;
|
|
12409
13031
|
entry.unhealthySince = null;
|
|
13032
|
+
this.persistEntryState(entry);
|
|
12410
13033
|
}
|
|
12411
13034
|
try {
|
|
12412
13035
|
await this.startDependencies(entry);
|
|
@@ -12419,6 +13042,14 @@ var init_supervisor = __esmMin((() => {
|
|
|
12419
13042
|
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
12420
13043
|
this.publishServer(entry);
|
|
12421
13044
|
if (entry.config.persistent && await this.resumePersistent(entry)) return { ok: true };
|
|
13045
|
+
if (!entry.config.persistent) {
|
|
13046
|
+
const recorded = await this.resolveRecordedChild(entry);
|
|
13047
|
+
if (recorded.kind === "blocked") return {
|
|
13048
|
+
ok: false,
|
|
13049
|
+
error: recorded.error
|
|
13050
|
+
};
|
|
13051
|
+
if (recorded.kind === "adopt") return this.adoptEntry(entry, recorded.pid, "the child this panel started before it died is still running");
|
|
13052
|
+
}
|
|
12422
13053
|
await this.runBootstrap(entry);
|
|
12423
13054
|
if (entry.stopping) return {
|
|
12424
13055
|
ok: false,
|
|
@@ -12433,7 +13064,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12433
13064
|
ok: false,
|
|
12434
13065
|
error: conflict.error
|
|
12435
13066
|
};
|
|
12436
|
-
if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid);
|
|
13067
|
+
if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid, `a detached restart of this entry is already serving port ${entry.config.port}`);
|
|
12437
13068
|
return this.spawnEntry(entry);
|
|
12438
13069
|
} finally {
|
|
12439
13070
|
entry.starting = false;
|
|
@@ -12444,9 +13075,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
12444
13075
|
if (!entry) return missingServer(id);
|
|
12445
13076
|
return this.stopEntry(entry);
|
|
12446
13077
|
}
|
|
12447
|
-
async restart(id) {
|
|
13078
|
+
async restart(id, options = {}) {
|
|
12448
13079
|
await this.stop(id);
|
|
12449
|
-
return this.start(id);
|
|
13080
|
+
return this.start(id, options);
|
|
12450
13081
|
}
|
|
12451
13082
|
/**
|
|
12452
13083
|
* Frees the port this entry wants, by asking whatever listens on it to leave.
|
|
@@ -12678,6 +13309,10 @@ var init_supervisor = __esmMin((() => {
|
|
|
12678
13309
|
entry.pid = null;
|
|
12679
13310
|
entry.adopted = false;
|
|
12680
13311
|
entry.nannyExit = null;
|
|
13312
|
+
if (entry.lastChild !== null && !isProcessAlive(entry.lastChild.pid)) {
|
|
13313
|
+
entry.lastChild = null;
|
|
13314
|
+
this.persistEntryState(entry);
|
|
13315
|
+
}
|
|
12681
13316
|
entry.status = survivor === null ? "stopped" : "conflict";
|
|
12682
13317
|
if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
|
|
12683
13318
|
this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
|
|
@@ -12688,6 +13323,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
12688
13323
|
};
|
|
12689
13324
|
}
|
|
12690
13325
|
createEntry(config) {
|
|
13326
|
+
const persisted = readEntryState(entryStatePath(this.options.nannyDir, config.id));
|
|
13327
|
+
const lastPid = persisted?.lastPid ?? null;
|
|
12691
13328
|
return {
|
|
12692
13329
|
config,
|
|
12693
13330
|
status: "stopped",
|
|
@@ -12696,9 +13333,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
12696
13333
|
child: null,
|
|
12697
13334
|
pid: null,
|
|
12698
13335
|
startedAt: null,
|
|
13336
|
+
lastChild: !config.persistent && lastPid !== null ? {
|
|
13337
|
+
pid: lastPid,
|
|
13338
|
+
startedAt: persisted?.lastStartedAt ?? 0
|
|
13339
|
+
} : null,
|
|
12699
13340
|
exitCode: null,
|
|
12700
13341
|
exitSignal: null,
|
|
12701
|
-
restarts: 0,
|
|
13342
|
+
restarts: persisted?.restarts ?? 0,
|
|
12702
13343
|
lastError: null,
|
|
12703
13344
|
nextRetryAt: null,
|
|
12704
13345
|
retryTimer: null,
|
|
@@ -12711,8 +13352,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
12711
13352
|
nannyExit: null,
|
|
12712
13353
|
starting: false,
|
|
12713
13354
|
stopping: false,
|
|
13355
|
+
actor: "policy",
|
|
12714
13356
|
removed: false,
|
|
12715
|
-
bootstrapDone: !config.bootstrap,
|
|
13357
|
+
bootstrapDone: persisted?.bootstrapDone ?? !config.bootstrap,
|
|
12716
13358
|
logs: new LogBuffer(config.logBufferLines),
|
|
12717
13359
|
echoed: /* @__PURE__ */ new Set(),
|
|
12718
13360
|
responseMs: null,
|
|
@@ -12730,6 +13372,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12730
13372
|
this.relay.unfollow(id);
|
|
12731
13373
|
this.options.logFiles.clear(id);
|
|
12732
13374
|
this.options.history.forget(id);
|
|
13375
|
+
clearEntryState(entryStatePath(this.options.nannyDir, id));
|
|
12733
13376
|
entry.removed = true;
|
|
12734
13377
|
this.stopEntry(entry).catch((error) => {
|
|
12735
13378
|
logger.error(`could not stop the removed server ${id}`, error);
|
|
@@ -12853,16 +13496,6 @@ var init_supervisor = __esmMin((() => {
|
|
|
12853
13496
|
return { kind: "free" };
|
|
12854
13497
|
}
|
|
12855
13498
|
/**
|
|
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
13499
|
* Stops the listed port holders and reports whether the port came free.
|
|
12867
13500
|
*
|
|
12868
13501
|
* `kill` and `reclaim` are the same sequence over a different pid list — the foreign
|
|
@@ -12887,7 +13520,21 @@ var init_supervisor = __esmMin((() => {
|
|
|
12887
13520
|
error: entry.lastError
|
|
12888
13521
|
};
|
|
12889
13522
|
}
|
|
12890
|
-
|
|
13523
|
+
/**
|
|
13524
|
+
* Takes over a detached successor: no spawn, no duplicate. The pid is supervised
|
|
13525
|
+
* from here on (liveness, health probe, resources, stop), while its output stays
|
|
13526
|
+
* wherever it was redirected.
|
|
13527
|
+
*
|
|
13528
|
+
* For a persistent entry that output is not a mystery: its nanny is still writing
|
|
13529
|
+
* the entry's log file, so the relay is started here too. Without this, adopting a
|
|
13530
|
+
* persistent entry whose state file was lost would show its history but never
|
|
13531
|
+
* another live line.
|
|
13532
|
+
*
|
|
13533
|
+
* `reason` is the caller's sentence for *why* this pid is ours: a port holder and a
|
|
13534
|
+
* recorded child that survived a hard panel death are the same adoption, said
|
|
13535
|
+
* differently, and the log line is the only place a person sees which happened.
|
|
13536
|
+
*/
|
|
13537
|
+
adoptEntry(entry, pid, reason) {
|
|
12891
13538
|
entry.adopted = true;
|
|
12892
13539
|
entry.pid = pid;
|
|
12893
13540
|
entry.child = null;
|
|
@@ -12896,12 +13543,60 @@ var init_supervisor = __esmMin((() => {
|
|
|
12896
13543
|
entry.startedAt = Date.now();
|
|
12897
13544
|
entry.lastError = null;
|
|
12898
13545
|
if (entry.config.persistent) this.followPersistent(entry, true);
|
|
12899
|
-
this.log(entry, "system", `adopted pid ${pid}:
|
|
13546
|
+
this.log(entry, "system", `adopted pid ${pid}: ${reason}`);
|
|
12900
13547
|
this.publishServer(entry);
|
|
12901
13548
|
return { ok: true };
|
|
12902
13549
|
}
|
|
13550
|
+
/**
|
|
13551
|
+
* The child this entry's state file recorded, when it is still that same process.
|
|
13552
|
+
*
|
|
13553
|
+
* A non-persistent child is spawned detached, so it survives a panel killed hard while
|
|
13554
|
+
* nothing else remembers it — `preflight` cannot help a portless entry, which is how a
|
|
13555
|
+
* portless worker got a second copy beside the running one. A pid is recycled, so
|
|
13556
|
+
* liveness alone proves nothing: the birth time and the marker decide, and a pid this
|
|
13557
|
+
* platform cannot place is never adopted on a guess.
|
|
13558
|
+
*
|
|
13559
|
+
* The record is cleared only once its process is known to be gone or to be somebody
|
|
13560
|
+
* else, so a stale pid is never chased and a live one is never forgotten into a twin.
|
|
13561
|
+
*/
|
|
13562
|
+
async resolveRecordedChild(entry) {
|
|
13563
|
+
const recorded = entry.lastChild;
|
|
13564
|
+
if (recorded === null) return { kind: "none" };
|
|
13565
|
+
if (recorded.pid === process.pid) {
|
|
13566
|
+
entry.lastChild = null;
|
|
13567
|
+
this.persistEntryState(entry);
|
|
13568
|
+
return { kind: "none" };
|
|
13569
|
+
}
|
|
13570
|
+
const liveness = recorded.startedAt > 0 ? await processLiveness(recorded.pid, recorded.startedAt) : isProcessAlive(recorded.pid) ? "unknown" : "gone";
|
|
13571
|
+
if (liveness === "gone" || liveness === "recycled") {
|
|
13572
|
+
entry.lastChild = null;
|
|
13573
|
+
this.persistEntryState(entry);
|
|
13574
|
+
return { kind: "none" };
|
|
13575
|
+
}
|
|
13576
|
+
if (await processCarriesServerId(recorded.pid, entry.config.id)) return {
|
|
13577
|
+
kind: "adopt",
|
|
13578
|
+
pid: recorded.pid
|
|
13579
|
+
};
|
|
13580
|
+
if (entry.config.port !== null) return { kind: "none" };
|
|
13581
|
+
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`;
|
|
13582
|
+
entry.status = "conflict";
|
|
13583
|
+
entry.lastError = error;
|
|
13584
|
+
this.log(entry, "system", error);
|
|
13585
|
+
this.publishServer(entry);
|
|
13586
|
+
return {
|
|
13587
|
+
kind: "blocked",
|
|
13588
|
+
error
|
|
13589
|
+
};
|
|
13590
|
+
}
|
|
13591
|
+
/** Clears the durable record of the last child, once that pid is known to be gone. */
|
|
13592
|
+
forgetRecordedChild(entry, pid) {
|
|
13593
|
+
if (pid === null || entry.lastChild === null || entry.lastChild.pid !== pid) return;
|
|
13594
|
+
entry.lastChild = null;
|
|
13595
|
+
this.persistEntryState(entry);
|
|
13596
|
+
}
|
|
12903
13597
|
/** An adopted successor disappeared: fall back to the normal lifecycle. */
|
|
12904
13598
|
handleAdoptedExit(entry) {
|
|
13599
|
+
const adoptedPid = entry.pid;
|
|
12905
13600
|
if (entry.pid !== null) this.sampler.forget(entry.pid);
|
|
12906
13601
|
if (entry.config.persistent) this.consumeNannyExit(entry);
|
|
12907
13602
|
const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
|
|
@@ -12909,6 +13604,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12909
13604
|
entry.nannyExit = null;
|
|
12910
13605
|
entry.adopted = false;
|
|
12911
13606
|
entry.pid = null;
|
|
13607
|
+
this.forgetRecordedChild(entry, adoptedPid);
|
|
12912
13608
|
entry.resources = null;
|
|
12913
13609
|
entry.responseMs = null;
|
|
12914
13610
|
entry.exitCode = null;
|
|
@@ -12949,6 +13645,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12949
13645
|
} catch (error) {
|
|
12950
13646
|
this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
|
|
12951
13647
|
entry.bootstrapDone = true;
|
|
13648
|
+
this.persistEntryState(entry);
|
|
12952
13649
|
return;
|
|
12953
13650
|
}
|
|
12954
13651
|
child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
|
|
@@ -12971,6 +13668,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
12971
13668
|
});
|
|
12972
13669
|
});
|
|
12973
13670
|
entry.bootstrapDone = true;
|
|
13671
|
+
this.persistEntryState(entry);
|
|
12974
13672
|
if (code === 0) this.log(entry, "system", "bootstrap finished");
|
|
12975
13673
|
else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
|
|
12976
13674
|
}
|
|
@@ -13112,9 +13810,17 @@ var init_supervisor = __esmMin((() => {
|
|
|
13112
13810
|
entry.child = child;
|
|
13113
13811
|
entry.pid = child.pid ?? null;
|
|
13114
13812
|
entry.startedAt = Date.now();
|
|
13813
|
+
if (!entry.config.persistent && child.pid !== void 0) {
|
|
13814
|
+
entry.lastChild = {
|
|
13815
|
+
pid: child.pid,
|
|
13816
|
+
startedAt: entry.startedAt
|
|
13817
|
+
};
|
|
13818
|
+
this.persistEntryState(entry);
|
|
13819
|
+
}
|
|
13115
13820
|
this.options.history.record(entry.config.id, {
|
|
13116
13821
|
type: "start",
|
|
13117
|
-
detail
|
|
13822
|
+
detail,
|
|
13823
|
+
actor: entry.actor
|
|
13118
13824
|
});
|
|
13119
13825
|
entry.exitCode = null;
|
|
13120
13826
|
entry.exitSignal = null;
|
|
@@ -13304,6 +14010,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
13304
14010
|
entry.responseMs = null;
|
|
13305
14011
|
entry.exitCode = code;
|
|
13306
14012
|
entry.exitSignal = signal;
|
|
14013
|
+
this.forgetRecordedChild(entry, child.pid ?? null);
|
|
13307
14014
|
const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
|
|
13308
14015
|
const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
|
|
13309
14016
|
code,
|
|
@@ -13332,10 +14039,12 @@ var init_supervisor = __esmMin((() => {
|
|
|
13332
14039
|
}
|
|
13333
14040
|
const restart = entry.config.restart;
|
|
13334
14041
|
if (ranForMs >= restart.resetAfterMs) entry.restarts = 0;
|
|
14042
|
+
this.persistEntryState(entry);
|
|
13335
14043
|
this.log(entry, "system", neverStarted ? `did not start: ${detail}` : `exited with ${detail} after ${ranFor}`);
|
|
13336
14044
|
entry.lastError = neverStarted ? detail : `exited with ${detail}`;
|
|
13337
14045
|
if (restart.enabled && entry.restarts < restart.maxRetries) {
|
|
13338
14046
|
entry.restarts += 1;
|
|
14047
|
+
this.persistEntryState(entry);
|
|
13339
14048
|
const backoffMs = computeBackoff(entry.restarts, restart);
|
|
13340
14049
|
entry.status = "backoff";
|
|
13341
14050
|
entry.nextRetryAt = Date.now() + backoffMs;
|
|
@@ -13471,6 +14180,16 @@ var init_supervisor = __esmMin((() => {
|
|
|
13471
14180
|
entry.retryTimer = null;
|
|
13472
14181
|
}
|
|
13473
14182
|
}
|
|
14183
|
+
/** The decisions that have to survive this panel, and nothing else. */
|
|
14184
|
+
persistEntryState(entry) {
|
|
14185
|
+
if (entry.removed) return;
|
|
14186
|
+
writeEntryState(entryStatePath(this.options.nannyDir, entry.config.id), {
|
|
14187
|
+
restarts: entry.restarts,
|
|
14188
|
+
bootstrapDone: entry.bootstrapDone,
|
|
14189
|
+
lastPid: entry.config.persistent ? null : entry.lastChild?.pid ?? null,
|
|
14190
|
+
lastStartedAt: entry.config.persistent ? null : entry.lastChild?.startedAt ?? null
|
|
14191
|
+
});
|
|
14192
|
+
}
|
|
13474
14193
|
buildVars(entry) {
|
|
13475
14194
|
return serverTemplateVars(entry.config);
|
|
13476
14195
|
}
|
|
@@ -13749,11 +14468,37 @@ function readText(file) {
|
|
|
13749
14468
|
return null;
|
|
13750
14469
|
}
|
|
13751
14470
|
}
|
|
14471
|
+
/** Whether an archive came from the schedule rather than from a person. */
|
|
14472
|
+
function isAutomatic(file) {
|
|
14473
|
+
return file.name.startsWith(`${AUTO_PREFIX}-`);
|
|
14474
|
+
}
|
|
13752
14475
|
/** The label of one item, for `applied`; items are built before actions run. */
|
|
13753
14476
|
function labelOf(plan, id) {
|
|
13754
14477
|
return plan.items.find((item) => item.id === id)?.label ?? id;
|
|
13755
14478
|
}
|
|
13756
14479
|
/**
|
|
14480
|
+
* Restores only `fields` from an archived secrets document into a live one.
|
|
14481
|
+
*
|
|
14482
|
+
* The archive may predate a secret the live file holds, and the live file may hold one
|
|
14483
|
+
* the archive never carried: merging by field is what makes restoring the Telegram token
|
|
14484
|
+
* leave the DDNS credentials alone, and what clears a secret the archive does not carry
|
|
14485
|
+
* rather than leaving the newer value behind as if it had been restored.
|
|
14486
|
+
*/
|
|
14487
|
+
function mergeIntoFile(target, archived, fields) {
|
|
14488
|
+
const merged = mergeSecrets(parseSecrets(readText(target) ?? ""), parseSecrets(archived), fields);
|
|
14489
|
+
writeFileAtomic(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
|
|
14490
|
+
}
|
|
14491
|
+
/**
|
|
14492
|
+
* What an archive says about where it came from. Both fields are absent on an archive
|
|
14493
|
+
* written before they existed, so a UI shows the row only when there is something to show.
|
|
14494
|
+
*/
|
|
14495
|
+
function manifestInfo(manifest) {
|
|
14496
|
+
return {
|
|
14497
|
+
...manifest.writtenBy === void 0 ? {} : { writtenBy: manifest.writtenBy },
|
|
14498
|
+
...manifest.schema === void 0 ? {} : { schema: manifest.schema }
|
|
14499
|
+
};
|
|
14500
|
+
}
|
|
14501
|
+
/**
|
|
13757
14502
|
* `servers.config.json` is only a readability gate here: the store applies the
|
|
13758
14503
|
* workspace's own defaults on the next load, and an archive from another machine
|
|
13759
14504
|
* need not match this one's default shapes exactly.
|
|
@@ -13774,13 +14519,17 @@ function readableWorkspaceSettings(text) {
|
|
|
13774
14519
|
return false;
|
|
13775
14520
|
}
|
|
13776
14521
|
}
|
|
13777
|
-
var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, BackupService;
|
|
14522
|
+
var MANIFEST, ALLOWED_ROOTS, SUFFIX, AUTO_PREFIX, RELOADABLE, SECRET_LABELS, BackupService;
|
|
13778
14523
|
var init_backups = __esmMin((() => {
|
|
14524
|
+
init_migrations();
|
|
13779
14525
|
init_parse();
|
|
14526
|
+
init_secret_parts();
|
|
13780
14527
|
init_atomic();
|
|
13781
14528
|
init_env_file();
|
|
14529
|
+
init_logger();
|
|
13782
14530
|
init_paths();
|
|
13783
14531
|
init_template();
|
|
14532
|
+
init_version();
|
|
13784
14533
|
init_archive();
|
|
13785
14534
|
init_supervisor();
|
|
13786
14535
|
init_error_message();
|
|
@@ -13793,7 +14542,26 @@ var init_backups = __esmMin((() => {
|
|
|
13793
14542
|
"data"
|
|
13794
14543
|
]);
|
|
13795
14544
|
SUFFIX = ".zip";
|
|
14545
|
+
AUTO_PREFIX = "auto";
|
|
13796
14546
|
RELOADABLE = /^global:settings$|^workspace:[^:]+:(?:settings|servers)$/;
|
|
14547
|
+
SECRET_LABELS = {
|
|
14548
|
+
password: {
|
|
14549
|
+
en: "Password",
|
|
14550
|
+
key: "settings.backup.secretPassword"
|
|
14551
|
+
},
|
|
14552
|
+
apiToken: {
|
|
14553
|
+
en: "API token",
|
|
14554
|
+
key: "settings.backup.secretApiToken"
|
|
14555
|
+
},
|
|
14556
|
+
telegram: {
|
|
14557
|
+
en: "Telegram bot token",
|
|
14558
|
+
key: "settings.backup.secretTelegram"
|
|
14559
|
+
},
|
|
14560
|
+
ddns: {
|
|
14561
|
+
en: "DDNS credentials",
|
|
14562
|
+
key: "settings.backup.secretDdns"
|
|
14563
|
+
}
|
|
14564
|
+
};
|
|
13797
14565
|
BackupService = class {
|
|
13798
14566
|
options;
|
|
13799
14567
|
/**
|
|
@@ -13803,6 +14571,17 @@ var init_backups = __esmMin((() => {
|
|
|
13803
14571
|
*/
|
|
13804
14572
|
flags = /* @__PURE__ */ new Map();
|
|
13805
14573
|
refreshing = null;
|
|
14574
|
+
/** One scheduled pass at a time: a slow archive must not stack up behind itself. */
|
|
14575
|
+
running = false;
|
|
14576
|
+
disposed = false;
|
|
14577
|
+
/**
|
|
14578
|
+
* When this instance last attempted an automatic pass, whatever it wrote.
|
|
14579
|
+
*
|
|
14580
|
+
* The archive's own mtime is the durable record across a restart, but it cannot bound a
|
|
14581
|
+
* failed pass: nothing is written, so every panel tick would retry — once a second, for as
|
|
14582
|
+
* long as the failure lasts. This is the in-memory backstop for that case.
|
|
14583
|
+
*/
|
|
14584
|
+
lastAttemptAt = null;
|
|
13806
14585
|
constructor(options) {
|
|
13807
14586
|
this.options = options;
|
|
13808
14587
|
}
|
|
@@ -13810,6 +14589,9 @@ var init_backups = __esmMin((() => {
|
|
|
13810
14589
|
async warm() {
|
|
13811
14590
|
await this.refresh();
|
|
13812
14591
|
}
|
|
14592
|
+
dispose() {
|
|
14593
|
+
this.disposed = true;
|
|
14594
|
+
}
|
|
13813
14595
|
get directory() {
|
|
13814
14596
|
return this.resolveDir();
|
|
13815
14597
|
}
|
|
@@ -13821,6 +14603,8 @@ var init_backups = __esmMin((() => {
|
|
|
13821
14603
|
enabled: config.enabled,
|
|
13822
14604
|
dir: this.directory,
|
|
13823
14605
|
keep: config.keep,
|
|
14606
|
+
schedule: { ...config.schedule },
|
|
14607
|
+
lastAutomaticAt: this.lastAutomatic(),
|
|
13824
14608
|
includePaths: [...sources.includePaths],
|
|
13825
14609
|
entries: this.entries(sources),
|
|
13826
14610
|
files: this.list()
|
|
@@ -13846,7 +14630,13 @@ var init_backups = __esmMin((() => {
|
|
|
13846
14630
|
const file = path.join(this.resolveDir(), name);
|
|
13847
14631
|
return fs.existsSync(file) ? file : null;
|
|
13848
14632
|
}
|
|
13849
|
-
/**
|
|
14633
|
+
/**
|
|
14634
|
+
* `password` encrypts the archive; it is never stored anywhere.
|
|
14635
|
+
*
|
|
14636
|
+
* `automatic` marks an archive the schedule wrote, which is what keeps the two retention
|
|
14637
|
+
* counts apart: an automatic pass must never prune the backup a person took by hand, and a
|
|
14638
|
+
* manual one must not silently eat the history the schedule is keeping.
|
|
14639
|
+
*/
|
|
13850
14640
|
async create(options = {}) {
|
|
13851
14641
|
if (!this.options.getConfig().enabled) return {
|
|
13852
14642
|
ok: false,
|
|
@@ -13856,11 +14646,21 @@ var init_backups = __esmMin((() => {
|
|
|
13856
14646
|
const wanted = options.include === void 0 ? null : new Set(options.include);
|
|
13857
14647
|
const capturesGlobal = (id) => wanted === null || wanted.has(id);
|
|
13858
14648
|
const capturesWorkspace = (id, suffix) => wanted === null || wanted.has(`workspace:${id}`) || wanted.has(`workspace:${id}:${suffix}`);
|
|
14649
|
+
/**
|
|
14650
|
+
* The secrets of one scope the request asked for; `null` means "every one of them".
|
|
14651
|
+
* `workspaceId` is how a bare `workspace:<id>` selection covers all of its leaves.
|
|
14652
|
+
*/
|
|
14653
|
+
const selectedSecretFields = (entryId, path, scope, workspaceId) => {
|
|
14654
|
+
const parts = secretParts(scope, presentSecretKeys(parseSecrets(readText(path) ?? "")));
|
|
14655
|
+
const coversEntry = wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`);
|
|
14656
|
+
const picked = parts.filter((part) => coversEntry || wanted.has(`${entryId}:${part.key}`) || part.fields.some((field) => wanted.has(`${entryId}:${field}`)));
|
|
14657
|
+
return picked.length === parts.length ? null : picked.flatMap((part) => part.fields);
|
|
14658
|
+
};
|
|
13859
14659
|
const dir = this.resolveDir();
|
|
13860
14660
|
const sources = this.options.getSources();
|
|
13861
14661
|
const staging = path.join(dir, `.staging-${Date.now()}`);
|
|
13862
14662
|
const createdAt = Date.now();
|
|
13863
|
-
const name =
|
|
14663
|
+
const name = `${options.automatic === true ? AUTO_PREFIX : "backup"}-${new Date(createdAt).toISOString().replace(/[:T]/g, "-").replace(/\.\d+Z$/, "")}-${createdAt % 1e3}${SUFFIX}`;
|
|
13864
14664
|
const destination = path.join(dir, name);
|
|
13865
14665
|
try {
|
|
13866
14666
|
fs.mkdirSync(staging, { recursive: true });
|
|
@@ -13868,15 +14668,41 @@ var init_backups = __esmMin((() => {
|
|
|
13868
14668
|
const copy = (relative, source, ignoreGenerated = false) => {
|
|
13869
14669
|
if (this.copyInto(staging, relative, source, ignoreGenerated)) captured++;
|
|
13870
14670
|
};
|
|
14671
|
+
/**
|
|
14672
|
+
* Captures one secrets file, narrowed to the picked secrets. Copying the whole file
|
|
14673
|
+
* would capture the ones that were *not* picked, so a per-secret selection writes a
|
|
14674
|
+
* staged document holding only those — and the restore side merges by field, which
|
|
14675
|
+
* is what keeps a restore from wiping a secret the archive never carried.
|
|
14676
|
+
*/
|
|
14677
|
+
const copySecrets = (relative, source, entryId, scope, workspaceId) => {
|
|
14678
|
+
const text = readText(source);
|
|
14679
|
+
if (text === null) return;
|
|
14680
|
+
const fields = selectedSecretFields(entryId, source, scope, workspaceId);
|
|
14681
|
+
if (fields === null) {
|
|
14682
|
+
copy(relative, source);
|
|
14683
|
+
return;
|
|
14684
|
+
}
|
|
14685
|
+
const chosen = pickSecrets(parseSecrets(text), fields);
|
|
14686
|
+
if (chosen === null) return;
|
|
14687
|
+
const target = path.join(staging, relative);
|
|
14688
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
14689
|
+
fs.writeFileSync(target, `${chosen}\n`);
|
|
14690
|
+
captured++;
|
|
14691
|
+
};
|
|
14692
|
+
/**
|
|
14693
|
+
* True when the request touches a secrets entry at all: the entry itself, one of its
|
|
14694
|
+
* leaves, or — for a workspace — the workspace id, which names every leaf it has.
|
|
14695
|
+
*/
|
|
14696
|
+
const capturesSecrets = (entryId, workspaceId) => wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`) || [...wanted].some((id) => id.startsWith(`${entryId}:`));
|
|
13871
14697
|
if (capturesGlobal("global:settings")) copy("global/settings.json", sources.globalSettingsPath);
|
|
13872
|
-
if (
|
|
14698
|
+
if (capturesSecrets("global:secrets")) copySecrets("global/secrets.json", sources.globalSecretsPath, "global:secrets", "global");
|
|
13873
14699
|
if (capturesGlobal("global:tls")) copy("global/tls", sources.tlsDir);
|
|
13874
14700
|
const data = [];
|
|
13875
14701
|
for (const workspace of sources.workspaces) {
|
|
13876
14702
|
const root = `workspaces/${workspace.id}`;
|
|
13877
14703
|
if (capturesWorkspace(workspace.id, "settings")) copy(`${root}/settings.json`, workspace.settingsPath);
|
|
13878
14704
|
if (capturesWorkspace(workspace.id, "servers")) copy(`${root}/servers.config.json`, workspace.serversPath);
|
|
13879
|
-
if (
|
|
14705
|
+
if (capturesSecrets(`workspace:${workspace.id}:secrets`, workspace.id)) copySecrets(`${root}/secrets.json`, workspace.secretsPath, `workspace:${workspace.id}:secrets`, "workspace", workspace.id);
|
|
13880
14706
|
for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
|
|
13881
14707
|
if (!declared.included) continue;
|
|
13882
14708
|
if (!capturesWorkspace(workspace.id, `data:${declared.path}`)) continue;
|
|
@@ -13907,6 +14733,8 @@ var init_backups = __esmMin((() => {
|
|
|
13907
14733
|
version: 1,
|
|
13908
14734
|
createdAt,
|
|
13909
14735
|
hostname: os.hostname(),
|
|
14736
|
+
writtenBy: appVersion(),
|
|
14737
|
+
schema: 2,
|
|
13910
14738
|
data
|
|
13911
14739
|
};
|
|
13912
14740
|
fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -14021,6 +14849,11 @@ var init_backups = __esmMin((() => {
|
|
|
14021
14849
|
error: "the archive has no manifest"
|
|
14022
14850
|
};
|
|
14023
14851
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
14852
|
+
plan.archive = manifestInfo(manifest);
|
|
14853
|
+
if (manifest.schema !== void 0 && manifest.schema > 2) return {
|
|
14854
|
+
...plan,
|
|
14855
|
+
error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 2`
|
|
14856
|
+
};
|
|
14024
14857
|
const sources = this.options.getSources();
|
|
14025
14858
|
const selectedIds = options.include === void 0 ? null : new Set(options.include);
|
|
14026
14859
|
const actions = /* @__PURE__ */ new Map();
|
|
@@ -14077,16 +14910,32 @@ var init_backups = __esmMin((() => {
|
|
|
14077
14910
|
writeFileAtomic(sources.globalSettingsPath, settingsText);
|
|
14078
14911
|
}, ["config"]);
|
|
14079
14912
|
const globalSecrets = readText(path.join(staging, "global", "secrets.json"));
|
|
14080
|
-
if (globalSecrets !== null)
|
|
14081
|
-
|
|
14082
|
-
|
|
14083
|
-
|
|
14084
|
-
|
|
14085
|
-
|
|
14086
|
-
|
|
14087
|
-
|
|
14088
|
-
|
|
14089
|
-
|
|
14913
|
+
if (globalSecrets !== null) {
|
|
14914
|
+
addItem({
|
|
14915
|
+
id: "global:secrets",
|
|
14916
|
+
label: "Global secrets (password, API token)",
|
|
14917
|
+
labelKey: "settings.backup.entryGlobalSecrets",
|
|
14918
|
+
kind: "secrets",
|
|
14919
|
+
restorable: true,
|
|
14920
|
+
note: null
|
|
14921
|
+
}, () => {
|
|
14922
|
+
writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
|
|
14923
|
+
}, ["secrets"]);
|
|
14924
|
+
for (const part of secretParts("global", presentSecretKeys(parseSecrets(globalSecrets)))) addItem({
|
|
14925
|
+
id: `global:secrets:${part.key}`,
|
|
14926
|
+
label: `Global secrets: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
|
|
14927
|
+
labelKey: "settings.backup.restoreItemLabel",
|
|
14928
|
+
labelParams: {
|
|
14929
|
+
workspace: "global",
|
|
14930
|
+
item: part.key
|
|
14931
|
+
},
|
|
14932
|
+
kind: "secrets",
|
|
14933
|
+
restorable: true,
|
|
14934
|
+
note: null
|
|
14935
|
+
}, () => {
|
|
14936
|
+
mergeIntoFile(sources.globalSecretsPath, globalSecrets, part.fields);
|
|
14937
|
+
});
|
|
14938
|
+
}
|
|
14090
14939
|
const tlsInArchive = path.join(staging, "global", "tls");
|
|
14091
14940
|
if (fs.existsSync(tlsInArchive)) {
|
|
14092
14941
|
const files = fs.readdirSync(tlsInArchive).filter((file) => fs.statSync(path.join(tlsInArchive, file)).isFile());
|
|
@@ -14214,21 +15063,38 @@ var init_backups = __esmMin((() => {
|
|
|
14214
15063
|
});
|
|
14215
15064
|
}
|
|
14216
15065
|
}
|
|
14217
|
-
if (secrets !== null)
|
|
14218
|
-
|
|
14219
|
-
|
|
14220
|
-
|
|
14221
|
-
|
|
14222
|
-
|
|
14223
|
-
|
|
14224
|
-
|
|
14225
|
-
|
|
14226
|
-
|
|
14227
|
-
|
|
14228
|
-
|
|
14229
|
-
|
|
14230
|
-
|
|
14231
|
-
|
|
15066
|
+
if (secrets !== null) {
|
|
15067
|
+
addItem({
|
|
15068
|
+
id: `workspace:${id}:secrets`,
|
|
15069
|
+
label: `${workspace.label}: Secrets`,
|
|
15070
|
+
labelKey: "settings.backup.restoreItemLabel",
|
|
15071
|
+
labelParams: {
|
|
15072
|
+
workspace: workspace.label,
|
|
15073
|
+
item: "secrets"
|
|
15074
|
+
},
|
|
15075
|
+
kind: "secrets",
|
|
15076
|
+
workspaceId: id,
|
|
15077
|
+
restorable: true,
|
|
15078
|
+
note: null
|
|
15079
|
+
}, () => {
|
|
15080
|
+
writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
|
|
15081
|
+
});
|
|
15082
|
+
for (const part of secretParts("workspace", presentSecretKeys(parseSecrets(secrets)))) addItem({
|
|
15083
|
+
id: `workspace:${id}:secrets:${part.key}`,
|
|
15084
|
+
label: `${workspace.label}: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
|
|
15085
|
+
labelKey: "settings.backup.restoreItemLabel",
|
|
15086
|
+
labelParams: {
|
|
15087
|
+
workspace: workspace.label,
|
|
15088
|
+
item: part.key
|
|
15089
|
+
},
|
|
15090
|
+
kind: "secrets",
|
|
15091
|
+
workspaceId: id,
|
|
15092
|
+
restorable: true,
|
|
15093
|
+
note: null
|
|
15094
|
+
}, () => {
|
|
15095
|
+
mergeIntoFile(workspace.secretsPath, secrets, part.fields);
|
|
15096
|
+
});
|
|
15097
|
+
}
|
|
14232
15098
|
}
|
|
14233
15099
|
const instanceTargets = [];
|
|
14234
15100
|
for (const workspace of sources.workspaces) for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
|
|
@@ -14381,7 +15247,7 @@ var init_backups = __esmMin((() => {
|
|
|
14381
15247
|
labelKey: "settings.backup.entryGlobalSecrets",
|
|
14382
15248
|
kind: "secrets",
|
|
14383
15249
|
...missing(sources.globalSecretsPath, "settings.backup.noteMissing"),
|
|
14384
|
-
items:
|
|
15250
|
+
items: this.secretItems("global", "global:secrets", sources.globalSecretsPath)
|
|
14385
15251
|
},
|
|
14386
15252
|
{
|
|
14387
15253
|
id: "global:tls",
|
|
@@ -14402,6 +15268,27 @@ var init_backups = __esmMin((() => {
|
|
|
14402
15268
|
});
|
|
14403
15269
|
return entries;
|
|
14404
15270
|
}
|
|
15271
|
+
/**
|
|
15272
|
+
* One leaf per secret the file actually holds, so a restore can bring back the
|
|
15273
|
+
* password without the API token, or the Telegram token without the DDNS credentials.
|
|
15274
|
+
*
|
|
15275
|
+
* The set comes from the file's own top-level keys rather than a list here, so a
|
|
15276
|
+
* secret added later appears on its own — the failure mode of a second list is a
|
|
15277
|
+
* secret that can be captured but never restored individually.
|
|
15278
|
+
*/
|
|
15279
|
+
secretItems(scope, entryId, path) {
|
|
15280
|
+
const present = presentSecretKeys(parseSecrets(readText(path) ?? ""));
|
|
15281
|
+
if (present.length === 0) return [];
|
|
15282
|
+
return secretParts(scope, present).map((part) => ({
|
|
15283
|
+
id: `${entryId}:${part.key}`,
|
|
15284
|
+
label: SECRET_LABELS[part.key]?.en ?? part.key,
|
|
15285
|
+
...SECRET_LABELS[part.key] === void 0 ? {} : { labelKey: SECRET_LABELS[part.key].key },
|
|
15286
|
+
kind: "secrets",
|
|
15287
|
+
secret: part.key,
|
|
15288
|
+
included: true,
|
|
15289
|
+
note: null
|
|
15290
|
+
}));
|
|
15291
|
+
}
|
|
14405
15292
|
workspaceItems(workspace, includePaths) {
|
|
14406
15293
|
const id = workspace.id;
|
|
14407
15294
|
const note = (path) => {
|
|
@@ -14435,7 +15322,8 @@ var init_backups = __esmMin((() => {
|
|
|
14435
15322
|
kind: "secrets",
|
|
14436
15323
|
included: true,
|
|
14437
15324
|
...note(workspace.secretsPath)
|
|
14438
|
-
}
|
|
15325
|
+
},
|
|
15326
|
+
...this.secretItems("workspace", `workspace:${id}:secrets`, workspace.secretsPath)
|
|
14439
15327
|
];
|
|
14440
15328
|
for (const declared of this.declaredData(workspace.servers, includePaths)) items.push({
|
|
14441
15329
|
id: `workspace:${id}:data:${declared.path}`,
|
|
@@ -14550,9 +15438,46 @@ var init_backups = __esmMin((() => {
|
|
|
14550
15438
|
});
|
|
14551
15439
|
return true;
|
|
14552
15440
|
}
|
|
15441
|
+
/**
|
|
15442
|
+
* Drops the oldest archives past their own limit.
|
|
15443
|
+
*
|
|
15444
|
+
* Two budgets, because the two sources are not the same thing: `keep` is what a person
|
|
15445
|
+
* agreed to store, and pruning a hand-taken archive to make room for a scheduled one would
|
|
15446
|
+
* lose the copy they deliberately made. The scheduled pair keeps `schedule.keep` automatic
|
|
15447
|
+
* archives; the rest keeps `keep`.
|
|
15448
|
+
*/
|
|
14553
15449
|
prune() {
|
|
14554
|
-
const
|
|
14555
|
-
|
|
15450
|
+
const config = this.options.getConfig();
|
|
15451
|
+
const files = this.list();
|
|
15452
|
+
const automatic = files.filter(isAutomatic);
|
|
15453
|
+
const manual = files.filter((file) => !isAutomatic(file));
|
|
15454
|
+
for (const file of automatic.slice(config.schedule.keep)) this.remove(file.name);
|
|
15455
|
+
for (const file of manual.slice(config.keep)) this.remove(file.name);
|
|
15456
|
+
}
|
|
15457
|
+
/** When the newest automatic archive was written, or null when there is none. */
|
|
15458
|
+
lastAutomatic() {
|
|
15459
|
+
return this.list().find(isAutomatic)?.createdAt ?? null;
|
|
15460
|
+
}
|
|
15461
|
+
/**
|
|
15462
|
+
* Runs one scheduled pass when it is due. Modelled on the DDNS pass: cheap to call on every
|
|
15463
|
+
* panel tick, never blocking, and never two at once.
|
|
15464
|
+
*/
|
|
15465
|
+
tick(now = Date.now()) {
|
|
15466
|
+
if (this.disposed || this.running) return;
|
|
15467
|
+
const config = this.options.getConfig();
|
|
15468
|
+
if (!config.enabled || config.schedule.intervalMs <= 0) return;
|
|
15469
|
+
const last = this.lastAutomatic();
|
|
15470
|
+
const latest = last === null ? this.lastAttemptAt : Math.max(last, this.lastAttemptAt ?? 0);
|
|
15471
|
+
if (latest !== null && now - latest < config.schedule.intervalMs) return;
|
|
15472
|
+
this.running = true;
|
|
15473
|
+
this.lastAttemptAt = now;
|
|
15474
|
+
this.create({ automatic: true }).then((result) => {
|
|
15475
|
+
if (!result.ok) logger.warn("the scheduled backup failed", result.error);
|
|
15476
|
+
}).catch((error) => {
|
|
15477
|
+
logger.error("the scheduled backup failed", error);
|
|
15478
|
+
}).finally(() => {
|
|
15479
|
+
this.running = false;
|
|
15480
|
+
});
|
|
14556
15481
|
}
|
|
14557
15482
|
resolveDir() {
|
|
14558
15483
|
const configured = this.options.getConfig().dir;
|
|
@@ -15800,10 +16725,11 @@ var init_log_files = __esmMin((() => {
|
|
|
15800
16725
|
}));
|
|
15801
16726
|
//#endregion
|
|
15802
16727
|
//#region src/services/notifications.ts
|
|
15803
|
-
var REASON_LABEL, TITLE, NotificationService;
|
|
16728
|
+
var REASON_LABEL, TITLE, COVERS, NotificationService;
|
|
15804
16729
|
var init_notifications = __esmMin((() => {
|
|
15805
16730
|
init_logger();
|
|
15806
16731
|
init_telegram();
|
|
16732
|
+
init_webhook();
|
|
15807
16733
|
init_error_message();
|
|
15808
16734
|
REASON_LABEL = {
|
|
15809
16735
|
"crash": "gave up restarting",
|
|
@@ -15827,122 +16753,230 @@ var init_notifications = __esmMin((() => {
|
|
|
15827
16753
|
"ddns": "🌐 DNS updated",
|
|
15828
16754
|
"ddns-error": "🌐 DNS update failed"
|
|
15829
16755
|
};
|
|
16756
|
+
COVERS = {
|
|
16757
|
+
"crash": (policy) => policy.onCrash,
|
|
16758
|
+
"rss": (policy) => policy.onCrash,
|
|
16759
|
+
"unhealthy": (policy) => policy.onUnhealthy,
|
|
16760
|
+
"forced-restart": (policy) => policy.onForcedRestart,
|
|
16761
|
+
"recovered": (policy) => policy.onRecovered,
|
|
16762
|
+
"host": (policy) => policy.onHost,
|
|
16763
|
+
"host-recovered": (policy) => policy.onHost,
|
|
16764
|
+
"ddns": (policy) => policy.onDdns,
|
|
16765
|
+
"ddns-error": (policy) => policy.onDdns
|
|
16766
|
+
};
|
|
15830
16767
|
NotificationService = class {
|
|
15831
16768
|
secrets;
|
|
15832
16769
|
getConfig;
|
|
15833
16770
|
cooldowns = /* @__PURE__ */ new Map();
|
|
15834
|
-
|
|
15835
|
-
lastResultAt = null;
|
|
16771
|
+
results = /* @__PURE__ */ new Map();
|
|
15836
16772
|
constructor(secrets, getConfig) {
|
|
15837
16773
|
this.secrets = secrets;
|
|
15838
16774
|
this.getConfig = getConfig;
|
|
15839
16775
|
}
|
|
15840
|
-
|
|
15841
|
-
|
|
16776
|
+
/** The general block, as the page edits it. */
|
|
16777
|
+
defaults() {
|
|
16778
|
+
return { ...this.getConfig().defaults };
|
|
15842
16779
|
}
|
|
15843
|
-
|
|
15844
|
-
|
|
15845
|
-
|
|
15846
|
-
|
|
15847
|
-
|
|
15848
|
-
|
|
15849
|
-
|
|
15850
|
-
|
|
15851
|
-
|
|
15852
|
-
|
|
15853
|
-
|
|
15854
|
-
|
|
15855
|
-
|
|
15856
|
-
|
|
15857
|
-
|
|
16780
|
+
/**
|
|
16781
|
+
* Every destination as a row: its own fields, the policy it will actually use, and its last result.
|
|
16782
|
+
*
|
|
16783
|
+
* The page shows this rather than the raw config, so an inheriting destination displays the
|
|
16784
|
+
* inherited toggles — the values a send will use, not the stale ones on disk.
|
|
16785
|
+
*/
|
|
16786
|
+
destinations() {
|
|
16787
|
+
return this.getConfig().destinations.map((target) => {
|
|
16788
|
+
const result = this.results.get(target.id) ?? {
|
|
16789
|
+
lastResult: null,
|
|
16790
|
+
lastResultAt: null
|
|
16791
|
+
};
|
|
16792
|
+
const common = {
|
|
16793
|
+
id: target.id,
|
|
16794
|
+
kind: target.kind,
|
|
16795
|
+
enabled: target.enabled,
|
|
16796
|
+
useOwnSettings: target.useOwnSettings,
|
|
16797
|
+
...this.effectivePolicy(target),
|
|
16798
|
+
lastResult: result.lastResult,
|
|
16799
|
+
lastResultAt: result.lastResultAt
|
|
16800
|
+
};
|
|
16801
|
+
if (target.kind === "telegram") return {
|
|
16802
|
+
...common,
|
|
16803
|
+
chatId: target.chatId,
|
|
16804
|
+
tokenSet: this.secrets.telegramTokenSet(target.id),
|
|
16805
|
+
url: null,
|
|
16806
|
+
method: null,
|
|
16807
|
+
format: null,
|
|
16808
|
+
headers: null
|
|
16809
|
+
};
|
|
16810
|
+
return {
|
|
16811
|
+
...common,
|
|
16812
|
+
chatId: null,
|
|
16813
|
+
tokenSet: false,
|
|
16814
|
+
url: target.url,
|
|
16815
|
+
method: target.method,
|
|
16816
|
+
format: target.format,
|
|
16817
|
+
headers: target.headers
|
|
16818
|
+
};
|
|
16819
|
+
});
|
|
16820
|
+
}
|
|
16821
|
+
/** The policy a destination actually uses: its own, or the general block's. */
|
|
16822
|
+
effectivePolicy(target) {
|
|
16823
|
+
if (target.useOwnSettings) return {
|
|
16824
|
+
onCrash: target.onCrash,
|
|
16825
|
+
onUnhealthy: target.onUnhealthy,
|
|
16826
|
+
onForcedRestart: target.onForcedRestart,
|
|
16827
|
+
onRecovered: target.onRecovered,
|
|
16828
|
+
onHost: target.onHost,
|
|
16829
|
+
onDdns: target.onDdns,
|
|
16830
|
+
cooldownMs: target.cooldownMs
|
|
15858
16831
|
};
|
|
16832
|
+
return { ...this.getConfig().defaults };
|
|
15859
16833
|
}
|
|
15860
|
-
/**
|
|
16834
|
+
/**
|
|
16835
|
+
* The destinations that would deliver this event right now.
|
|
16836
|
+
*
|
|
16837
|
+
* Per destination rather than one boolean for the service: a single answer would either send a
|
|
16838
|
+
* host warning somewhere switched off for it, or silence a crash because another destination's
|
|
16839
|
+
* cooldown was still running.
|
|
16840
|
+
*/
|
|
16841
|
+
targetsFor(event, now = Date.now()) {
|
|
16842
|
+
return this.getConfig().destinations.filter((target) => {
|
|
16843
|
+
if (!target.enabled) return false;
|
|
16844
|
+
const policy = this.effectivePolicy(target);
|
|
16845
|
+
if (!COVERS[event.reason](policy)) return false;
|
|
16846
|
+
const until = this.cooldowns.get(this.cooldownKey(target, event)) ?? 0;
|
|
16847
|
+
return !(policy.cooldownMs > 0 && until > now);
|
|
16848
|
+
});
|
|
16849
|
+
}
|
|
16850
|
+
/** Whether *any* destination would deliver this event. */
|
|
15861
16851
|
shouldNotify(event, now = Date.now()) {
|
|
15862
|
-
|
|
15863
|
-
|
|
15864
|
-
|
|
15865
|
-
"crash": telegram.onCrash,
|
|
15866
|
-
"unhealthy": telegram.onUnhealthy,
|
|
15867
|
-
"forced-restart": telegram.onForcedRestart,
|
|
15868
|
-
"recovered": telegram.onRecovered,
|
|
15869
|
-
"rss": telegram.onCrash,
|
|
15870
|
-
"host": telegram.onHost,
|
|
15871
|
-
"host-recovered": telegram.onHost,
|
|
15872
|
-
"ddns": telegram.onDdns,
|
|
15873
|
-
"ddns-error": telegram.onDdns
|
|
15874
|
-
}[event.reason]) return false;
|
|
15875
|
-
const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
|
|
15876
|
-
return !(telegram.cooldownMs > 0 && until > now);
|
|
15877
|
-
}
|
|
15878
|
-
/** Starts the cooldown window for this event, so a flapping server stays quiet. */
|
|
16852
|
+
return this.targetsFor(event, now).length > 0;
|
|
16853
|
+
}
|
|
16854
|
+
/** Starts the cooldown window for every destination about to be asked to send. */
|
|
15879
16855
|
markSent(event, now = Date.now()) {
|
|
15880
|
-
this.cooldowns.set(
|
|
16856
|
+
for (const target of this.targetsFor(event, now)) this.cooldowns.set(this.cooldownKey(target, event), now + this.effectivePolicy(target).cooldownMs);
|
|
15881
16857
|
}
|
|
15882
|
-
/** Fire-and-forget by design: supervision must never wait on a chat API. */
|
|
16858
|
+
/** Fire-and-forget by design: supervision must never wait on a chat or webhook API. */
|
|
15883
16859
|
notify(event) {
|
|
15884
16860
|
this.dispatch(event).catch((error) => {
|
|
15885
16861
|
logger.warn(`notification failed: ${errorMessage(error)}`);
|
|
15886
16862
|
});
|
|
15887
16863
|
}
|
|
15888
16864
|
async dispatch(event) {
|
|
15889
|
-
|
|
16865
|
+
const targets = this.targetsFor(event);
|
|
16866
|
+
if (targets.length === 0) return false;
|
|
15890
16867
|
this.markSent(event);
|
|
15891
|
-
|
|
16868
|
+
const title = TITLE[event.reason];
|
|
16869
|
+
const message = `${event.label} (${event.serverId}) ${REASON_LABEL[event.reason]}\n${event.detail}`;
|
|
16870
|
+
return (await Promise.all(targets.map((target) => this.deliver(target, title, message, event)))).includes(true);
|
|
15892
16871
|
}
|
|
15893
|
-
/**
|
|
15894
|
-
async
|
|
15895
|
-
|
|
15896
|
-
|
|
16872
|
+
/** One destination, one event. A throw is recorded against it and nothing else. */
|
|
16873
|
+
async deliver(target, title, message, event) {
|
|
16874
|
+
try {
|
|
16875
|
+
const result = target.kind === "telegram" ? await this.sendTelegram(target, title, message) : await sendWebhook(target, {
|
|
16876
|
+
title,
|
|
16877
|
+
message,
|
|
16878
|
+
reason: event.reason,
|
|
16879
|
+
serverId: event.serverId,
|
|
16880
|
+
label: event.label
|
|
16881
|
+
});
|
|
16882
|
+
this.remember(target.id, result.ok ? "sent" : result.error ?? "send failed");
|
|
16883
|
+
return result.ok;
|
|
16884
|
+
} catch (error) {
|
|
16885
|
+
this.remember(target.id, errorMessage(error));
|
|
16886
|
+
return false;
|
|
16887
|
+
}
|
|
16888
|
+
}
|
|
16889
|
+
/** A probe for one destination. */
|
|
16890
|
+
async sendTest(id, overrides = {}) {
|
|
16891
|
+
const target = this.getConfig().destinations.find((entry) => entry.id === id);
|
|
16892
|
+
if (target === void 0) return {
|
|
16893
|
+
ok: false,
|
|
16894
|
+
error: "no such destination"
|
|
16895
|
+
};
|
|
16896
|
+
const result = target.kind === "telegram" ? await this.testTelegram(id, target.chatId, overrides.chatId, overrides.botToken) : await this.testWebhook(target, id);
|
|
16897
|
+
this.remember(id, result.ok ? "test message sent" : result.error ?? "test failed");
|
|
16898
|
+
return result;
|
|
16899
|
+
}
|
|
16900
|
+
/**
|
|
16901
|
+
* A Telegram destination's own token, with the chat id and the token both overridable.
|
|
16902
|
+
*
|
|
16903
|
+
* The override is what lets a token be tried before it is saved — the route advertises it and
|
|
16904
|
+
* `detect-chats` honours it, so ignoring it here made "send a test" fail on a token the page was
|
|
16905
|
+
* still holding.
|
|
16906
|
+
*/
|
|
16907
|
+
async testTelegram(id, chatId, overrideChatId, overrideToken) {
|
|
16908
|
+
const chat = (overrideChatId ?? chatId).trim();
|
|
16909
|
+
if (chat.length === 0) return {
|
|
15897
16910
|
ok: false,
|
|
15898
16911
|
error: "no chat id configured"
|
|
15899
16912
|
};
|
|
15900
|
-
const token = this.
|
|
16913
|
+
const token = overrideToken !== void 0 && overrideToken.length > 0 ? overrideToken : this.secrets.telegramToken(id);
|
|
15901
16914
|
if (token === null) return {
|
|
15902
16915
|
ok: false,
|
|
15903
|
-
error: "no bot token configured"
|
|
16916
|
+
error: "no bot token configured for this destination"
|
|
15904
16917
|
};
|
|
15905
|
-
|
|
15906
|
-
|
|
16918
|
+
return sendTelegramMessage(token, chat, formatTelegramMessage("✅ home-hosted test", ["notifications are wired up correctly"]));
|
|
16919
|
+
}
|
|
16920
|
+
/**
|
|
16921
|
+
* A webhook probe, for a saved destination or for a draft the page has not saved.
|
|
16922
|
+
*
|
|
16923
|
+
* A draft is passed whole, so a URL and its headers can be tried before they are stored — and so
|
|
16924
|
+
* a destination's own "test" tests *that* destination, not whichever one comes first.
|
|
16925
|
+
*/
|
|
16926
|
+
async testWebhook(target, id) {
|
|
16927
|
+
const problem = targetProblem(target, { requireUrl: true });
|
|
16928
|
+
if (problem !== null) return {
|
|
16929
|
+
ok: false,
|
|
16930
|
+
error: problem
|
|
16931
|
+
};
|
|
16932
|
+
const result = await sendWebhook(target, {
|
|
16933
|
+
title: "home-hosted test",
|
|
16934
|
+
message: "notifications are wired up correctly",
|
|
16935
|
+
reason: "test",
|
|
16936
|
+
serverId: "",
|
|
16937
|
+
label: ""
|
|
16938
|
+
});
|
|
16939
|
+
if (id !== void 0) this.remember(id, result.ok ? "test message sent" : result.error ?? "test failed");
|
|
15907
16940
|
return result;
|
|
15908
16941
|
}
|
|
15909
|
-
|
|
15910
|
-
|
|
15911
|
-
|
|
16942
|
+
/** Lists the chats a destination's bot can see, so its chat id can be picked. */
|
|
16943
|
+
async detectChats(id, overrides = {}) {
|
|
16944
|
+
const token = (overrides.botToken ?? this.secrets.telegramToken(id) ?? "").trim();
|
|
16945
|
+
if (token.length === 0) return {
|
|
15912
16946
|
ok: false,
|
|
15913
16947
|
chats: [],
|
|
15914
|
-
error: "no bot token configured"
|
|
16948
|
+
error: "no bot token configured for this destination"
|
|
15915
16949
|
};
|
|
15916
16950
|
const result = await listTelegramChats(token);
|
|
15917
|
-
this.remember(result.ok ? `${result.chats.length} chat(s) found` : result.error ?? "detect failed");
|
|
16951
|
+
this.remember(id, result.ok ? `${result.chats.length} chat(s) found` : result.error ?? "detect failed");
|
|
15918
16952
|
return result;
|
|
15919
16953
|
}
|
|
15920
|
-
/** Verifies a token without sending anything. */
|
|
16954
|
+
/** Verifies a token without storing or sending anything. */
|
|
15921
16955
|
async verifyToken(token) {
|
|
15922
16956
|
return verifyTelegramToken(token);
|
|
15923
16957
|
}
|
|
15924
|
-
|
|
15925
|
-
|
|
15926
|
-
return token.length > 0 ? token : null;
|
|
16958
|
+
cooldownKey(target, event) {
|
|
16959
|
+
return `${target.id}:${event.serverId}:${event.reason}`;
|
|
15927
16960
|
}
|
|
15928
|
-
async sendTelegram(
|
|
15929
|
-
|
|
15930
|
-
|
|
15931
|
-
|
|
15932
|
-
|
|
15933
|
-
|
|
15934
|
-
|
|
15935
|
-
|
|
15936
|
-
|
|
15937
|
-
|
|
15938
|
-
|
|
15939
|
-
|
|
15940
|
-
|
|
15941
|
-
|
|
15942
|
-
|
|
15943
|
-
|
|
15944
|
-
|
|
15945
|
-
|
|
16961
|
+
async sendTelegram(target, title, message) {
|
|
16962
|
+
if (target.chatId.length === 0) return {
|
|
16963
|
+
ok: false,
|
|
16964
|
+
error: "no chat id configured"
|
|
16965
|
+
};
|
|
16966
|
+
const token = this.secrets.telegramToken(target.id);
|
|
16967
|
+
if (token === null) return {
|
|
16968
|
+
ok: false,
|
|
16969
|
+
error: "no bot token configured for this destination"
|
|
16970
|
+
};
|
|
16971
|
+
const body = message.split("\n").filter((line) => line.length > 0);
|
|
16972
|
+
return sendTelegramMessage(token, target.chatId, formatTelegramMessage(title, body));
|
|
16973
|
+
}
|
|
16974
|
+
/** Records one destination's outcome, keyed by its id. */
|
|
16975
|
+
remember(id, message) {
|
|
16976
|
+
this.results.set(id, {
|
|
16977
|
+
lastResult: message,
|
|
16978
|
+
lastResultAt: Date.now()
|
|
16979
|
+
});
|
|
15946
16980
|
}
|
|
15947
16981
|
};
|
|
15948
16982
|
}));
|
|
@@ -15981,8 +17015,7 @@ function stateSignature(state) {
|
|
|
15981
17015
|
workspace.ddns.ipv6,
|
|
15982
17016
|
...workspace.ddns.records.map((record) => `${record.host}:${record.type}:${record.state}:${record.ip}`)
|
|
15983
17017
|
].join(":"),
|
|
15984
|
-
workspace.notifications.
|
|
15985
|
-
workspace.notifications.telegram.tokenSet,
|
|
17018
|
+
...(workspace.notifications.destinations ?? []).map((destination) => `${destination.id}:${destination.kind}:${destination.enabled}:${destination.tokenSet}:${destination.lastResult ?? ""}`),
|
|
15986
17019
|
...workspace.servers.map((server) => [
|
|
15987
17020
|
server.id,
|
|
15988
17021
|
server.status,
|
|
@@ -16213,6 +17246,7 @@ var init_panel = __esmMin((() => {
|
|
|
16213
17246
|
async dispose() {
|
|
16214
17247
|
this.disposed = true;
|
|
16215
17248
|
clearInterval(this.timer);
|
|
17249
|
+
this.options.backups.dispose();
|
|
16216
17250
|
await Promise.all(this.workspaces().map((runtime) => runtime.supervisor.dispose()));
|
|
16217
17251
|
for (const runtime of this.workspaces()) this.disposeRuntime(runtime);
|
|
16218
17252
|
}
|
|
@@ -16326,6 +17360,7 @@ var init_panel = __esmMin((() => {
|
|
|
16326
17360
|
if (this.disposed) return;
|
|
16327
17361
|
const now = Date.now();
|
|
16328
17362
|
await this.options.hostMonitor.tick(now);
|
|
17363
|
+
this.options.backups.tick(now);
|
|
16329
17364
|
for (const runtime of this.workspaces()) runtime.ddns.tick(now);
|
|
16330
17365
|
const proxy = this.options.proxy();
|
|
16331
17366
|
if (proxy !== null) await proxy.sync().catch((error) => {
|
|
@@ -18168,7 +19203,10 @@ function targets(configOverride) {
|
|
|
18168
19203
|
validate: (raw) => {
|
|
18169
19204
|
let defaults = {};
|
|
18170
19205
|
try {
|
|
18171
|
-
|
|
19206
|
+
const settingsRaw = JSON.parse(fs.readFileSync(workspaceSettingsPath(workspace.id), "utf8"));
|
|
19207
|
+
const meta = typeof settingsRaw.meta === "object" && settingsRaw.meta !== null ? settingsRaw.meta : {};
|
|
19208
|
+
const from = typeof meta.schema === "number" ? meta.schema : 1;
|
|
19209
|
+
defaults = (planConfigMigrations(from).steps.length > 0 ? applyConfigMigrations(settingsRaw, from).config : settingsRaw)?.defaults ?? {};
|
|
18172
19210
|
} catch {}
|
|
18173
19211
|
return parseServersFile(raw, defaults).errors[0] ?? null;
|
|
18174
19212
|
}
|
|
@@ -18192,7 +19230,18 @@ async function runMigrate(config, dryRun, yes) {
|
|
|
18192
19230
|
const from = typeof meta.schema === "number" ? meta.schema : 1;
|
|
18193
19231
|
const plan = planConfigMigrations(from);
|
|
18194
19232
|
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.`);
|
|
18195
|
-
|
|
19233
|
+
/**
|
|
19234
|
+
* Validate what this release will actually read: the file as-is when it needs no migration,
|
|
19235
|
+
* otherwise the result the steps produce.
|
|
19236
|
+
*
|
|
19237
|
+
* The parsed check cannot be run on the raw file when a migration is pending — the reader
|
|
19238
|
+
* refuses an older schema by design (`readStamp`), which would make `migrate` refuse every
|
|
19239
|
+
* config it exists to lift. `checkShape` below catches a file that is not even JSON-shaped
|
|
19240
|
+
* before the steps touch it.
|
|
19241
|
+
*/
|
|
19242
|
+
const migrated = plan.steps.length === 0 ? raw : applyConfigMigrations(raw, from).config;
|
|
19243
|
+
if (plan.steps.length > 0 && !isRecord(migrated)) fail(`${target.file} did not produce an object when migrated — refusing to write it`);
|
|
19244
|
+
const problem = target.validate(migrated);
|
|
18196
19245
|
if (problem !== null) fail(`${target.file} is not a config this release can read: ${problem}`);
|
|
18197
19246
|
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}.
|
|
18198
19247
|
That is a bug in this release: report it rather than editing the file.`);
|
|
@@ -18213,7 +19262,6 @@ async function runMigrate(config, dryRun, yes) {
|
|
|
18213
19262
|
return;
|
|
18214
19263
|
}
|
|
18215
19264
|
}
|
|
18216
|
-
const { config: migrated } = applyConfigMigrations(raw, from);
|
|
18217
19265
|
const migratedProblem = target.validate(migrated);
|
|
18218
19266
|
if (migratedProblem !== null) fail(`the migration produced a config this release cannot read:\n ${migratedProblem}`);
|
|
18219
19267
|
fs.copyFileSync(target.file, `${target.file}.bak`);
|
|
@@ -18238,6 +19286,7 @@ var init_migrate = __esmMin((() => {
|
|
|
18238
19286
|
init_paths();
|
|
18239
19287
|
init_version();
|
|
18240
19288
|
init_error_message();
|
|
19289
|
+
init_shape();
|
|
18241
19290
|
migrateArgs = {
|
|
18242
19291
|
config: {
|
|
18243
19292
|
type: "string",
|