home-hosted 0.8.3 → 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 +16 -5
- package/dist/cli.js +888 -232
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +2 -1
- package/docs/NOTIFICATIONS.md +72 -20
- package/docs/SERVERS.md +1 -0
- package/docs/UI_CREATION.md +16 -6
- package/package.json +24 -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/index.html +2 -2
- package/uis/stock/dist/ui.json +3 -3
- package/uis/stock/dist/assets/de-CiT_tMnf.js +0 -1
- package/uis/stock/dist/assets/es-JJDGEbt0.js +0 -1
- package/uis/stock/dist/assets/fr-CYIAuJgb.js +0 -1
- package/uis/stock/dist/assets/index-BCPp9MUj.js +0 -46
- package/uis/stock/dist/assets/index-Skf2FzpK.css +0 -2
- package/uis/stock/dist/assets/ja-DmykOijW.js +0 -1
- package/uis/stock/dist/assets/ko-C4EcOJB0.js +0 -1
- package/uis/stock/dist/assets/ru-Dlbre_Q1.js +0 -1
- package/uis/stock/dist/assets/vi-DhBWYY9I.js +0 -1
- package/uis/stock/dist/assets/zh-CN-BI0ZDO_Y.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?",
|
|
@@ -1384,10 +1478,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1384
1478
|
privateKey: "string >= 1"
|
|
1385
1479
|
}).onUndeclaredKey("reject");
|
|
1386
1480
|
proxyEngineInstallSchema = type({ version: "string = \"\"" }).onUndeclaredKey("reject");
|
|
1387
|
-
|
|
1388
|
-
enabled: "boolean",
|
|
1389
|
-
tokenSet: "boolean",
|
|
1390
|
-
chatId: "string",
|
|
1481
|
+
notificationPolicyStatusShape = {
|
|
1391
1482
|
onCrash: "boolean",
|
|
1392
1483
|
onUnhealthy: "boolean",
|
|
1393
1484
|
onForcedRestart: "boolean",
|
|
@@ -1396,8 +1487,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1396
1487
|
onHost: "boolean",
|
|
1397
1488
|
/** Dynamic DNS, when this panel knows about it (optional for an older panel). */
|
|
1398
1489
|
onDdns: "boolean?",
|
|
1399
|
-
cooldownMs: "number"
|
|
1400
|
-
|
|
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. */
|
|
1401
1508
|
lastResult: "string | null",
|
|
1402
1509
|
lastResultAt: "number | null"
|
|
1403
1510
|
});
|
|
@@ -1405,7 +1512,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1405
1512
|
id: "string | number",
|
|
1406
1513
|
title: "string"
|
|
1407
1514
|
}).array() });
|
|
1408
|
-
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
|
+
});
|
|
1409
1521
|
sessionViewSchema = type({
|
|
1410
1522
|
authenticated: "boolean",
|
|
1411
1523
|
authRequired: "boolean",
|
|
@@ -1526,14 +1638,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1526
1638
|
}).array()
|
|
1527
1639
|
});
|
|
1528
1640
|
backupsViewSchema = type({
|
|
1529
|
-
enabled: "boolean",
|
|
1530
|
-
dir: "string",
|
|
1531
|
-
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",
|
|
1532
1654
|
/** Extra paths from the global config, in addition to each server's own. */
|
|
1533
|
-
includePaths: type("string[]"),
|
|
1655
|
+
"includePaths": type("string[]"),
|
|
1534
1656
|
/** Every selectable entry, global first, then each workspace. */
|
|
1535
|
-
entries: backupEntrySchema.array(),
|
|
1536
|
-
files: backupFileSchema.array()
|
|
1657
|
+
"entries": backupEntrySchema.array(),
|
|
1658
|
+
"files": backupFileSchema.array()
|
|
1537
1659
|
});
|
|
1538
1660
|
restoreItemSchema = type({
|
|
1539
1661
|
/** A backup entry or leaf id (`workspace:<id>:servers`, `data:<path>`, ...). */
|
|
@@ -1662,11 +1784,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1662
1784
|
"files?": "number.integer >= 1",
|
|
1663
1785
|
/** `owner/name` of the UI's own repository, for `ui-update`. */
|
|
1664
1786
|
"repo?": "string",
|
|
1665
|
-
/** 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. */
|
|
1666
1788
|
"tag?": "string",
|
|
1667
1789
|
/** The release asset name, e.g. `home-hosted-ui-noc-console`. */
|
|
1668
1790
|
"asset?": "string",
|
|
1669
|
-
/** 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. */
|
|
1670
1792
|
"unix?": "number.integer >= 0"
|
|
1671
1793
|
});
|
|
1672
1794
|
uiStatusSchema = type({
|
|
@@ -1766,10 +1888,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
1766
1888
|
lines: logLineSchema.array()
|
|
1767
1889
|
});
|
|
1768
1890
|
notificationActionSchema = type({
|
|
1769
|
-
/** Optional override, so the token can be
|
|
1891
|
+
/** Optional override, so the token can be tried before it is saved. */
|
|
1770
1892
|
botToken: "string?",
|
|
1771
1893
|
chatId: "string?"
|
|
1772
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");
|
|
1773
1904
|
telegramTokenSchema = type({ botToken: "string >= 1" }).onUndeclaredKey("reject");
|
|
1774
1905
|
apiErrorSchema = type({
|
|
1775
1906
|
"message": "string",
|
|
@@ -1967,6 +2098,20 @@ function isPlainDdns(value) {
|
|
|
1967
2098
|
return "plain" in value;
|
|
1968
2099
|
}
|
|
1969
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
|
+
/**
|
|
1970
2115
|
* Sealed on disk, or `{ provider, values }` waiting for the sealing write. A bare
|
|
1971
2116
|
* field map — written before the provider was recorded — no longer reads, because
|
|
1972
2117
|
* there is nothing to say which provider those fields belong to.
|
|
@@ -2048,7 +2193,7 @@ function openDdns(accountId, sealed, key) {
|
|
|
2048
2193
|
return null;
|
|
2049
2194
|
}
|
|
2050
2195
|
}
|
|
2051
|
-
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;
|
|
2052
2197
|
var init_secrets = __esmMin((() => {
|
|
2053
2198
|
init_atomic();
|
|
2054
2199
|
init_logger();
|
|
@@ -2061,6 +2206,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2061
2206
|
};
|
|
2062
2207
|
KEYLEN = 64;
|
|
2063
2208
|
SALT_BYTES = 16;
|
|
2209
|
+
LEGACY_TELEGRAM_ID$1 = "telegram";
|
|
2064
2210
|
DDNS_KEYLEN = 32;
|
|
2065
2211
|
DDNS_IV_BYTES = 12;
|
|
2066
2212
|
DDNS_AAD_PREFIX = "hh-ddns-v1:";
|
|
@@ -2138,11 +2284,17 @@ var init_secrets = __esmMin((() => {
|
|
|
2138
2284
|
get apiTokenHint() {
|
|
2139
2285
|
return this.apiToken?.hint ?? null;
|
|
2140
2286
|
}
|
|
2141
|
-
|
|
2142
|
-
|
|
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;
|
|
2143
2294
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
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();
|
|
2146
2298
|
}
|
|
2147
2299
|
setPassword(password, options = {}) {
|
|
2148
2300
|
const record = hashPassword(password, options);
|
|
@@ -2177,11 +2329,24 @@ var init_secrets = __esmMin((() => {
|
|
|
2177
2329
|
apiToken: null
|
|
2178
2330
|
});
|
|
2179
2331
|
}
|
|
2180
|
-
setTelegramToken(token) {
|
|
2332
|
+
setTelegramToken(id, token) {
|
|
2181
2333
|
const trimmed = token?.trim() ?? "";
|
|
2334
|
+
const current = { ...this.load().telegram };
|
|
2335
|
+
if (trimmed.length > 0) current[id] = trimmed;
|
|
2336
|
+
else delete current[id];
|
|
2182
2337
|
this.save({
|
|
2183
2338
|
...this.load(),
|
|
2184
|
-
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
|
|
2185
2350
|
});
|
|
2186
2351
|
}
|
|
2187
2352
|
/** Ids that have something stored, whether or not a config still declares them. */
|
|
@@ -2286,7 +2451,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2286
2451
|
const empty = {
|
|
2287
2452
|
password: null,
|
|
2288
2453
|
apiToken: null,
|
|
2289
|
-
telegram:
|
|
2454
|
+
telegram: {},
|
|
2290
2455
|
ddns: {},
|
|
2291
2456
|
ddnsKdf: null
|
|
2292
2457
|
};
|
|
@@ -2303,7 +2468,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2303
2468
|
return this.inScope({
|
|
2304
2469
|
password: parsed?.password ?? null,
|
|
2305
2470
|
apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
|
|
2306
|
-
telegram: parsed?.telegram
|
|
2471
|
+
telegram: readTelegramTokens(parsed?.telegram),
|
|
2307
2472
|
ddns: readStoredDdns(parsed?.ddns),
|
|
2308
2473
|
ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
|
|
2309
2474
|
});
|
|
@@ -2316,7 +2481,7 @@ var init_secrets = __esmMin((() => {
|
|
|
2316
2481
|
inScope(contents) {
|
|
2317
2482
|
if (this.scope === "global") return {
|
|
2318
2483
|
...contents,
|
|
2319
|
-
telegram:
|
|
2484
|
+
telegram: {},
|
|
2320
2485
|
ddns: {},
|
|
2321
2486
|
ddnsKdf: null
|
|
2322
2487
|
};
|
|
@@ -3121,7 +3286,10 @@ function buildWorkspaceView(source) {
|
|
|
3121
3286
|
logsDir: source.logsDir,
|
|
3122
3287
|
defaults: source.store.defaults,
|
|
3123
3288
|
logs: source.store.logs,
|
|
3124
|
-
notifications: {
|
|
3289
|
+
notifications: {
|
|
3290
|
+
defaults: source.notifications.defaults(),
|
|
3291
|
+
destinations: source.notifications.destinations()
|
|
3292
|
+
},
|
|
3125
3293
|
ddns: source.ddns.view,
|
|
3126
3294
|
serverCount: servers.length,
|
|
3127
3295
|
runningCount: servers.filter((server) => server.status === "running").length,
|
|
@@ -3717,9 +3885,55 @@ var init_control = __esmMin((() => {
|
|
|
3717
3885
|
}));
|
|
3718
3886
|
//#endregion
|
|
3719
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
|
+
}
|
|
3720
3934
|
/** What would have to run to bring `from` up to `to`. */
|
|
3721
3935
|
function planConfigMigrations(from, options = {}) {
|
|
3722
|
-
const to = options.to ??
|
|
3936
|
+
const to = options.to ?? 2;
|
|
3723
3937
|
const steps = (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to);
|
|
3724
3938
|
return {
|
|
3725
3939
|
from,
|
|
@@ -3729,19 +3943,42 @@ function planConfigMigrations(from, options = {}) {
|
|
|
3729
3943
|
reached: steps.at(-1)?.to ?? from
|
|
3730
3944
|
};
|
|
3731
3945
|
}
|
|
3732
|
-
/**
|
|
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
|
+
*/
|
|
3733
3954
|
function applyConfigMigrations(config, from, options = {}) {
|
|
3734
|
-
const
|
|
3955
|
+
const plan = planConfigMigrations(from, options);
|
|
3735
3956
|
let current = config;
|
|
3736
|
-
for (const step of steps) current = step.apply(current);
|
|
3737
|
-
return {
|
|
3957
|
+
for (const step of plan.steps) current = step.apply(current);
|
|
3958
|
+
if (plan.steps.length === 0) return {
|
|
3738
3959
|
config: current,
|
|
3739
|
-
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
|
|
3740
3972
|
};
|
|
3741
3973
|
}
|
|
3742
|
-
var configMigrations;
|
|
3974
|
+
var LEGACY_TELEGRAM_ID, configMigrations;
|
|
3743
3975
|
var init_migrations = __esmMin((() => {
|
|
3744
|
-
|
|
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
|
+
}];
|
|
3745
3982
|
}));
|
|
3746
3983
|
//#endregion
|
|
3747
3984
|
//#region src/config/parse.ts
|
|
@@ -3805,10 +4042,10 @@ function readStamp(raw, options) {
|
|
|
3805
4042
|
const meta = isRecord(raw.meta) ? raw.meta : null;
|
|
3806
4043
|
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
3807
4044
|
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
3808
|
-
if (schemaVersion >
|
|
4045
|
+
if (schemaVersion > 2) return {
|
|
3809
4046
|
schemaVersion,
|
|
3810
4047
|
writtenBy,
|
|
3811
|
-
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`
|
|
3812
4049
|
};
|
|
3813
4050
|
const { steps } = planConfigMigrations(schemaVersion, options);
|
|
3814
4051
|
if (steps.length > 0) return {
|
|
@@ -3965,6 +4202,7 @@ function validateCrossField(servers) {
|
|
|
3965
4202
|
for (const server of servers) {
|
|
3966
4203
|
const { health } = server;
|
|
3967
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`);
|
|
3968
4206
|
for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
3969
4207
|
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
3970
4208
|
}
|
|
@@ -3992,7 +4230,7 @@ function stampConfig(draft) {
|
|
|
3992
4230
|
...$schema === void 0 ? {} : { $schema },
|
|
3993
4231
|
meta: {
|
|
3994
4232
|
writtenBy: appVersion(),
|
|
3995
|
-
schema:
|
|
4233
|
+
schema: 2
|
|
3996
4234
|
},
|
|
3997
4235
|
...rest
|
|
3998
4236
|
};
|
|
@@ -4043,7 +4281,7 @@ function mergeGroup(target, patch) {
|
|
|
4043
4281
|
}
|
|
4044
4282
|
return merged;
|
|
4045
4283
|
}
|
|
4046
|
-
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;
|
|
4047
4285
|
var init_patch = __esmMin((() => {
|
|
4048
4286
|
init_shape();
|
|
4049
4287
|
SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -4052,9 +4290,10 @@ var init_patch = __esmMin((() => {
|
|
|
4052
4290
|
"stop"
|
|
4053
4291
|
]);
|
|
4054
4292
|
CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
|
|
4055
|
-
NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["
|
|
4293
|
+
NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["defaults"]);
|
|
4056
4294
|
DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
|
|
4057
4295
|
PROXY_MERGE_KEYS = /* @__PURE__ */ new Set(["dns01"]);
|
|
4296
|
+
BACKUP_MERGE_KEYS = /* @__PURE__ */ new Set(["schedule"]);
|
|
4058
4297
|
EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
|
|
4059
4298
|
}));
|
|
4060
4299
|
//#endregion
|
|
@@ -4244,7 +4483,7 @@ var init_settings$1 = __esmMin((() => {
|
|
|
4244
4483
|
updateBackups(patch) {
|
|
4245
4484
|
const draft = structuredClone(this.raw);
|
|
4246
4485
|
draft.backups = { ...draft.backups ?? {} };
|
|
4247
|
-
applyPatch(draft.backups, patch,
|
|
4486
|
+
applyPatch(draft.backups, patch, BACKUP_MERGE_KEYS);
|
|
4248
4487
|
const backups = this.parseGroup(backupsSchema, draft.backups, "backups");
|
|
4249
4488
|
this.commit(draft);
|
|
4250
4489
|
return backups;
|
|
@@ -5806,6 +6045,171 @@ var init_ddns$2 = __esmMin((() => {
|
|
|
5806
6045
|
BY_ID$1 = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
|
|
5807
6046
|
}));
|
|
5808
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
|
|
5809
6213
|
//#region src/config/store.ts
|
|
5810
6214
|
function readFile(file) {
|
|
5811
6215
|
let text;
|
|
@@ -5851,6 +6255,8 @@ var init_store = __esmMin((() => {
|
|
|
5851
6255
|
init_settings$1();
|
|
5852
6256
|
init_atomic();
|
|
5853
6257
|
init_ddns$2();
|
|
6258
|
+
init_webhook();
|
|
6259
|
+
init_contracts();
|
|
5854
6260
|
WorkspaceStore = class {
|
|
5855
6261
|
workspaceId;
|
|
5856
6262
|
settingsFile;
|
|
@@ -6070,6 +6476,13 @@ var init_store = __esmMin((() => {
|
|
|
6070
6476
|
draft.notifications = { ...draft.notifications ?? {} };
|
|
6071
6477
|
applyPatch(draft.notifications, patch, NOTIFICATION_MERGE_KEYS);
|
|
6072
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
|
+
}
|
|
6073
6486
|
this.commitSettings(draft);
|
|
6074
6487
|
return notifications;
|
|
6075
6488
|
}
|
|
@@ -6769,58 +7182,77 @@ var init_telegram = __esmMin((() => {
|
|
|
6769
7182
|
//#endregion
|
|
6770
7183
|
//#region src/api/notifications.ts
|
|
6771
7184
|
/**
|
|
6772
|
-
*
|
|
7185
|
+
* A destination's credential is written straight to a workspace's secrets file and never into a
|
|
6773
7186
|
* servers config, so notification *policy* and the *credential* stay separate.
|
|
6774
7187
|
*
|
|
6775
|
-
* 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.
|
|
6776
7190
|
*/
|
|
6777
7191
|
function createNotificationsRoute(deps) {
|
|
6778
|
-
|
|
7192
|
+
const runtimeFor = (workspace) => requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), workspace);
|
|
7193
|
+
return appFactory.createApp().put("/notifications/destinations/:id/token", describeRoute({
|
|
6779
7194
|
tags: ["notifications"],
|
|
6780
|
-
summary: "Store
|
|
7195
|
+
summary: "Store a Telegram destination's bot token (verified first)",
|
|
6781
7196
|
responses: {
|
|
6782
7197
|
200: { description: "Stored" },
|
|
6783
7198
|
400: ERROR_RESPONSES[400]
|
|
6784
7199
|
}
|
|
6785
|
-
}), validate("query", workspaceQuerySchema), validate("json", telegramTokenSchema), async (c) => {
|
|
6786
|
-
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;
|
|
6787
7203
|
const { botToken } = c.req.valid("json");
|
|
6788
7204
|
const verified = await runtime.notifications.verifyToken(botToken);
|
|
6789
7205
|
if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
|
|
6790
7206
|
statusCode: 400,
|
|
6791
7207
|
code: "TELEGRAM_TOKEN_REJECTED"
|
|
6792
7208
|
});
|
|
6793
|
-
runtime.secrets.setTelegramToken(botToken);
|
|
7209
|
+
runtime.secrets.setTelegramToken(id, botToken);
|
|
6794
7210
|
forgetBots();
|
|
6795
7211
|
return c.json({
|
|
6796
7212
|
ok: true,
|
|
6797
7213
|
username: verified.username ?? null
|
|
6798
7214
|
});
|
|
6799
|
-
}).delete("/notifications/token", describeRoute({
|
|
7215
|
+
}).delete("/notifications/destinations/:id/token", describeRoute({
|
|
6800
7216
|
tags: ["notifications"],
|
|
6801
|
-
summary: "Forget
|
|
7217
|
+
summary: "Forget a Telegram destination's bot token",
|
|
6802
7218
|
responses: { 200: { description: "Removed" } }
|
|
6803
|
-
}), validate("query", workspaceQuerySchema), (c) => {
|
|
6804
|
-
|
|
7219
|
+
}), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), (c) => {
|
|
7220
|
+
runtimeFor(c.req.valid("query").workspace).secrets.setTelegramToken(c.req.valid("param").id, null);
|
|
6805
7221
|
forgetBots();
|
|
6806
7222
|
return c.json({ ok: true });
|
|
6807
|
-
}).post("/notifications/test", describeRoute({
|
|
7223
|
+
}).post("/notifications/destinations/:id/test", describeRoute({
|
|
6808
7224
|
tags: ["notifications"],
|
|
6809
|
-
summary: "Send a test message",
|
|
7225
|
+
summary: "Send a test message to one destination",
|
|
6810
7226
|
responses: {
|
|
6811
7227
|
200: { description: "Sent or refused" },
|
|
6812
7228
|
400: ERROR_RESPONSES[400]
|
|
6813
7229
|
}
|
|
6814
|
-
}), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
|
|
6815
|
-
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"));
|
|
6816
7232
|
if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
|
|
6817
7233
|
statusCode: 400,
|
|
6818
7234
|
code: "TELEGRAM_SEND_FAILED"
|
|
6819
7235
|
});
|
|
6820
7236
|
return c.json(result);
|
|
6821
|
-
}).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({
|
|
6822
7254
|
tags: ["notifications"],
|
|
6823
|
-
summary: "List the chats
|
|
7255
|
+
summary: "List the chats a Telegram destination's bot can see",
|
|
6824
7256
|
responses: {
|
|
6825
7257
|
200: {
|
|
6826
7258
|
description: "Chats",
|
|
@@ -6828,8 +7260,8 @@ function createNotificationsRoute(deps) {
|
|
|
6828
7260
|
},
|
|
6829
7261
|
400: ERROR_RESPONSES[400]
|
|
6830
7262
|
}
|
|
6831
|
-
}), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
|
|
6832
|
-
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"));
|
|
6833
7265
|
if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
|
|
6834
7266
|
statusCode: 400,
|
|
6835
7267
|
code: "TELEGRAM_LIST_FAILED"
|
|
@@ -10531,7 +10963,10 @@ function buildWorkspaceSettings(runtime) {
|
|
|
10531
10963
|
configError: runtime.store.configError,
|
|
10532
10964
|
defaults: runtime.store.defaults,
|
|
10533
10965
|
logs: runtime.store.logs,
|
|
10534
|
-
notifications: {
|
|
10966
|
+
notifications: {
|
|
10967
|
+
defaults: runtime.notifications.defaults(),
|
|
10968
|
+
destinations: runtime.notifications.destinations()
|
|
10969
|
+
}
|
|
10535
10970
|
};
|
|
10536
10971
|
}
|
|
10537
10972
|
function createSettingsRoute(deps) {
|
|
@@ -10723,7 +11158,10 @@ function createSettingsRoute(deps) {
|
|
|
10723
11158
|
try {
|
|
10724
11159
|
if (patch.defaults !== void 0) runtime.store.updateDefaults(patch.defaults);
|
|
10725
11160
|
if (patch.logs !== void 0) runtime.store.updateLogs(patch.logs);
|
|
10726
|
-
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
|
+
}
|
|
10727
11165
|
} catch (error) {
|
|
10728
11166
|
if (error instanceof ConfigError) throw new DetailedError(error.message, {
|
|
10729
11167
|
statusCode: 400,
|
|
@@ -11546,64 +11984,6 @@ var init_secret_parts = __esmMin((() => {
|
|
|
11546
11984
|
EXTRA_FIELDS = { ddns: ["ddns", "ddnsKdf"] };
|
|
11547
11985
|
}));
|
|
11548
11986
|
//#endregion
|
|
11549
|
-
//#region src/helpers/env-file.ts
|
|
11550
|
-
/** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
|
|
11551
|
-
function parseEnvFile(text) {
|
|
11552
|
-
const env = {};
|
|
11553
|
-
for (const raw of text.split("\n")) {
|
|
11554
|
-
const line = raw.trim();
|
|
11555
|
-
if (line.length === 0 || line.startsWith("#")) continue;
|
|
11556
|
-
const assignment = line.startsWith("export ") ? line.slice(7) : line;
|
|
11557
|
-
const separator = assignment.indexOf("=");
|
|
11558
|
-
if (separator <= 0) continue;
|
|
11559
|
-
const key = assignment.slice(0, separator).trim();
|
|
11560
|
-
let value = assignment.slice(separator + 1).trim();
|
|
11561
|
-
if (value.length > 1 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
11562
|
-
env[key] = value;
|
|
11563
|
-
}
|
|
11564
|
-
return env;
|
|
11565
|
-
}
|
|
11566
|
-
/** Missing files are not an error: an env file is an optional override layer. */
|
|
11567
|
-
function loadEnvFile(file) {
|
|
11568
|
-
try {
|
|
11569
|
-
return {
|
|
11570
|
-
env: parseEnvFile(fs.readFileSync(file, "utf8")),
|
|
11571
|
-
path: file,
|
|
11572
|
-
error: null
|
|
11573
|
-
};
|
|
11574
|
-
} catch (error) {
|
|
11575
|
-
if (error.code === "ENOENT") return {
|
|
11576
|
-
env: {},
|
|
11577
|
-
path: file,
|
|
11578
|
-
error: null
|
|
11579
|
-
};
|
|
11580
|
-
return {
|
|
11581
|
-
env: {},
|
|
11582
|
-
path: file,
|
|
11583
|
-
error: errorMessage(error)
|
|
11584
|
-
};
|
|
11585
|
-
}
|
|
11586
|
-
}
|
|
11587
|
-
/** Expands `${VAR}` from the given vars; unknown references are left visible. */
|
|
11588
|
-
function expandEnv(value, vars) {
|
|
11589
|
-
return value.replace(VARIABLE, (match, name) => vars[name] ?? match);
|
|
11590
|
-
}
|
|
11591
|
-
function expandEnvRecord(record, vars) {
|
|
11592
|
-
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expandEnv(value, vars)]));
|
|
11593
|
-
}
|
|
11594
|
-
function expandEnvList(values, vars) {
|
|
11595
|
-
return values.map((value) => expandEnv(value, vars));
|
|
11596
|
-
}
|
|
11597
|
-
function resolveEnvFilePath(file, cwd) {
|
|
11598
|
-
if (path.isAbsolute(file)) return file;
|
|
11599
|
-
return path.resolve(cwd, file);
|
|
11600
|
-
}
|
|
11601
|
-
var VARIABLE;
|
|
11602
|
-
var init_env_file = __esmMin((() => {
|
|
11603
|
-
init_error_message();
|
|
11604
|
-
VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
|
|
11605
|
-
}));
|
|
11606
|
-
//#endregion
|
|
11607
11987
|
//#region src/providers/archive.ts
|
|
11608
11988
|
/** Recognised by content, never by the file's name. */
|
|
11609
11989
|
function isZipArchive(file) {
|
|
@@ -11850,18 +12230,99 @@ async function probeTcp(host, port, timeoutMs) {
|
|
|
11850
12230
|
};
|
|
11851
12231
|
}
|
|
11852
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
|
+
/**
|
|
11853
12319
|
* Fetches the configured path and asserts the response, so "listening" is not
|
|
11854
12320
|
* mistaken for "working".
|
|
11855
12321
|
*/
|
|
11856
12322
|
async function probeHttp(host, port, options) {
|
|
11857
|
-
const url = `http://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`;
|
|
11858
12323
|
const started = Date.now();
|
|
11859
12324
|
try {
|
|
11860
|
-
const response = await
|
|
11861
|
-
method: options.method,
|
|
11862
|
-
redirect: "manual",
|
|
11863
|
-
signal: AbortSignal.timeout(options.timeoutMs)
|
|
11864
|
-
});
|
|
12325
|
+
const response = await request(new URL(`${options.scheme}://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`), options.method, options.allowInvalidCert, options.timeoutMs);
|
|
11865
12326
|
const ms = Date.now() - started;
|
|
11866
12327
|
const expected = options.expectStatus ?? null;
|
|
11867
12328
|
if (expected !== null && response.status !== expected) return {
|
|
@@ -11876,11 +12337,14 @@ async function probeHttp(host, port, options) {
|
|
|
11876
12337
|
};
|
|
11877
12338
|
const bodySkipped = options.expectBody.length > 0 && options.method === "HEAD";
|
|
11878
12339
|
if (options.expectBody.length > 0 && !bodySkipped) {
|
|
11879
|
-
if (!
|
|
11880
|
-
|
|
11881
|
-
|
|
11882
|
-
|
|
11883
|
-
|
|
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
|
+
}
|
|
11884
12348
|
}
|
|
11885
12349
|
if (bodySkipped) return {
|
|
11886
12350
|
healthy: true,
|
|
@@ -11916,9 +12380,11 @@ async function probeHealth(options) {
|
|
|
11916
12380
|
}
|
|
11917
12381
|
return last;
|
|
11918
12382
|
}
|
|
12383
|
+
var MAX_BODY_BYTES;
|
|
11919
12384
|
var init_health_check = __esmMin((() => {
|
|
11920
12385
|
init_port();
|
|
11921
12386
|
init_error_message();
|
|
12387
|
+
MAX_BODY_BYTES = 1e6;
|
|
11922
12388
|
}));
|
|
11923
12389
|
//#endregion
|
|
11924
12390
|
//#region src/providers/process.ts
|
|
@@ -13716,6 +14182,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
13716
14182
|
}
|
|
13717
14183
|
/** The decisions that have to survive this panel, and nothing else. */
|
|
13718
14184
|
persistEntryState(entry) {
|
|
14185
|
+
if (entry.removed) return;
|
|
13719
14186
|
writeEntryState(entryStatePath(this.options.nannyDir, entry.config.id), {
|
|
13720
14187
|
restarts: entry.restarts,
|
|
13721
14188
|
bootstrapDone: entry.bootstrapDone,
|
|
@@ -14001,6 +14468,10 @@ function readText(file) {
|
|
|
14001
14468
|
return null;
|
|
14002
14469
|
}
|
|
14003
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
|
+
}
|
|
14004
14475
|
/** The label of one item, for `applied`; items are built before actions run. */
|
|
14005
14476
|
function labelOf(plan, id) {
|
|
14006
14477
|
return plan.items.find((item) => item.id === id)?.label ?? id;
|
|
@@ -14048,13 +14519,14 @@ function readableWorkspaceSettings(text) {
|
|
|
14048
14519
|
return false;
|
|
14049
14520
|
}
|
|
14050
14521
|
}
|
|
14051
|
-
var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, SECRET_LABELS, BackupService;
|
|
14522
|
+
var MANIFEST, ALLOWED_ROOTS, SUFFIX, AUTO_PREFIX, RELOADABLE, SECRET_LABELS, BackupService;
|
|
14052
14523
|
var init_backups = __esmMin((() => {
|
|
14053
14524
|
init_migrations();
|
|
14054
14525
|
init_parse();
|
|
14055
14526
|
init_secret_parts();
|
|
14056
14527
|
init_atomic();
|
|
14057
14528
|
init_env_file();
|
|
14529
|
+
init_logger();
|
|
14058
14530
|
init_paths();
|
|
14059
14531
|
init_template();
|
|
14060
14532
|
init_version();
|
|
@@ -14070,6 +14542,7 @@ var init_backups = __esmMin((() => {
|
|
|
14070
14542
|
"data"
|
|
14071
14543
|
]);
|
|
14072
14544
|
SUFFIX = ".zip";
|
|
14545
|
+
AUTO_PREFIX = "auto";
|
|
14073
14546
|
RELOADABLE = /^global:settings$|^workspace:[^:]+:(?:settings|servers)$/;
|
|
14074
14547
|
SECRET_LABELS = {
|
|
14075
14548
|
password: {
|
|
@@ -14098,6 +14571,17 @@ var init_backups = __esmMin((() => {
|
|
|
14098
14571
|
*/
|
|
14099
14572
|
flags = /* @__PURE__ */ new Map();
|
|
14100
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;
|
|
14101
14585
|
constructor(options) {
|
|
14102
14586
|
this.options = options;
|
|
14103
14587
|
}
|
|
@@ -14105,6 +14589,9 @@ var init_backups = __esmMin((() => {
|
|
|
14105
14589
|
async warm() {
|
|
14106
14590
|
await this.refresh();
|
|
14107
14591
|
}
|
|
14592
|
+
dispose() {
|
|
14593
|
+
this.disposed = true;
|
|
14594
|
+
}
|
|
14108
14595
|
get directory() {
|
|
14109
14596
|
return this.resolveDir();
|
|
14110
14597
|
}
|
|
@@ -14116,6 +14603,8 @@ var init_backups = __esmMin((() => {
|
|
|
14116
14603
|
enabled: config.enabled,
|
|
14117
14604
|
dir: this.directory,
|
|
14118
14605
|
keep: config.keep,
|
|
14606
|
+
schedule: { ...config.schedule },
|
|
14607
|
+
lastAutomaticAt: this.lastAutomatic(),
|
|
14119
14608
|
includePaths: [...sources.includePaths],
|
|
14120
14609
|
entries: this.entries(sources),
|
|
14121
14610
|
files: this.list()
|
|
@@ -14141,7 +14630,13 @@ var init_backups = __esmMin((() => {
|
|
|
14141
14630
|
const file = path.join(this.resolveDir(), name);
|
|
14142
14631
|
return fs.existsSync(file) ? file : null;
|
|
14143
14632
|
}
|
|
14144
|
-
/**
|
|
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
|
+
*/
|
|
14145
14640
|
async create(options = {}) {
|
|
14146
14641
|
if (!this.options.getConfig().enabled) return {
|
|
14147
14642
|
ok: false,
|
|
@@ -14165,7 +14660,7 @@ var init_backups = __esmMin((() => {
|
|
|
14165
14660
|
const sources = this.options.getSources();
|
|
14166
14661
|
const staging = path.join(dir, `.staging-${Date.now()}`);
|
|
14167
14662
|
const createdAt = Date.now();
|
|
14168
|
-
const name =
|
|
14663
|
+
const name = `${options.automatic === true ? AUTO_PREFIX : "backup"}-${new Date(createdAt).toISOString().replace(/[:T]/g, "-").replace(/\.\d+Z$/, "")}-${createdAt % 1e3}${SUFFIX}`;
|
|
14169
14664
|
const destination = path.join(dir, name);
|
|
14170
14665
|
try {
|
|
14171
14666
|
fs.mkdirSync(staging, { recursive: true });
|
|
@@ -14239,7 +14734,7 @@ var init_backups = __esmMin((() => {
|
|
|
14239
14734
|
createdAt,
|
|
14240
14735
|
hostname: os.hostname(),
|
|
14241
14736
|
writtenBy: appVersion(),
|
|
14242
|
-
schema:
|
|
14737
|
+
schema: 2,
|
|
14243
14738
|
data
|
|
14244
14739
|
};
|
|
14245
14740
|
fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -14355,9 +14850,9 @@ var init_backups = __esmMin((() => {
|
|
|
14355
14850
|
};
|
|
14356
14851
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
14357
14852
|
plan.archive = manifestInfo(manifest);
|
|
14358
|
-
if (manifest.schema !== void 0 && manifest.schema >
|
|
14853
|
+
if (manifest.schema !== void 0 && manifest.schema > 2) return {
|
|
14359
14854
|
...plan,
|
|
14360
|
-
error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema
|
|
14855
|
+
error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 2`
|
|
14361
14856
|
};
|
|
14362
14857
|
const sources = this.options.getSources();
|
|
14363
14858
|
const selectedIds = options.include === void 0 ? null : new Set(options.include);
|
|
@@ -14943,9 +15438,46 @@ var init_backups = __esmMin((() => {
|
|
|
14943
15438
|
});
|
|
14944
15439
|
return true;
|
|
14945
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
|
+
*/
|
|
14946
15449
|
prune() {
|
|
14947
|
-
const
|
|
14948
|
-
|
|
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
|
+
});
|
|
14949
15481
|
}
|
|
14950
15482
|
resolveDir() {
|
|
14951
15483
|
const configured = this.options.getConfig().dir;
|
|
@@ -16193,10 +16725,11 @@ var init_log_files = __esmMin((() => {
|
|
|
16193
16725
|
}));
|
|
16194
16726
|
//#endregion
|
|
16195
16727
|
//#region src/services/notifications.ts
|
|
16196
|
-
var REASON_LABEL, TITLE, NotificationService;
|
|
16728
|
+
var REASON_LABEL, TITLE, COVERS, NotificationService;
|
|
16197
16729
|
var init_notifications = __esmMin((() => {
|
|
16198
16730
|
init_logger();
|
|
16199
16731
|
init_telegram();
|
|
16732
|
+
init_webhook();
|
|
16200
16733
|
init_error_message();
|
|
16201
16734
|
REASON_LABEL = {
|
|
16202
16735
|
"crash": "gave up restarting",
|
|
@@ -16220,122 +16753,230 @@ var init_notifications = __esmMin((() => {
|
|
|
16220
16753
|
"ddns": "🌐 DNS updated",
|
|
16221
16754
|
"ddns-error": "🌐 DNS update failed"
|
|
16222
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
|
+
};
|
|
16223
16767
|
NotificationService = class {
|
|
16224
16768
|
secrets;
|
|
16225
16769
|
getConfig;
|
|
16226
16770
|
cooldowns = /* @__PURE__ */ new Map();
|
|
16227
|
-
|
|
16228
|
-
lastResultAt = null;
|
|
16771
|
+
results = /* @__PURE__ */ new Map();
|
|
16229
16772
|
constructor(secrets, getConfig) {
|
|
16230
16773
|
this.secrets = secrets;
|
|
16231
16774
|
this.getConfig = getConfig;
|
|
16232
16775
|
}
|
|
16233
|
-
|
|
16234
|
-
|
|
16776
|
+
/** The general block, as the page edits it. */
|
|
16777
|
+
defaults() {
|
|
16778
|
+
return { ...this.getConfig().defaults };
|
|
16235
16779
|
}
|
|
16236
|
-
|
|
16237
|
-
|
|
16238
|
-
|
|
16239
|
-
|
|
16240
|
-
|
|
16241
|
-
|
|
16242
|
-
|
|
16243
|
-
|
|
16244
|
-
|
|
16245
|
-
|
|
16246
|
-
|
|
16247
|
-
|
|
16248
|
-
|
|
16249
|
-
|
|
16250
|
-
|
|
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
|
|
16251
16831
|
};
|
|
16832
|
+
return { ...this.getConfig().defaults };
|
|
16833
|
+
}
|
|
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
|
+
});
|
|
16252
16849
|
}
|
|
16253
|
-
/**
|
|
16850
|
+
/** Whether *any* destination would deliver this event. */
|
|
16254
16851
|
shouldNotify(event, now = Date.now()) {
|
|
16255
|
-
|
|
16256
|
-
|
|
16257
|
-
|
|
16258
|
-
"crash": telegram.onCrash,
|
|
16259
|
-
"unhealthy": telegram.onUnhealthy,
|
|
16260
|
-
"forced-restart": telegram.onForcedRestart,
|
|
16261
|
-
"recovered": telegram.onRecovered,
|
|
16262
|
-
"rss": telegram.onCrash,
|
|
16263
|
-
"host": telegram.onHost,
|
|
16264
|
-
"host-recovered": telegram.onHost,
|
|
16265
|
-
"ddns": telegram.onDdns,
|
|
16266
|
-
"ddns-error": telegram.onDdns
|
|
16267
|
-
}[event.reason]) return false;
|
|
16268
|
-
const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
|
|
16269
|
-
return !(telegram.cooldownMs > 0 && until > now);
|
|
16270
|
-
}
|
|
16271
|
-
/** 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. */
|
|
16272
16855
|
markSent(event, now = Date.now()) {
|
|
16273
|
-
this.cooldowns.set(
|
|
16856
|
+
for (const target of this.targetsFor(event, now)) this.cooldowns.set(this.cooldownKey(target, event), now + this.effectivePolicy(target).cooldownMs);
|
|
16274
16857
|
}
|
|
16275
|
-
/** 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. */
|
|
16276
16859
|
notify(event) {
|
|
16277
16860
|
this.dispatch(event).catch((error) => {
|
|
16278
16861
|
logger.warn(`notification failed: ${errorMessage(error)}`);
|
|
16279
16862
|
});
|
|
16280
16863
|
}
|
|
16281
16864
|
async dispatch(event) {
|
|
16282
|
-
|
|
16865
|
+
const targets = this.targetsFor(event);
|
|
16866
|
+
if (targets.length === 0) return false;
|
|
16283
16867
|
this.markSent(event);
|
|
16284
|
-
|
|
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);
|
|
16871
|
+
}
|
|
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;
|
|
16285
16899
|
}
|
|
16286
|
-
/**
|
|
16287
|
-
|
|
16288
|
-
|
|
16289
|
-
|
|
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 {
|
|
16290
16910
|
ok: false,
|
|
16291
16911
|
error: "no chat id configured"
|
|
16292
16912
|
};
|
|
16293
|
-
const token = this.
|
|
16913
|
+
const token = overrideToken !== void 0 && overrideToken.length > 0 ? overrideToken : this.secrets.telegramToken(id);
|
|
16294
16914
|
if (token === null) return {
|
|
16295
16915
|
ok: false,
|
|
16296
|
-
error: "no bot token configured"
|
|
16916
|
+
error: "no bot token configured for this destination"
|
|
16917
|
+
};
|
|
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
|
|
16297
16931
|
};
|
|
16298
|
-
const result = await
|
|
16299
|
-
|
|
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");
|
|
16300
16940
|
return result;
|
|
16301
16941
|
}
|
|
16302
|
-
|
|
16303
|
-
|
|
16304
|
-
|
|
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 {
|
|
16305
16946
|
ok: false,
|
|
16306
16947
|
chats: [],
|
|
16307
|
-
error: "no bot token configured"
|
|
16948
|
+
error: "no bot token configured for this destination"
|
|
16308
16949
|
};
|
|
16309
16950
|
const result = await listTelegramChats(token);
|
|
16310
|
-
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");
|
|
16311
16952
|
return result;
|
|
16312
16953
|
}
|
|
16313
|
-
/** Verifies a token without sending anything. */
|
|
16954
|
+
/** Verifies a token without storing or sending anything. */
|
|
16314
16955
|
async verifyToken(token) {
|
|
16315
16956
|
return verifyTelegramToken(token);
|
|
16316
16957
|
}
|
|
16317
|
-
|
|
16318
|
-
|
|
16319
|
-
return token.length > 0 ? token : null;
|
|
16320
|
-
}
|
|
16321
|
-
async sendTelegram(html) {
|
|
16322
|
-
const telegram = this.getConfig().telegram;
|
|
16323
|
-
if (telegram.chatId.length === 0) {
|
|
16324
|
-
this.remember("no chat id configured");
|
|
16325
|
-
return false;
|
|
16326
|
-
}
|
|
16327
|
-
const token = this.resolveToken();
|
|
16328
|
-
if (token === null) {
|
|
16329
|
-
this.remember("no bot token configured");
|
|
16330
|
-
return false;
|
|
16331
|
-
}
|
|
16332
|
-
const result = await sendTelegramMessage(token, telegram.chatId, html);
|
|
16333
|
-
this.remember(result.ok ? "sent" : result.error ?? "send failed");
|
|
16334
|
-
return result.ok;
|
|
16958
|
+
cooldownKey(target, event) {
|
|
16959
|
+
return `${target.id}:${event.serverId}:${event.reason}`;
|
|
16335
16960
|
}
|
|
16336
|
-
|
|
16337
|
-
|
|
16338
|
-
|
|
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
|
+
});
|
|
16339
16980
|
}
|
|
16340
16981
|
};
|
|
16341
16982
|
}));
|
|
@@ -16374,8 +17015,7 @@ function stateSignature(state) {
|
|
|
16374
17015
|
workspace.ddns.ipv6,
|
|
16375
17016
|
...workspace.ddns.records.map((record) => `${record.host}:${record.type}:${record.state}:${record.ip}`)
|
|
16376
17017
|
].join(":"),
|
|
16377
|
-
workspace.notifications.
|
|
16378
|
-
workspace.notifications.telegram.tokenSet,
|
|
17018
|
+
...(workspace.notifications.destinations ?? []).map((destination) => `${destination.id}:${destination.kind}:${destination.enabled}:${destination.tokenSet}:${destination.lastResult ?? ""}`),
|
|
16379
17019
|
...workspace.servers.map((server) => [
|
|
16380
17020
|
server.id,
|
|
16381
17021
|
server.status,
|
|
@@ -16606,6 +17246,7 @@ var init_panel = __esmMin((() => {
|
|
|
16606
17246
|
async dispose() {
|
|
16607
17247
|
this.disposed = true;
|
|
16608
17248
|
clearInterval(this.timer);
|
|
17249
|
+
this.options.backups.dispose();
|
|
16609
17250
|
await Promise.all(this.workspaces().map((runtime) => runtime.supervisor.dispose()));
|
|
16610
17251
|
for (const runtime of this.workspaces()) this.disposeRuntime(runtime);
|
|
16611
17252
|
}
|
|
@@ -16719,6 +17360,7 @@ var init_panel = __esmMin((() => {
|
|
|
16719
17360
|
if (this.disposed) return;
|
|
16720
17361
|
const now = Date.now();
|
|
16721
17362
|
await this.options.hostMonitor.tick(now);
|
|
17363
|
+
this.options.backups.tick(now);
|
|
16722
17364
|
for (const runtime of this.workspaces()) runtime.ddns.tick(now);
|
|
16723
17365
|
const proxy = this.options.proxy();
|
|
16724
17366
|
if (proxy !== null) await proxy.sync().catch((error) => {
|
|
@@ -18561,7 +19203,10 @@ function targets(configOverride) {
|
|
|
18561
19203
|
validate: (raw) => {
|
|
18562
19204
|
let defaults = {};
|
|
18563
19205
|
try {
|
|
18564
|
-
|
|
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 ?? {};
|
|
18565
19210
|
} catch {}
|
|
18566
19211
|
return parseServersFile(raw, defaults).errors[0] ?? null;
|
|
18567
19212
|
}
|
|
@@ -18585,7 +19230,18 @@ async function runMigrate(config, dryRun, yes) {
|
|
|
18585
19230
|
const from = typeof meta.schema === "number" ? meta.schema : 1;
|
|
18586
19231
|
const plan = planConfigMigrations(from);
|
|
18587
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.`);
|
|
18588
|
-
|
|
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);
|
|
18589
19245
|
if (problem !== null) fail(`${target.file} is not a config this release can read: ${problem}`);
|
|
18590
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}.
|
|
18591
19247
|
That is a bug in this release: report it rather than editing the file.`);
|
|
@@ -18606,7 +19262,6 @@ async function runMigrate(config, dryRun, yes) {
|
|
|
18606
19262
|
return;
|
|
18607
19263
|
}
|
|
18608
19264
|
}
|
|
18609
|
-
const { config: migrated } = applyConfigMigrations(raw, from);
|
|
18610
19265
|
const migratedProblem = target.validate(migrated);
|
|
18611
19266
|
if (migratedProblem !== null) fail(`the migration produced a config this release cannot read:\n ${migratedProblem}`);
|
|
18612
19267
|
fs.copyFileSync(target.file, `${target.file}.bak`);
|
|
@@ -18631,6 +19286,7 @@ var init_migrate = __esmMin((() => {
|
|
|
18631
19286
|
init_paths();
|
|
18632
19287
|
init_version();
|
|
18633
19288
|
init_error_message();
|
|
19289
|
+
init_shape();
|
|
18634
19290
|
migrateArgs = {
|
|
18635
19291
|
config: {
|
|
18636
19292
|
type: "string",
|