home-hosted 0.2.0 → 0.4.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 +103 -15
- package/NOTIFICATIONS.md +69 -0
- package/README.md +208 -179
- package/SERVERS.md +101 -0
- package/UI_CREATION.md +104 -77
- package/dist/cli.js +1197 -142
- package/dist/cli.js.map +1 -1
- package/package.json +12 -4
- package/uis/stock/dist/assets/index-DOD80X00.css +2 -0
- package/uis/stock/dist/assets/index-x_wy1ycR.js +47 -0
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/assets/index-CmkrtNU0.css +0 -2
- package/uis/stock/dist/assets/index-DqcdQi-W.js +0 -47
package/dist/cli.js
CHANGED
|
@@ -88,7 +88,7 @@ function parseBind(value) {
|
|
|
88
88
|
const parsed = bindSchema(value);
|
|
89
89
|
return parsed instanceof type.errors ? null : parsed;
|
|
90
90
|
}
|
|
91
|
-
var bindSchema, portSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, tlsUploadSchema;
|
|
91
|
+
var bindSchema, portSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, tlsUploadSchema;
|
|
92
92
|
var init_contracts = __esmMin((() => {
|
|
93
93
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
94
94
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
@@ -162,7 +162,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
162
162
|
bootstrap: bootstrapOrNullSchema.optional(),
|
|
163
163
|
port: portSchema.optional(),
|
|
164
164
|
bind: bindSchema.default(() => "local"),
|
|
165
|
-
onPortConflict: "\"block\" | \"warn\" = \"block\"",
|
|
165
|
+
onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\" = \"block\"",
|
|
166
166
|
restart: restartSchema.default(() => ({})),
|
|
167
167
|
health: healthSchema.default(() => ({})),
|
|
168
168
|
stop: stopSchema.default(() => ({})),
|
|
@@ -226,7 +226,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
226
226
|
}).onUndeclaredKey("reject");
|
|
227
227
|
controlSchema = type({
|
|
228
228
|
/** What the panel calls itself; the stock UI shows it in the sidebar. */
|
|
229
|
-
label: "1 <= string <= 60 = \"
|
|
229
|
+
label: "1 <= string <= 60 = \"home-hosted\"",
|
|
230
230
|
port: "1 <= number.integer <= 65535 = 3999",
|
|
231
231
|
/** Where the control panel itself listens; keep it `local` unless you mean it. */
|
|
232
232
|
host: bindSchema.default(() => "local"),
|
|
@@ -238,7 +238,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
238
238
|
enabled: "boolean = true",
|
|
239
239
|
autostart: "boolean = false",
|
|
240
240
|
bind: bindSchema.default(() => "local"),
|
|
241
|
-
onPortConflict: "\"block\" | \"warn\" = \"block\"",
|
|
241
|
+
onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\" = \"block\"",
|
|
242
242
|
restart: restartSchema.default(() => ({})),
|
|
243
243
|
health: healthSchema.default(() => ({})),
|
|
244
244
|
stop: stopSchema.default(() => ({})),
|
|
@@ -329,7 +329,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
329
329
|
enabled: "boolean?",
|
|
330
330
|
autostart: "boolean?",
|
|
331
331
|
bind: bindSchema.optional(),
|
|
332
|
-
onPortConflict: "\"block\" | \"warn\"?",
|
|
332
|
+
onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\"?",
|
|
333
333
|
restart: restartPatchSchema.optional(),
|
|
334
334
|
health: healthPatchSchema.optional(),
|
|
335
335
|
stop: stopPatchSchema.optional(),
|
|
@@ -347,7 +347,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
347
347
|
bootstrap: bootstrapOrNullSchema.optional(),
|
|
348
348
|
port: portSchema.optional(),
|
|
349
349
|
bind: bindSchema.optional(),
|
|
350
|
-
onPortConflict: "\"block\" | \"warn\"?",
|
|
350
|
+
onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\"?",
|
|
351
351
|
restart: restartPatchSchema.optional(),
|
|
352
352
|
health: healthPatchSchema.optional(),
|
|
353
353
|
stop: stopPatchSchema.optional(),
|
|
@@ -375,6 +375,13 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
375
375
|
enabled: "boolean",
|
|
376
376
|
passwordSet: "boolean",
|
|
377
377
|
passwordUpdatedAt: "number | null",
|
|
378
|
+
/**
|
|
379
|
+
* An API token is set; it is shown here as a flag, never as a value. Optional so
|
|
380
|
+
* a freshly built UI still parses the state of a panel process that predates it:
|
|
381
|
+
* an upgrade replaces `uis/stock/dist` on disk while the old process keeps
|
|
382
|
+
* serving, and a missing key must not blank the whole app.
|
|
383
|
+
*/
|
|
384
|
+
apiTokenSet: "boolean?",
|
|
378
385
|
/** Still the boot-time default; the login page says so and exposure stays blocked. */
|
|
379
386
|
usingDefaultPassword: "boolean",
|
|
380
387
|
/** The panel currently listens beyond loopback. */
|
|
@@ -419,6 +426,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
419
426
|
authenticated: "boolean",
|
|
420
427
|
authRequired: "boolean",
|
|
421
428
|
passwordSet: "boolean",
|
|
429
|
+
/** A long-lived API token is configured; optional for the same reason as above. */
|
|
430
|
+
apiTokenSet: "boolean?",
|
|
422
431
|
usingDefaultPassword: "boolean",
|
|
423
432
|
/** The boot-time password, exposed only while it is still in use. */
|
|
424
433
|
defaultPassword: "string | null",
|
|
@@ -553,6 +562,8 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
553
562
|
health: healthStateSchema,
|
|
554
563
|
portState: portStateSchema,
|
|
555
564
|
pid: "number | null",
|
|
565
|
+
/** True when the process serving this entry was adopted, not spawned by the panel. */
|
|
566
|
+
adopted: "boolean?",
|
|
556
567
|
startedAt: "number | null",
|
|
557
568
|
exitCode: "number | null",
|
|
558
569
|
exitSignal: "string | null",
|
|
@@ -608,6 +619,17 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
608
619
|
lines: logLineSchema.array().optional()
|
|
609
620
|
});
|
|
610
621
|
logQuerySchema = type({ limit: "string?" });
|
|
622
|
+
freePortResultSchema = type({
|
|
623
|
+
ok: "boolean",
|
|
624
|
+
port: "number | null",
|
|
625
|
+
/** Asked to leave with SIGTERM, and the ones that ignored it. */
|
|
626
|
+
terminated: "number[]",
|
|
627
|
+
forced: "number[]",
|
|
628
|
+
/** Listeners this panel supervises; never signalled. */
|
|
629
|
+
skipped: "number[]",
|
|
630
|
+
/** The port answers no more after the attempt. */
|
|
631
|
+
free: "boolean"
|
|
632
|
+
});
|
|
611
633
|
logHistoryQuerySchema = type({
|
|
612
634
|
tail: "string?",
|
|
613
635
|
/** Case-insensitive substring filter over the tail window. */
|
|
@@ -735,6 +757,7 @@ function isLoopbackRequest(c) {
|
|
|
735
757
|
var init_loopback = __esmMin((() => {}));
|
|
736
758
|
//#endregion
|
|
737
759
|
//#region src/helpers/atomic.ts
|
|
760
|
+
var atomic_exports = /* @__PURE__ */ __exportAll({ writeFileAtomic: () => writeFileAtomic });
|
|
738
761
|
/**
|
|
739
762
|
* Write via a temp file in the same directory, then rename: readers never see a
|
|
740
763
|
* half-written file, and a crash cannot truncate the previous config.
|
|
@@ -750,9 +773,14 @@ var init_atomic = __esmMin((() => {}));
|
|
|
750
773
|
//#endregion
|
|
751
774
|
//#region src/config/secrets.ts
|
|
752
775
|
var secrets_exports = /* @__PURE__ */ __exportAll({
|
|
776
|
+
API_TOKEN_PREFIX: () => "hh_",
|
|
753
777
|
SecretsStore: () => SecretsStore,
|
|
778
|
+
apiTokenRecord: () => apiTokenRecord,
|
|
754
779
|
deriveKey: () => deriveKey,
|
|
780
|
+
generateApiToken: () => generateApiToken,
|
|
781
|
+
hashApiToken: () => hashApiToken,
|
|
755
782
|
hashPassword: () => hashPassword,
|
|
783
|
+
verifyApiToken: () => verifyApiToken,
|
|
756
784
|
verifyPassword: () => verifyPassword
|
|
757
785
|
});
|
|
758
786
|
function deriveKey(password, salt, cost, keylen) {
|
|
@@ -781,7 +809,27 @@ function verifyPassword(password, record) {
|
|
|
781
809
|
if (actual.length !== expected.length) return false;
|
|
782
810
|
return crypto.timingSafeEqual(actual, expected);
|
|
783
811
|
}
|
|
784
|
-
|
|
812
|
+
function generateApiToken() {
|
|
813
|
+
return `hh_${crypto.randomBytes(API_TOKEN_BYTES).toString("base64url")}`;
|
|
814
|
+
}
|
|
815
|
+
function hashApiToken(token) {
|
|
816
|
+
return crypto.createHash("sha256").update(token, "utf8").digest("base64");
|
|
817
|
+
}
|
|
818
|
+
function verifyApiToken(token, record) {
|
|
819
|
+
if (token.length === 0) return false;
|
|
820
|
+
const expected = Buffer$1.from(record.hash, "base64");
|
|
821
|
+
const actual = Buffer$1.from(hashApiToken(token), "base64");
|
|
822
|
+
return crypto.timingSafeEqual(actual, expected);
|
|
823
|
+
}
|
|
824
|
+
function apiTokenRecord(token, now = Date.now()) {
|
|
825
|
+
return {
|
|
826
|
+
algo: "sha256",
|
|
827
|
+
hash: hashApiToken(token),
|
|
828
|
+
hint: token.slice(0, API_TOKEN_HINT_CHARS),
|
|
829
|
+
updatedAt: now
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
var COST, KEYLEN, SALT_BYTES, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
|
|
785
833
|
var init_secrets = __esmMin((() => {
|
|
786
834
|
init_atomic();
|
|
787
835
|
COST = {
|
|
@@ -791,6 +839,8 @@ var init_secrets = __esmMin((() => {
|
|
|
791
839
|
};
|
|
792
840
|
KEYLEN = 64;
|
|
793
841
|
SALT_BYTES = 16;
|
|
842
|
+
API_TOKEN_BYTES = 32;
|
|
843
|
+
API_TOKEN_HINT_CHARS = 8;
|
|
794
844
|
SecretsStore = class {
|
|
795
845
|
file;
|
|
796
846
|
cache = null;
|
|
@@ -833,6 +883,16 @@ var init_secrets = __esmMin((() => {
|
|
|
833
883
|
get usingDefaultPassword() {
|
|
834
884
|
return this.password?.isDefault === true;
|
|
835
885
|
}
|
|
886
|
+
get apiToken() {
|
|
887
|
+
return this.load().apiToken;
|
|
888
|
+
}
|
|
889
|
+
get apiTokenSet() {
|
|
890
|
+
return this.apiToken !== null;
|
|
891
|
+
}
|
|
892
|
+
/** The readable head of the stored token, or null when none is set. */
|
|
893
|
+
get apiTokenHint() {
|
|
894
|
+
return this.apiToken?.hint ?? null;
|
|
895
|
+
}
|
|
836
896
|
get telegramToken() {
|
|
837
897
|
return this.load().telegram?.botToken ?? null;
|
|
838
898
|
}
|
|
@@ -858,6 +918,20 @@ var init_secrets = __esmMin((() => {
|
|
|
858
918
|
password: null
|
|
859
919
|
});
|
|
860
920
|
}
|
|
921
|
+
setApiToken(token) {
|
|
922
|
+
const record = apiTokenRecord(token);
|
|
923
|
+
this.save({
|
|
924
|
+
...this.load(),
|
|
925
|
+
apiToken: record
|
|
926
|
+
});
|
|
927
|
+
return record;
|
|
928
|
+
}
|
|
929
|
+
clearApiToken() {
|
|
930
|
+
this.save({
|
|
931
|
+
...this.load(),
|
|
932
|
+
apiToken: null
|
|
933
|
+
});
|
|
934
|
+
}
|
|
861
935
|
setTelegramToken(token) {
|
|
862
936
|
const trimmed = token?.trim() ?? "";
|
|
863
937
|
this.save({
|
|
@@ -867,21 +941,24 @@ var init_secrets = __esmMin((() => {
|
|
|
867
941
|
}
|
|
868
942
|
read() {
|
|
869
943
|
if (!fs.existsSync(this.file)) return {
|
|
870
|
-
version:
|
|
944
|
+
version: 3,
|
|
871
945
|
password: null,
|
|
946
|
+
apiToken: null,
|
|
872
947
|
telegram: null
|
|
873
948
|
};
|
|
874
949
|
try {
|
|
875
950
|
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
876
951
|
return {
|
|
877
|
-
version:
|
|
952
|
+
version: 3,
|
|
878
953
|
password: parsed?.password ?? null,
|
|
954
|
+
apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
|
|
879
955
|
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null
|
|
880
956
|
};
|
|
881
957
|
} catch {
|
|
882
958
|
return {
|
|
883
|
-
version:
|
|
959
|
+
version: 3,
|
|
884
960
|
password: null,
|
|
961
|
+
apiToken: null,
|
|
885
962
|
telegram: null
|
|
886
963
|
};
|
|
887
964
|
}
|
|
@@ -891,7 +968,21 @@ var init_secrets = __esmMin((() => {
|
|
|
891
968
|
this.cache = contents;
|
|
892
969
|
}
|
|
893
970
|
};
|
|
894
|
-
}))
|
|
971
|
+
}));
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/services/auth.ts
|
|
974
|
+
/**
|
|
975
|
+
* `Authorization: Bearer <token>`; the scheme is case-insensitive per RFC 7235,
|
|
976
|
+
* and the credential is taken whole so a stray space cannot silently shorten it.
|
|
977
|
+
*/
|
|
978
|
+
function bearerToken(header) {
|
|
979
|
+
if (!header) return null;
|
|
980
|
+
const [scheme, ...rest] = header.trim().split(/\s+/);
|
|
981
|
+
if (scheme?.toLowerCase() !== "bearer") return null;
|
|
982
|
+
const token = rest.join("");
|
|
983
|
+
return token.length > 0 ? token : null;
|
|
984
|
+
}
|
|
985
|
+
var SESSION_COOKIE, MAX_SESSIONS, MAX_LOCKOUT_MS, MAX_ATTEMPT_RECORDS, ATTEMPT_RECORD_TTL_MS, ANONYMOUS, AuthService;
|
|
895
986
|
var init_auth$1 = __esmMin((() => {
|
|
896
987
|
init_secrets();
|
|
897
988
|
init_cookies();
|
|
@@ -900,6 +991,11 @@ var init_auth$1 = __esmMin((() => {
|
|
|
900
991
|
MAX_LOCKOUT_MS = 9e5;
|
|
901
992
|
MAX_ATTEMPT_RECORDS = 1e4;
|
|
902
993
|
ATTEMPT_RECORD_TTL_MS = 36e5;
|
|
994
|
+
ANONYMOUS = {
|
|
995
|
+
authenticated: false,
|
|
996
|
+
method: null,
|
|
997
|
+
session: null
|
|
998
|
+
};
|
|
903
999
|
AuthService = class {
|
|
904
1000
|
secrets;
|
|
905
1001
|
getConfig;
|
|
@@ -926,7 +1022,7 @@ var init_auth$1 = __esmMin((() => {
|
|
|
926
1022
|
isEnabled() {
|
|
927
1023
|
return this.getConfig().enabled;
|
|
928
1024
|
}
|
|
929
|
-
/**
|
|
1025
|
+
/** A password is set, so a browser has something to sign in with. */
|
|
930
1026
|
isArmed() {
|
|
931
1027
|
return this.getConfig().enabled && this.secrets.passwordSet;
|
|
932
1028
|
}
|
|
@@ -934,11 +1030,44 @@ var init_auth$1 = __esmMin((() => {
|
|
|
934
1030
|
isRequired() {
|
|
935
1031
|
return this.isArmed();
|
|
936
1032
|
}
|
|
937
|
-
|
|
1033
|
+
get apiTokenSet() {
|
|
1034
|
+
return this.secrets.apiTokenSet;
|
|
1035
|
+
}
|
|
1036
|
+
/** The readable head of the stored token, never the token itself. */
|
|
1037
|
+
get apiTokenHint() {
|
|
1038
|
+
return this.secrets.apiTokenHint;
|
|
1039
|
+
}
|
|
1040
|
+
/** Compared against the stored SHA-256, in constant time. */
|
|
1041
|
+
validateApiToken(token) {
|
|
1042
|
+
if (token === null) return false;
|
|
1043
|
+
const record = this.secrets.apiToken;
|
|
1044
|
+
if (record === null) return false;
|
|
1045
|
+
return verifyApiToken(token, record);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Resolves whichever credential the request carried. The token is only
|
|
1049
|
+
* consulted when no session matched, so a stale cookie cannot mask it.
|
|
1050
|
+
*/
|
|
1051
|
+
authenticate(credentials) {
|
|
1052
|
+
const session = this.validate(credentials.cookieToken);
|
|
1053
|
+
if (session !== null) return {
|
|
1054
|
+
authenticated: true,
|
|
1055
|
+
method: "cookie",
|
|
1056
|
+
session
|
|
1057
|
+
};
|
|
1058
|
+
if (this.validateApiToken(credentials.bearerToken)) return {
|
|
1059
|
+
authenticated: true,
|
|
1060
|
+
method: "token",
|
|
1061
|
+
session: null
|
|
1062
|
+
};
|
|
1063
|
+
return ANONYMOUS;
|
|
1064
|
+
}
|
|
1065
|
+
sessionView(authenticated) {
|
|
938
1066
|
return {
|
|
939
|
-
authenticated
|
|
1067
|
+
authenticated,
|
|
940
1068
|
authRequired: this.isRequired(),
|
|
941
1069
|
passwordSet: this.secrets.passwordSet,
|
|
1070
|
+
apiTokenSet: this.secrets.apiTokenSet,
|
|
942
1071
|
usingDefaultPassword: this.secrets.usingDefaultPassword,
|
|
943
1072
|
defaultPassword: this.secrets.usingDefaultPassword ? "hh" : null,
|
|
944
1073
|
sessionTtlMs: this.getConfig().sessionTtlMs
|
|
@@ -1083,9 +1212,24 @@ var init_auth$1 = __esmMin((() => {
|
|
|
1083
1212
|
//#endregion
|
|
1084
1213
|
//#region src/middleware/auth.ts
|
|
1085
1214
|
/**
|
|
1215
|
+
* The one place that reads a request's credentials, so the guard, the session
|
|
1216
|
+
* route and `/healthz` can never disagree about who is calling.
|
|
1217
|
+
*/
|
|
1218
|
+
function requestIdentity(c, auth) {
|
|
1219
|
+
return auth.authenticate({
|
|
1220
|
+
cookieToken: auth.tokenFromCookie(c.req.header("cookie")),
|
|
1221
|
+
bearerToken: bearerToken(c.req.header("authorization"))
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1086
1225
|
* Guards every `/api/*` route. The SPA shell stays public (it holds no data),
|
|
1087
1226
|
* so the browser can load the app and show the login screen.
|
|
1088
1227
|
*
|
|
1228
|
+
* A request may prove itself with the session cookie or with an API token
|
|
1229
|
+
* (`Authorization: Bearer …`), which is what lets a script or an agent drive the
|
|
1230
|
+
* panel without a browser. The token also works while auth is enabled but no
|
|
1231
|
+
* password is set — that state otherwise only trusts this machine.
|
|
1232
|
+
*
|
|
1089
1233
|
* State-changing requests that carry an `Origin` must come from this same host:
|
|
1090
1234
|
* with `SameSite=Strict` cookies that closes the cross-site CSRF path.
|
|
1091
1235
|
*/
|
|
@@ -1111,14 +1255,17 @@ function createAuthGuard(deps) {
|
|
|
1111
1255
|
}
|
|
1112
1256
|
}
|
|
1113
1257
|
if (!PUBLIC_PATHS.has(path)) {
|
|
1258
|
+
const identity = requestIdentity(c, deps.auth);
|
|
1114
1259
|
if (deps.auth.isArmed()) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1260
|
+
if (!identity.authenticated) {
|
|
1261
|
+
if (deps.auth.apiTokenSet) c.header("WWW-Authenticate", "Bearer realm=\"home-hosted\"");
|
|
1262
|
+
throw new DetailedError("authentication required", {
|
|
1263
|
+
statusCode: 401,
|
|
1264
|
+
code: AUTH_REQUIRED_CODE
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1120
1267
|
} else if (deps.auth.isEnabled()) {
|
|
1121
|
-
if (!isLoopbackRequest(c)) throw new DetailedError("authentication is enabled but no password is set — set one from the machine running the panel", {
|
|
1268
|
+
if (!identity.authenticated && !isLoopbackRequest(c)) throw new DetailedError("authentication is enabled but no password is set — set one from the machine running the panel", {
|
|
1122
1269
|
statusCode: 401,
|
|
1123
1270
|
code: "AUTH_UNARMED"
|
|
1124
1271
|
});
|
|
@@ -1180,7 +1327,7 @@ function checkExposure(control, passwordSet, usingDefaultPassword = false) {
|
|
|
1180
1327
|
};
|
|
1181
1328
|
if (!passwordSet) return {
|
|
1182
1329
|
exposed,
|
|
1183
|
-
blockedReason: `the control panel is bound to ${control.host} but no password is set (run \`pnpm run
|
|
1330
|
+
blockedReason: `the control panel is bound to ${control.host} but no password is set (run \`pnpm run set-password\`)`
|
|
1184
1331
|
};
|
|
1185
1332
|
if (usingDefaultPassword) return {
|
|
1186
1333
|
exposed,
|
|
@@ -1211,7 +1358,7 @@ function createAuthRoute(deps) {
|
|
|
1211
1358
|
description: "Session",
|
|
1212
1359
|
content: jsonBody(sessionViewSchema)
|
|
1213
1360
|
} }
|
|
1214
|
-
}), (c) => c.json(deps.auth.sessionView(deps.auth.
|
|
1361
|
+
}), (c) => c.json(deps.auth.sessionView(requestIdentity(c, deps.auth).authenticated))).post("/auth/login", describeRoute({
|
|
1215
1362
|
tags: ["auth"],
|
|
1216
1363
|
summary: "Exchange the panel password for a session cookie",
|
|
1217
1364
|
responses: {
|
|
@@ -1238,7 +1385,7 @@ function createAuthRoute(deps) {
|
|
|
1238
1385
|
sameSite: "Strict",
|
|
1239
1386
|
httpOnly: true
|
|
1240
1387
|
}));
|
|
1241
|
-
return c.json(deps.auth.sessionView(
|
|
1388
|
+
return c.json(deps.auth.sessionView(true));
|
|
1242
1389
|
}).post("/auth/logout", describeRoute({
|
|
1243
1390
|
tags: ["auth"],
|
|
1244
1391
|
summary: "Drop the session cookie",
|
|
@@ -1262,14 +1409,14 @@ function createAuthRoute(deps) {
|
|
|
1262
1409
|
}
|
|
1263
1410
|
}), validate("json", passwordSchema), (c) => {
|
|
1264
1411
|
const body = c.req.valid("json");
|
|
1412
|
+
const identity = requestIdentity(c, deps.auth);
|
|
1265
1413
|
const hadPassword = deps.auth.passwordSet;
|
|
1266
|
-
const authenticated = deps.auth.validate(deps.auth.tokenFromCookie(c.req.header("cookie"))) !== null;
|
|
1267
1414
|
const firstSetup = !hadPassword && isLoopbackRequest(c);
|
|
1268
|
-
if (!authenticated && !firstSetup) throw new DetailedError("authentication required", {
|
|
1415
|
+
if (!identity.authenticated && !firstSetup) throw new DetailedError("authentication required", {
|
|
1269
1416
|
statusCode: 401,
|
|
1270
1417
|
code: AUTH_REQUIRED_CODE
|
|
1271
1418
|
});
|
|
1272
|
-
if (authenticated && hadPassword) {
|
|
1419
|
+
if (identity.authenticated && hadPassword) {
|
|
1273
1420
|
if (body.currentPassword === void 0) throw new DetailedError("currentPassword is required to change an existing password", {
|
|
1274
1421
|
statusCode: 400,
|
|
1275
1422
|
code: "CURRENT_PASSWORD_REQUIRED"
|
|
@@ -1279,7 +1426,7 @@ function createAuthRoute(deps) {
|
|
|
1279
1426
|
code: "CURRENT_PASSWORD_WRONG"
|
|
1280
1427
|
});
|
|
1281
1428
|
}
|
|
1282
|
-
deps.auth.setPassword(body.newPassword, { keepToken:
|
|
1429
|
+
deps.auth.setPassword(body.newPassword, { keepToken: identity.session?.token ?? null });
|
|
1283
1430
|
let enabled = deps.store.config.control.auth.enabled;
|
|
1284
1431
|
if (!enabled) {
|
|
1285
1432
|
deps.store.updateControl({ auth: { enabled: true } });
|
|
@@ -1299,7 +1446,7 @@ function createAuthRoute(deps) {
|
|
|
1299
1446
|
401: ERROR_RESPONSES[401]
|
|
1300
1447
|
}
|
|
1301
1448
|
}), (c) => {
|
|
1302
|
-
if (
|
|
1449
|
+
if (!requestIdentity(c, deps.auth).authenticated) throw new DetailedError("authentication required", {
|
|
1303
1450
|
statusCode: 401,
|
|
1304
1451
|
code: AUTH_REQUIRED_CODE
|
|
1305
1452
|
});
|
|
@@ -1432,6 +1579,7 @@ function buildControlView(store, auth, control, tls) {
|
|
|
1432
1579
|
enabled: config.auth.enabled,
|
|
1433
1580
|
passwordSet: auth.passwordSet,
|
|
1434
1581
|
passwordUpdatedAt: auth.passwordUpdatedAt,
|
|
1582
|
+
apiTokenSet: auth.apiTokenSet,
|
|
1435
1583
|
usingDefaultPassword: auth.usingDefaultPassword,
|
|
1436
1584
|
exposed: exposure.exposed,
|
|
1437
1585
|
blockedReason: exposure.blockedReason,
|
|
@@ -1800,7 +1948,7 @@ function createHealthRoute(deps) {
|
|
|
1800
1948
|
}), (c) => {
|
|
1801
1949
|
const state = deps.supervisor.getState();
|
|
1802
1950
|
const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
1803
|
-
const authenticated =
|
|
1951
|
+
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
1804
1952
|
return c.json({
|
|
1805
1953
|
status: broken.length > 0 ? "degraded" : "ok",
|
|
1806
1954
|
uptimeMs: Math.round(process.uptime() * 1e3),
|
|
@@ -1819,6 +1967,7 @@ function createHealthRoute(deps) {
|
|
|
1819
1967
|
var init_health = __esmMin((() => {
|
|
1820
1968
|
init_factory();
|
|
1821
1969
|
init_openapi$1();
|
|
1970
|
+
init_auth();
|
|
1822
1971
|
}));
|
|
1823
1972
|
//#endregion
|
|
1824
1973
|
//#region src/api/logs.ts
|
|
@@ -2137,12 +2286,49 @@ var init_notifications$1 = __esmMin((() => {
|
|
|
2137
2286
|
init_contracts();
|
|
2138
2287
|
}));
|
|
2139
2288
|
//#endregion
|
|
2289
|
+
//#region src/config/migrations.ts
|
|
2290
|
+
var migrations_exports = /* @__PURE__ */ __exportAll({
|
|
2291
|
+
CONFIG_SCHEMA: () => 1,
|
|
2292
|
+
applyConfigMigrations: () => applyConfigMigrations,
|
|
2293
|
+
configMigrations: () => configMigrations,
|
|
2294
|
+
planConfigMigrations: () => planConfigMigrations
|
|
2295
|
+
});
|
|
2296
|
+
/** What would have to run to bring `from` up to `to`. */
|
|
2297
|
+
function planConfigMigrations(from, options = {}) {
|
|
2298
|
+
const to = options.to ?? 1;
|
|
2299
|
+
return {
|
|
2300
|
+
from,
|
|
2301
|
+
to,
|
|
2302
|
+
steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
|
|
2303
|
+
tooNew: from > to
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
/** Applies the plan in order; the caller owns writing the result. */
|
|
2307
|
+
function applyConfigMigrations(config, from, options = {}) {
|
|
2308
|
+
const { steps } = planConfigMigrations(from, options);
|
|
2309
|
+
let current = config;
|
|
2310
|
+
for (const step of steps) current = step.apply(current);
|
|
2311
|
+
return {
|
|
2312
|
+
config: current,
|
|
2313
|
+
applied: steps
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
var configMigrations;
|
|
2317
|
+
var init_migrations = __esmMin((() => {
|
|
2318
|
+
configMigrations = [];
|
|
2319
|
+
}));
|
|
2320
|
+
//#endregion
|
|
2140
2321
|
//#region src/config/schema.ts
|
|
2141
|
-
var configSchema;
|
|
2322
|
+
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2142
2323
|
var init_schema = __esmMin((() => {
|
|
2143
2324
|
init_contracts();
|
|
2325
|
+
metaSchema = type({
|
|
2326
|
+
writtenBy: "string = \"\"",
|
|
2327
|
+
schema: "number.integer >= 1 = 1"
|
|
2328
|
+
}).onUndeclaredKey("reject");
|
|
2144
2329
|
configSchema = type({
|
|
2145
2330
|
$schema: "string?",
|
|
2331
|
+
meta: metaSchema.optional(),
|
|
2146
2332
|
control: controlSchema.default(() => ({})),
|
|
2147
2333
|
defaults: defaultsSchema.default(() => ({})),
|
|
2148
2334
|
logs: logsSchema.default(() => ({})),
|
|
@@ -2151,6 +2337,240 @@ var init_schema = __esmMin((() => {
|
|
|
2151
2337
|
backups: backupsSchema.default(() => ({})),
|
|
2152
2338
|
servers: serverSchema.array().default(() => [])
|
|
2153
2339
|
}).onUndeclaredKey("reject");
|
|
2340
|
+
CONFIG_KEYS = [
|
|
2341
|
+
"$schema",
|
|
2342
|
+
"meta",
|
|
2343
|
+
"control",
|
|
2344
|
+
"defaults",
|
|
2345
|
+
"logs",
|
|
2346
|
+
"notifications",
|
|
2347
|
+
"host",
|
|
2348
|
+
"backups",
|
|
2349
|
+
"servers"
|
|
2350
|
+
];
|
|
2351
|
+
}));
|
|
2352
|
+
//#endregion
|
|
2353
|
+
//#region src/helpers/version.ts
|
|
2354
|
+
var version_exports = /* @__PURE__ */ __exportAll({ appVersion: () => appVersion });
|
|
2355
|
+
/**
|
|
2356
|
+
* The running release, read from the package manifest.
|
|
2357
|
+
*
|
|
2358
|
+
* The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
|
|
2359
|
+
* is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
|
|
2360
|
+
* its own read because it may only import node builtins statically.
|
|
2361
|
+
*/
|
|
2362
|
+
function appVersion() {
|
|
2363
|
+
if (cached !== null) return cached;
|
|
2364
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
2365
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
2366
|
+
try {
|
|
2367
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
2368
|
+
if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
|
|
2369
|
+
cached = manifest.version;
|
|
2370
|
+
return cached;
|
|
2371
|
+
}
|
|
2372
|
+
} catch {}
|
|
2373
|
+
const parent = path.dirname(dir);
|
|
2374
|
+
if (parent === dir) break;
|
|
2375
|
+
dir = parent;
|
|
2376
|
+
}
|
|
2377
|
+
cached = "0.0.0";
|
|
2378
|
+
return cached;
|
|
2379
|
+
}
|
|
2380
|
+
var cached;
|
|
2381
|
+
var init_version = __esmMin((() => {
|
|
2382
|
+
cached = null;
|
|
2383
|
+
}));
|
|
2384
|
+
//#endregion
|
|
2385
|
+
//#region src/config/parse.ts
|
|
2386
|
+
var parse_exports = /* @__PURE__ */ __exportAll({
|
|
2387
|
+
parseConfig: () => parseConfig,
|
|
2388
|
+
stampConfig: () => stampConfig
|
|
2389
|
+
});
|
|
2390
|
+
function isRecord$1(value) {
|
|
2391
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2392
|
+
}
|
|
2393
|
+
function deleteAtPath(root, path) {
|
|
2394
|
+
let node = root;
|
|
2395
|
+
for (const key of path.slice(0, -1)) {
|
|
2396
|
+
node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
|
|
2397
|
+
if (node === void 0) return;
|
|
2398
|
+
}
|
|
2399
|
+
const last = path[path.length - 1];
|
|
2400
|
+
if (last === void 0) return;
|
|
2401
|
+
if (isRecord$1(node)) delete node[String(last)];
|
|
2402
|
+
else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Validates one object against one schema, tolerating keys the schema does not
|
|
2406
|
+
* know: a config written by a newer release has to keep working here, and losing
|
|
2407
|
+
* the *whole group* to schema defaults would silently reset the listener port,
|
|
2408
|
+
* the bind and the auth policy over a single unrecognized key.
|
|
2409
|
+
*
|
|
2410
|
+
* Only unrecognized keys are dropped, and each one is reported. Anything else is
|
|
2411
|
+
* a real problem, returned for the caller to refuse on.
|
|
2412
|
+
*/
|
|
2413
|
+
function parseTolerant(value, schema, prefix, unknownKeys) {
|
|
2414
|
+
const candidate = structuredClone(value);
|
|
2415
|
+
for (let pass = 0; pass < 25; pass++) {
|
|
2416
|
+
const parsed = schema(candidate);
|
|
2417
|
+
if (!(parsed instanceof type.errors)) return {
|
|
2418
|
+
value: parsed,
|
|
2419
|
+
error: null
|
|
2420
|
+
};
|
|
2421
|
+
const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
|
|
2422
|
+
if (removable.length === 0) return {
|
|
2423
|
+
value: null,
|
|
2424
|
+
error: parsed.summary
|
|
2425
|
+
};
|
|
2426
|
+
if (!isRecord$1(candidate)) return {
|
|
2427
|
+
value: null,
|
|
2428
|
+
error: parsed.summary
|
|
2429
|
+
};
|
|
2430
|
+
for (const problem of removable) {
|
|
2431
|
+
const at = `${prefix}.${problem.path.join(".")}`;
|
|
2432
|
+
if (!unknownKeys.includes(at)) unknownKeys.push(at);
|
|
2433
|
+
deleteAtPath(candidate, problem.path);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
return {
|
|
2437
|
+
value: null,
|
|
2438
|
+
error: "too many unrecognized keys to ignore"
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Reads a `servers.config.json` from any release. Unrecognized keys are reported
|
|
2443
|
+
* and left on disk; blocking problems — including a file this release cannot
|
|
2444
|
+
* understand — land in `errors` and leave `config` null.
|
|
2445
|
+
*/
|
|
2446
|
+
function parseConfig(raw, options = {}) {
|
|
2447
|
+
const unknownKeys = [];
|
|
2448
|
+
const errors = [];
|
|
2449
|
+
const warnings = [];
|
|
2450
|
+
const result = {
|
|
2451
|
+
config: null,
|
|
2452
|
+
errors,
|
|
2453
|
+
unknownKeys,
|
|
2454
|
+
warnings,
|
|
2455
|
+
schemaVersion: 1,
|
|
2456
|
+
writtenBy: null
|
|
2457
|
+
};
|
|
2458
|
+
if (!isRecord$1(raw)) {
|
|
2459
|
+
errors.push("the config must contain a JSON object");
|
|
2460
|
+
return result;
|
|
2461
|
+
}
|
|
2462
|
+
const meta = isRecord$1(raw.meta) ? raw.meta : null;
|
|
2463
|
+
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
2464
|
+
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
2465
|
+
result.schemaVersion = schemaVersion;
|
|
2466
|
+
result.writtenBy = writtenBy;
|
|
2467
|
+
if (schemaVersion > 1) {
|
|
2468
|
+
errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
|
|
2469
|
+
return result;
|
|
2470
|
+
}
|
|
2471
|
+
const { steps } = planConfigMigrations(schemaVersion, options);
|
|
2472
|
+
if (steps.length > 0) {
|
|
2473
|
+
errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
|
|
2474
|
+
return result;
|
|
2475
|
+
}
|
|
2476
|
+
for (const key of Object.keys(raw)) if (!CONFIG_KEYS.includes(key)) unknownKeys.push(key);
|
|
2477
|
+
const groups = {};
|
|
2478
|
+
for (const [name, schema] of GROUPS) {
|
|
2479
|
+
const parsed = parseTolerant(raw[name] ?? {}, schema, name, unknownKeys);
|
|
2480
|
+
if (parsed.error !== null) errors.push(`${name}: ${parsed.error}`);
|
|
2481
|
+
groups[name] = parsed.value ?? schema({});
|
|
2482
|
+
}
|
|
2483
|
+
const defaults = groups.defaults;
|
|
2484
|
+
const servers = [];
|
|
2485
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2486
|
+
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
2487
|
+
const label = `servers[${index}]`;
|
|
2488
|
+
const parsed = parseTolerant(isRecord$1(entry) ? {
|
|
2489
|
+
...defaults,
|
|
2490
|
+
...entry
|
|
2491
|
+
} : entry, serverSchema, label, unknownKeys);
|
|
2492
|
+
if (parsed.error !== null) {
|
|
2493
|
+
const id = isRecord$1(entry) ? entry.id : void 0;
|
|
2494
|
+
errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const server = parsed.value;
|
|
2498
|
+
if (seen.has(server.id)) {
|
|
2499
|
+
errors.push(`${label}: duplicate id "${server.id}"`);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
seen.add(server.id);
|
|
2503
|
+
servers.push({
|
|
2504
|
+
...server,
|
|
2505
|
+
port: server.port ?? null
|
|
2506
|
+
});
|
|
2507
|
+
});
|
|
2508
|
+
warnings.push(...validateDependencies(servers));
|
|
2509
|
+
if (errors.length > 0) return result;
|
|
2510
|
+
result.config = {
|
|
2511
|
+
meta: {
|
|
2512
|
+
writtenBy: writtenBy ?? "",
|
|
2513
|
+
schema: schemaVersion
|
|
2514
|
+
},
|
|
2515
|
+
control: groups.control,
|
|
2516
|
+
defaults,
|
|
2517
|
+
logs: groups.logs,
|
|
2518
|
+
notifications: groups.notifications,
|
|
2519
|
+
host: groups.host,
|
|
2520
|
+
backups: groups.backups,
|
|
2521
|
+
servers
|
|
2522
|
+
};
|
|
2523
|
+
return result;
|
|
2524
|
+
}
|
|
2525
|
+
/** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
|
|
2526
|
+
function validateDependencies(servers) {
|
|
2527
|
+
const ids = new Set(servers.map((server) => server.id));
|
|
2528
|
+
const errors = [];
|
|
2529
|
+
for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
2530
|
+
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
2531
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2532
|
+
const settled = /* @__PURE__ */ new Set();
|
|
2533
|
+
const byId = new Map(servers.map((server) => [server.id, server]));
|
|
2534
|
+
const walk = (id) => {
|
|
2535
|
+
if (settled.has(id)) return;
|
|
2536
|
+
if (visiting.has(id)) {
|
|
2537
|
+
errors.push(`dependency cycle through "${id}"`);
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
visiting.add(id);
|
|
2541
|
+
for (const dependency of byId.get(id)?.dependsOn ?? []) walk(dependency);
|
|
2542
|
+
visiting.delete(id);
|
|
2543
|
+
settled.add(id);
|
|
2544
|
+
};
|
|
2545
|
+
for (const server of servers) walk(server.id);
|
|
2546
|
+
return [...new Set(errors)];
|
|
2547
|
+
}
|
|
2548
|
+
/** Adds the stamp every write carries, so the next release can tell what wrote the file. */
|
|
2549
|
+
function stampConfig(draft) {
|
|
2550
|
+
const { $schema, meta: _meta, ...rest } = draft;
|
|
2551
|
+
return {
|
|
2552
|
+
...$schema === void 0 ? {} : { $schema },
|
|
2553
|
+
meta: {
|
|
2554
|
+
writtenBy: appVersion(),
|
|
2555
|
+
schema: 1
|
|
2556
|
+
},
|
|
2557
|
+
...rest
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
var GROUPS, UNDECLARED;
|
|
2561
|
+
var init_parse = __esmMin((() => {
|
|
2562
|
+
init_migrations();
|
|
2563
|
+
init_schema();
|
|
2564
|
+
init_version();
|
|
2565
|
+
GROUPS = [
|
|
2566
|
+
["control", controlSchema],
|
|
2567
|
+
["defaults", defaultsSchema],
|
|
2568
|
+
["logs", logsSchema],
|
|
2569
|
+
["notifications", notificationsSchema],
|
|
2570
|
+
["host", hostSchema],
|
|
2571
|
+
["backups", backupsSchema]
|
|
2572
|
+
];
|
|
2573
|
+
UNDECLARED = "must be removed";
|
|
2154
2574
|
}));
|
|
2155
2575
|
//#endregion
|
|
2156
2576
|
//#region src/config/seed.ts
|
|
@@ -2180,30 +2600,6 @@ function formatErrors(errors) {
|
|
|
2180
2600
|
function isRecord(value) {
|
|
2181
2601
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2182
2602
|
}
|
|
2183
|
-
/** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
|
|
2184
|
-
function validateDependencies(servers) {
|
|
2185
|
-
const ids = new Set(servers.map((server) => server.id));
|
|
2186
|
-
const errors = [];
|
|
2187
|
-
for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
2188
|
-
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
2189
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
2190
|
-
const done = /* @__PURE__ */ new Set();
|
|
2191
|
-
const byId = new Map(servers.map((server) => [server.id, server]));
|
|
2192
|
-
const walk = (id, trail) => {
|
|
2193
|
-
if (done.has(id)) return;
|
|
2194
|
-
if (visiting.has(id)) {
|
|
2195
|
-
errors.push(`dependency cycle: ${[...trail, id].join(" -> ")}`);
|
|
2196
|
-
return;
|
|
2197
|
-
}
|
|
2198
|
-
visiting.add(id);
|
|
2199
|
-
for (const dependency of byId.get(id)?.dependsOn ?? []) if (ids.has(dependency) && dependency !== id) walk(dependency, [...trail, id]);
|
|
2200
|
-
visiting.delete(id);
|
|
2201
|
-
done.add(id);
|
|
2202
|
-
};
|
|
2203
|
-
for (const server of servers) walk(server.id, []);
|
|
2204
|
-
return errors;
|
|
2205
|
-
}
|
|
2206
|
-
/** Nested groups merge so a partial edit never drops a sibling field. */
|
|
2207
2603
|
function applyPatch(target, patch, mergeKeys) {
|
|
2208
2604
|
for (const [key, value] of Object.entries(patch)) {
|
|
2209
2605
|
if (value === void 0) continue;
|
|
@@ -2238,6 +2634,8 @@ function mergeGroup(target, patch) {
|
|
|
2238
2634
|
}
|
|
2239
2635
|
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, EMPTY_MERGE_KEYS, ConfigError, ConfigStore;
|
|
2240
2636
|
var init_store = __esmMin((() => {
|
|
2637
|
+
init_migrations();
|
|
2638
|
+
init_parse();
|
|
2241
2639
|
init_schema();
|
|
2242
2640
|
init_seed();
|
|
2243
2641
|
init_atomic();
|
|
@@ -2259,6 +2657,8 @@ var init_store = __esmMin((() => {
|
|
|
2259
2657
|
raw = {};
|
|
2260
2658
|
resolvedConfig;
|
|
2261
2659
|
error = null;
|
|
2660
|
+
warnings = [];
|
|
2661
|
+
schemaVersion = 1;
|
|
2262
2662
|
listeners = /* @__PURE__ */ new Set();
|
|
2263
2663
|
constructor(file, seed = SEED_CONFIG) {
|
|
2264
2664
|
this.file = file;
|
|
@@ -2273,6 +2673,18 @@ var init_store = __esmMin((() => {
|
|
|
2273
2673
|
get configError() {
|
|
2274
2674
|
return this.error;
|
|
2275
2675
|
}
|
|
2676
|
+
/** Keys a newer release wrote that this one ignores; nothing to refuse over. */
|
|
2677
|
+
get configWarnings() {
|
|
2678
|
+
return [...this.warnings];
|
|
2679
|
+
}
|
|
2680
|
+
/** The shape the file declares, as last read. */
|
|
2681
|
+
get configSchemaVersion() {
|
|
2682
|
+
return this.schemaVersion;
|
|
2683
|
+
}
|
|
2684
|
+
/** Steps that would have to run before this release could use the file. */
|
|
2685
|
+
get pendingMigrations() {
|
|
2686
|
+
return planConfigMigrations(this.schemaVersion).steps;
|
|
2687
|
+
}
|
|
2276
2688
|
get servers() {
|
|
2277
2689
|
return this.resolvedConfig.servers;
|
|
2278
2690
|
}
|
|
@@ -2299,7 +2711,7 @@ var init_store = __esmMin((() => {
|
|
|
2299
2711
|
}
|
|
2300
2712
|
read() {
|
|
2301
2713
|
if (!fs.existsSync(this.file)) {
|
|
2302
|
-
const seed = structuredClone(this.seed);
|
|
2714
|
+
const seed = stampConfig(structuredClone(this.seed));
|
|
2303
2715
|
writeFileAtomic(this.file, `${JSON.stringify(seed, null, 2)}\n`);
|
|
2304
2716
|
this.raw = seed;
|
|
2305
2717
|
this.apply(seed);
|
|
@@ -2314,12 +2726,6 @@ var init_store = __esmMin((() => {
|
|
|
2314
2726
|
this.resolvedConfig = this.resolveFallback();
|
|
2315
2727
|
return;
|
|
2316
2728
|
}
|
|
2317
|
-
if (!isRecord(parsed)) {
|
|
2318
|
-
this.error = `${path.basename(this.file)} must contain a JSON object`;
|
|
2319
|
-
this.raw = {};
|
|
2320
|
-
this.resolvedConfig = this.resolveFallback();
|
|
2321
|
-
return;
|
|
2322
|
-
}
|
|
2323
2729
|
this.apply(parsed);
|
|
2324
2730
|
}
|
|
2325
2731
|
notify() {
|
|
@@ -2423,9 +2829,10 @@ var init_store = __esmMin((() => {
|
|
|
2423
2829
|
};
|
|
2424
2830
|
}
|
|
2425
2831
|
commit(draft) {
|
|
2426
|
-
|
|
2427
|
-
this.
|
|
2428
|
-
this.
|
|
2832
|
+
const stamped = stampConfig(draft);
|
|
2833
|
+
writeFileAtomic(this.file, `${JSON.stringify(stamped, null, 2)}\n`);
|
|
2834
|
+
this.raw = stamped;
|
|
2835
|
+
this.apply(stamped);
|
|
2429
2836
|
this.notify();
|
|
2430
2837
|
}
|
|
2431
2838
|
resolveFallback() {
|
|
@@ -2449,53 +2856,12 @@ var init_store = __esmMin((() => {
|
|
|
2449
2856
|
}
|
|
2450
2857
|
apply(raw) {
|
|
2451
2858
|
this.raw = raw;
|
|
2452
|
-
const
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
const backups = backupsSchema(raw.backups ?? {});
|
|
2459
|
-
if (control instanceof type.errors) errors.push(`control: ${formatErrors(control)}`);
|
|
2460
|
-
if (defaults instanceof type.errors) errors.push(`defaults: ${formatErrors(defaults)}`);
|
|
2461
|
-
if (logs instanceof type.errors) errors.push(`logs: ${formatErrors(logs)}`);
|
|
2462
|
-
if (notifications instanceof type.errors) errors.push(`notifications: ${formatErrors(notifications)}`);
|
|
2463
|
-
if (host instanceof type.errors) errors.push(`host: ${formatErrors(host)}`);
|
|
2464
|
-
if (backups instanceof type.errors) errors.push(`backups: ${formatErrors(backups)}`);
|
|
2465
|
-
const resolvedDefaults = defaults instanceof type.errors ? defaultsSchema({}) : defaults;
|
|
2466
|
-
const servers = [];
|
|
2467
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2468
|
-
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
2469
|
-
const parsed = serverSchema({
|
|
2470
|
-
...resolvedDefaults,
|
|
2471
|
-
...entry
|
|
2472
|
-
});
|
|
2473
|
-
if (parsed instanceof type.errors) {
|
|
2474
|
-
errors.push(`servers[${index}] (${entry?.id ?? "no id"}): ${formatErrors(parsed)}`);
|
|
2475
|
-
return;
|
|
2476
|
-
}
|
|
2477
|
-
if (seen.has(parsed.id)) {
|
|
2478
|
-
errors.push(`servers[${index}]: duplicate id "${parsed.id}"`);
|
|
2479
|
-
return;
|
|
2480
|
-
}
|
|
2481
|
-
seen.add(parsed.id);
|
|
2482
|
-
servers.push({
|
|
2483
|
-
...parsed,
|
|
2484
|
-
port: parsed.port ?? null
|
|
2485
|
-
});
|
|
2486
|
-
});
|
|
2487
|
-
errors.push(...validateDependencies(servers));
|
|
2488
|
-
this.error = errors.length > 0 ? errors.join("; ") : null;
|
|
2489
|
-
this.resolvedConfig = {
|
|
2490
|
-
$schema: raw.$schema,
|
|
2491
|
-
control: control instanceof type.errors ? controlSchema({}) : control,
|
|
2492
|
-
defaults: resolvedDefaults,
|
|
2493
|
-
logs: logs instanceof type.errors ? logsSchema({}) : logs,
|
|
2494
|
-
notifications: notifications instanceof type.errors ? notificationsSchema({}) : notifications,
|
|
2495
|
-
host: host instanceof type.errors ? hostSchema({}) : host,
|
|
2496
|
-
backups: backups instanceof type.errors ? backupsSchema({}) : backups,
|
|
2497
|
-
servers
|
|
2498
|
-
};
|
|
2859
|
+
const parsed = parseConfig(raw);
|
|
2860
|
+
this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
|
|
2861
|
+
this.schemaVersion = parsed.schemaVersion;
|
|
2862
|
+
this.warnings = [...parsed.warnings, ...parsed.unknownKeys.length === 0 ? [] : [`${path.basename(this.file)} carries ${parsed.unknownKeys.length} unrecognized key(s) this release ignores: ${parsed.unknownKeys.join(", ")}`]];
|
|
2863
|
+
if (parsed.config !== null) this.resolvedConfig = parsed.config;
|
|
2864
|
+
else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
|
|
2499
2865
|
}
|
|
2500
2866
|
};
|
|
2501
2867
|
}));
|
|
@@ -2681,6 +3047,25 @@ function createServersRoute(deps) {
|
|
|
2681
3047
|
}), validate("param", idParam), (c) => {
|
|
2682
3048
|
deps.supervisor.clearLogs(c.req.valid("param").id);
|
|
2683
3049
|
return c.json({ ok: true });
|
|
3050
|
+
}).post("/:id/free-port", describeRoute({
|
|
3051
|
+
tags: ["servers"],
|
|
3052
|
+
summary: "Ask whatever holds this server's port to stop",
|
|
3053
|
+
responses: {
|
|
3054
|
+
200: {
|
|
3055
|
+
description: "What was signalled",
|
|
3056
|
+
content: jsonBody(freePortResultSchema)
|
|
3057
|
+
},
|
|
3058
|
+
404: ERROR_RESPONSES[404],
|
|
3059
|
+
409: { description: "Nothing to free, or the holder is supervised by this panel" }
|
|
3060
|
+
}
|
|
3061
|
+
}), validate("param", idParam), async (c) => {
|
|
3062
|
+
const { id } = c.req.valid("param");
|
|
3063
|
+
const result = await deps.supervisor.freePort(id);
|
|
3064
|
+
if (!result.ok) throw new DetailedError(result.error ?? `could not free the port for "${id}"`, {
|
|
3065
|
+
statusCode: statusFor(result),
|
|
3066
|
+
code: "FREE_PORT_FAILED"
|
|
3067
|
+
});
|
|
3068
|
+
return c.json(result);
|
|
2684
3069
|
}).patch("/:id", describeRoute({
|
|
2685
3070
|
tags: ["servers"],
|
|
2686
3071
|
summary: "Edit a server",
|
|
@@ -3197,7 +3582,7 @@ var init_app = __esmMin((() => {
|
|
|
3197
3582
|
//#region src/helpers/daemon.ts
|
|
3198
3583
|
var daemon_exports = /* @__PURE__ */ __exportAll({
|
|
3199
3584
|
clearRuntime: () => clearRuntime,
|
|
3200
|
-
isProcessAlive: () => isProcessAlive,
|
|
3585
|
+
isProcessAlive: () => isProcessAlive$1,
|
|
3201
3586
|
newToken: () => newToken,
|
|
3202
3587
|
probeRuntime: () => probeRuntime,
|
|
3203
3588
|
readRuntime: () => readRuntime,
|
|
@@ -3223,7 +3608,7 @@ function newToken() {
|
|
|
3223
3608
|
return randomBytes(32).toString("base64url");
|
|
3224
3609
|
}
|
|
3225
3610
|
/** Signal 0 only probes the pid; `EPERM` still means the process is there. */
|
|
3226
|
-
function isProcessAlive(pid) {
|
|
3611
|
+
function isProcessAlive$1(pid) {
|
|
3227
3612
|
try {
|
|
3228
3613
|
process.kill(pid, 0);
|
|
3229
3614
|
return true;
|
|
@@ -3361,6 +3746,46 @@ async function killPortHolders(port) {
|
|
|
3361
3746
|
} catch {}
|
|
3362
3747
|
return pids;
|
|
3363
3748
|
}
|
|
3749
|
+
/** Signal 0 asks the OS whether the pid still exists, without touching it. */
|
|
3750
|
+
function isProcessAlive(pid) {
|
|
3751
|
+
try {
|
|
3752
|
+
process.kill(pid, 0);
|
|
3753
|
+
return true;
|
|
3754
|
+
} catch {
|
|
3755
|
+
return false;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
function signalPid$1(pid, name) {
|
|
3759
|
+
try {
|
|
3760
|
+
process.kill(pid, name);
|
|
3761
|
+
} catch {}
|
|
3762
|
+
}
|
|
3763
|
+
function delay$3(ms) {
|
|
3764
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3765
|
+
}
|
|
3766
|
+
/**
|
|
3767
|
+
* Asks specific pids to leave, politely first: a stray dev server stops on
|
|
3768
|
+
* SIGTERM, and only what ignores it is killed. Deciding *which* pids may be
|
|
3769
|
+
* touched belongs to the caller — this only does the signalling.
|
|
3770
|
+
*/
|
|
3771
|
+
async function terminatePids(pids, options = {}) {
|
|
3772
|
+
const graceMs = options.graceMs ?? 3e3;
|
|
3773
|
+
const aliveBefore = [...new Set(pids)].filter(isProcessAlive);
|
|
3774
|
+
for (const pid of aliveBefore) signalPid$1(pid, "SIGTERM");
|
|
3775
|
+
const deadline = Date.now() + graceMs;
|
|
3776
|
+
let alive = aliveBefore.filter(isProcessAlive);
|
|
3777
|
+
while (alive.length > 0 && Date.now() < deadline) {
|
|
3778
|
+
await delay$3(100);
|
|
3779
|
+
alive = alive.filter(isProcessAlive);
|
|
3780
|
+
}
|
|
3781
|
+
const stopped = aliveBefore.filter((pid) => !alive.includes(pid));
|
|
3782
|
+
for (const pid of alive) signalPid$1(pid, "SIGKILL");
|
|
3783
|
+
if (alive.length > 0 && graceMs > 0) await delay$3(150);
|
|
3784
|
+
return {
|
|
3785
|
+
stopped,
|
|
3786
|
+
forced: alive
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
3364
3789
|
/** `netstat -ano` lines: ` TCP 127.0.0.1:4010 0.0.0.0:0 LISTENING 1234` */
|
|
3365
3790
|
function parseNetstatListeners(output, port) {
|
|
3366
3791
|
const pids = /* @__PURE__ */ new Set();
|
|
@@ -3880,6 +4305,39 @@ function collectTree(rootPid, children) {
|
|
|
3880
4305
|
}
|
|
3881
4306
|
return pids;
|
|
3882
4307
|
}
|
|
4308
|
+
/**
|
|
4309
|
+
* Does this process carry the environment the supervisor gave the entry?
|
|
4310
|
+
*
|
|
4311
|
+
* A program that restarts itself — especially a plugin doing it — leaves behind a
|
|
4312
|
+
* detached process that still inherits `HHOSTED_SERVER_ID`, and that marker is what
|
|
4313
|
+
* tells a legitimate successor apart from a stranger squatting on the port.
|
|
4314
|
+
*
|
|
4315
|
+
* Linux reads `/proc`; macOS asks `ps -E`; Windows has no per-process environment,
|
|
4316
|
+
* so the answer is always "no" there and ownership falls back to the port policy.
|
|
4317
|
+
*/
|
|
4318
|
+
async function processCarriesServerId(pid, serverId) {
|
|
4319
|
+
if (serverId.length === 0) return false;
|
|
4320
|
+
const needle = `HHOSTED_SERVER_ID=${serverId}`;
|
|
4321
|
+
if (process.platform === "linux") try {
|
|
4322
|
+
return (await fs.promises.readFile(`/proc/${pid}/environ`, "utf8")).split("\0").includes(needle);
|
|
4323
|
+
} catch {
|
|
4324
|
+
return false;
|
|
4325
|
+
}
|
|
4326
|
+
if (process.platform === "darwin") try {
|
|
4327
|
+
const { stdout } = await execFileAsync$2("ps", [
|
|
4328
|
+
"-p",
|
|
4329
|
+
String(pid),
|
|
4330
|
+
"-E",
|
|
4331
|
+
"-ww",
|
|
4332
|
+
"-o",
|
|
4333
|
+
"command="
|
|
4334
|
+
], { timeout: 3e3 });
|
|
4335
|
+
return new RegExp(`(?:^|\\s)${needle}(?:\\s|$)`).test(stdout);
|
|
4336
|
+
} catch {
|
|
4337
|
+
return false;
|
|
4338
|
+
}
|
|
4339
|
+
return false;
|
|
4340
|
+
}
|
|
3883
4341
|
var execFileAsync$2, clockTicks, ProcessSampler;
|
|
3884
4342
|
var init_proc = __esmMin((() => {
|
|
3885
4343
|
execFileAsync$2 = promisify(execFile);
|
|
@@ -4016,6 +4474,9 @@ async function killTreeWindows(pid) {
|
|
|
4016
4474
|
function signalChild(child, signal, killGroup) {
|
|
4017
4475
|
const pid = child.pid;
|
|
4018
4476
|
if (pid === void 0) return;
|
|
4477
|
+
signalPid(pid, signal, killGroup);
|
|
4478
|
+
}
|
|
4479
|
+
function signalPid(pid, signal, killGroup) {
|
|
4019
4480
|
if (process.platform === "win32") {
|
|
4020
4481
|
if (killGroup) killTreeWindows(pid);
|
|
4021
4482
|
else try {
|
|
@@ -4031,6 +4492,33 @@ function signalChild(child, signal, killGroup) {
|
|
|
4031
4492
|
process.kill(pid, signal);
|
|
4032
4493
|
} catch {}
|
|
4033
4494
|
}
|
|
4495
|
+
function alive(pid) {
|
|
4496
|
+
try {
|
|
4497
|
+
process.kill(pid, 0);
|
|
4498
|
+
return true;
|
|
4499
|
+
} catch {
|
|
4500
|
+
return false;
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
/**
|
|
4504
|
+
* The same shutdown a supervised child gets, for a process we adopted instead of
|
|
4505
|
+
* spawned: a detached successor is not our child, so it is signalled by pid (and
|
|
4506
|
+
* by process group when asked) and its exit is polled rather than awaited.
|
|
4507
|
+
*/
|
|
4508
|
+
async function terminatePid(pid, options) {
|
|
4509
|
+
if (!alive(pid)) return "exited";
|
|
4510
|
+
signalPid(pid, options.signal, options.killGroup);
|
|
4511
|
+
const deadline = Date.now() + Math.max(0, options.graceMs);
|
|
4512
|
+
while (Date.now() < deadline && alive(pid)) await delay$2(50);
|
|
4513
|
+
if (!alive(pid)) return "exited";
|
|
4514
|
+
signalPid(pid, "SIGKILL", options.killGroup);
|
|
4515
|
+
const hardDeadline = Date.now() + 2e3;
|
|
4516
|
+
while (Date.now() < hardDeadline && alive(pid)) await delay$2(50);
|
|
4517
|
+
return "force-killed";
|
|
4518
|
+
}
|
|
4519
|
+
function delay$2(ms) {
|
|
4520
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4521
|
+
}
|
|
4034
4522
|
function waitForExit$1(child, timeoutMs) {
|
|
4035
4523
|
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
|
|
4036
4524
|
if (timeoutMs <= 0) return Promise.resolve(false);
|
|
@@ -4259,10 +4747,11 @@ var init_supervisor = __esmMin((() => {
|
|
|
4259
4747
|
error: "supervisor is shutting down"
|
|
4260
4748
|
};
|
|
4261
4749
|
const conflict = await this.preflight(entry);
|
|
4262
|
-
if (conflict
|
|
4750
|
+
if (conflict.kind === "blocked") return {
|
|
4263
4751
|
ok: false,
|
|
4264
|
-
error: conflict
|
|
4752
|
+
error: conflict.error
|
|
4265
4753
|
};
|
|
4754
|
+
if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid);
|
|
4266
4755
|
return this.spawnEntry(entry);
|
|
4267
4756
|
} finally {
|
|
4268
4757
|
entry.starting = false;
|
|
@@ -4280,6 +4769,97 @@ var init_supervisor = __esmMin((() => {
|
|
|
4280
4769
|
await this.stop(id);
|
|
4281
4770
|
return this.start(id);
|
|
4282
4771
|
}
|
|
4772
|
+
/**
|
|
4773
|
+
* Frees the port this entry wants, by asking whatever listens on it to leave.
|
|
4774
|
+
*
|
|
4775
|
+
* The pid is never taken from a message: holders are listed again here, and a
|
|
4776
|
+
* listener this panel supervises is refused rather than killed — a port held by
|
|
4777
|
+
* a sibling entry is a configuration mistake, not a stray process. That also
|
|
4778
|
+
* keeps a stale `(pid 1234)` in an old banner from killing a recycled pid.
|
|
4779
|
+
*/
|
|
4780
|
+
async freePort(id) {
|
|
4781
|
+
const entry = this.entries.get(id);
|
|
4782
|
+
const empty = {
|
|
4783
|
+
ok: false,
|
|
4784
|
+
port: null,
|
|
4785
|
+
terminated: [],
|
|
4786
|
+
forced: [],
|
|
4787
|
+
skipped: [],
|
|
4788
|
+
free: false
|
|
4789
|
+
};
|
|
4790
|
+
if (!entry) return {
|
|
4791
|
+
...empty,
|
|
4792
|
+
error: `unknown server "${id}"`
|
|
4793
|
+
};
|
|
4794
|
+
const port = entry.config.port;
|
|
4795
|
+
if (port === null) return {
|
|
4796
|
+
...empty,
|
|
4797
|
+
error: `server "${id}" has no port configured`
|
|
4798
|
+
};
|
|
4799
|
+
if (entry.starting || entry.stopping) return {
|
|
4800
|
+
...empty,
|
|
4801
|
+
port,
|
|
4802
|
+
error: `server "${id}" is busy — try again in a moment`
|
|
4803
|
+
};
|
|
4804
|
+
const supervised = this.supervisedPids();
|
|
4805
|
+
const holders = await listPortHolders(port);
|
|
4806
|
+
const ours = holders.filter((pid) => supervised.has(pid));
|
|
4807
|
+
const foreign = holders.filter((pid) => !supervised.has(pid));
|
|
4808
|
+
if (holders.length === 0) return {
|
|
4809
|
+
...empty,
|
|
4810
|
+
port,
|
|
4811
|
+
error: `nothing is listening on port ${port} any more`
|
|
4812
|
+
};
|
|
4813
|
+
if (foreign.length === 0) {
|
|
4814
|
+
const reason = `port ${port} is held by pid ${ours.join(", ")}, which this panel supervises — stop that server instead`;
|
|
4815
|
+
return {
|
|
4816
|
+
...empty,
|
|
4817
|
+
port,
|
|
4818
|
+
error: reason,
|
|
4819
|
+
skipped: ours
|
|
4820
|
+
};
|
|
4821
|
+
}
|
|
4822
|
+
this.log(entry, "system", `freeing port ${port}: asking pid ${foreign.join(", ")} to stop`);
|
|
4823
|
+
const { stopped, forced } = await terminatePids(foreign);
|
|
4824
|
+
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
4825
|
+
const free = await this.waitForPortRelease(entry, port);
|
|
4826
|
+
if (free) {
|
|
4827
|
+
if (entry.status === "conflict") {
|
|
4828
|
+
entry.status = "stopped";
|
|
4829
|
+
entry.lastError = null;
|
|
4830
|
+
}
|
|
4831
|
+
entry.portState = "free";
|
|
4832
|
+
this.log(entry, "system", `port ${port} is free${ours.length > 0 ? ` (pid ${ours.join(", ")} left untouched)` : ""}`);
|
|
4833
|
+
this.publishServer(entry);
|
|
4834
|
+
} else {
|
|
4835
|
+
entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
|
|
4836
|
+
this.log(entry, "system", entry.lastError);
|
|
4837
|
+
this.publishServer(entry);
|
|
4838
|
+
}
|
|
4839
|
+
return {
|
|
4840
|
+
ok: true,
|
|
4841
|
+
port,
|
|
4842
|
+
terminated: stopped,
|
|
4843
|
+
forced,
|
|
4844
|
+
skipped: ours,
|
|
4845
|
+
free
|
|
4846
|
+
};
|
|
4847
|
+
}
|
|
4848
|
+
/** Pids of the child processes this panel owns, plus itself. */
|
|
4849
|
+
supervisedPids() {
|
|
4850
|
+
const pids = /* @__PURE__ */ new Set([process.pid]);
|
|
4851
|
+
for (const entry of this.entries.values()) if (entry.pid !== null) pids.add(entry.pid);
|
|
4852
|
+
return pids;
|
|
4853
|
+
}
|
|
4854
|
+
/** The port's own answer, with the same short recheck the preflight uses. */
|
|
4855
|
+
async waitForPortRelease(entry, port) {
|
|
4856
|
+
const freeOnAll = async () => {
|
|
4857
|
+
return (await Promise.all(this.occupancyHosts(entry).map((host) => isPortFree(port, host)))).every(Boolean);
|
|
4858
|
+
};
|
|
4859
|
+
if (await freeOnAll()) return true;
|
|
4860
|
+
await delay$1(PORT_RELEASE_RECHECK_MS);
|
|
4861
|
+
return freeOnAll();
|
|
4862
|
+
}
|
|
4283
4863
|
clearLogs(id) {
|
|
4284
4864
|
const entry = this.entries.get(id);
|
|
4285
4865
|
if (!entry) return;
|
|
@@ -4351,15 +4931,14 @@ var init_supervisor = __esmMin((() => {
|
|
|
4351
4931
|
this.clearRetry(entry);
|
|
4352
4932
|
entry.nextRetryAt = null;
|
|
4353
4933
|
entry.stopping = true;
|
|
4354
|
-
if (entry.child === null) {
|
|
4934
|
+
if (entry.child === null && entry.pid === null) {
|
|
4355
4935
|
entry.status = "stopped";
|
|
4356
|
-
entry.pid = null;
|
|
4357
4936
|
this.publishServer(entry);
|
|
4358
4937
|
return { ok: true };
|
|
4359
4938
|
}
|
|
4360
4939
|
entry.status = "stopping";
|
|
4361
4940
|
this.publishServer(entry);
|
|
4362
|
-
if (await terminate(entry.child, entry.config.stop) === "force-killed") this.log(entry, "system", "force-killed after grace period");
|
|
4941
|
+
if ((entry.child === null && entry.pid !== null ? await terminatePid(entry.pid, entry.config.stop) : await terminate(entry.child, entry.config.stop)) === "force-killed") this.log(entry, "system", "force-killed after grace period");
|
|
4363
4942
|
const { port, stop } = entry.config;
|
|
4364
4943
|
if (stop.killPortHolders && port !== null) {
|
|
4365
4944
|
const leftover = await listPortHolders(port);
|
|
@@ -4371,6 +4950,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4371
4950
|
entry.stopping = false;
|
|
4372
4951
|
entry.child = null;
|
|
4373
4952
|
entry.pid = null;
|
|
4953
|
+
entry.adopted = false;
|
|
4374
4954
|
entry.status = "stopped";
|
|
4375
4955
|
this.log(entry, "system", "stopped");
|
|
4376
4956
|
this.publishServer(entry);
|
|
@@ -4396,6 +4976,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4396
4976
|
lastProbeAt: 0,
|
|
4397
4977
|
lastOccupancyProbeAt: 0,
|
|
4398
4978
|
probing: false,
|
|
4979
|
+
adopted: false,
|
|
4399
4980
|
starting: false,
|
|
4400
4981
|
stopping: false,
|
|
4401
4982
|
bootstrapDone: !config.bootstrap,
|
|
@@ -4450,9 +5031,20 @@ var init_supervisor = __esmMin((() => {
|
|
|
4450
5031
|
async portAccepts(entry, port, timeoutMs) {
|
|
4451
5032
|
return (await Promise.all(this.occupancyHosts(entry).map((host) => probePort(host, port, timeoutMs)))).some(Boolean);
|
|
4452
5033
|
}
|
|
5034
|
+
/**
|
|
5035
|
+
* The port holders that carry this entry's own marker — a program that restarted
|
|
5036
|
+
* itself leaves a detached process behind, and that process is the *same server*,
|
|
5037
|
+
* not a stranger to kill. Read from the environment the supervisor gave the entry,
|
|
5038
|
+
* which a successor inherits unless it scrubs it.
|
|
5039
|
+
*/
|
|
5040
|
+
async ownPortHolders(entry, holders) {
|
|
5041
|
+
const own = [];
|
|
5042
|
+
for (const pid of holders) if (await processCarriesServerId(pid, entry.config.id)) own.push(pid);
|
|
5043
|
+
return own;
|
|
5044
|
+
}
|
|
4453
5045
|
async preflight(entry) {
|
|
4454
5046
|
const port = entry.config.port;
|
|
4455
|
-
if (port === null) return
|
|
5047
|
+
if (port === null) return { kind: "free" };
|
|
4456
5048
|
const hosts = this.occupancyHosts(entry);
|
|
4457
5049
|
const freeOnAll = async () => {
|
|
4458
5050
|
return (await Promise.all(hosts.map((host) => isPortFree(port, host)))).every(Boolean);
|
|
@@ -4463,18 +5055,80 @@ var init_supervisor = __esmMin((() => {
|
|
|
4463
5055
|
free = await freeOnAll();
|
|
4464
5056
|
}
|
|
4465
5057
|
entry.portState = free ? "free" : "in-use";
|
|
4466
|
-
if (free) return
|
|
5058
|
+
if (free) return { kind: "free" };
|
|
4467
5059
|
const holders = await listPortHolders(port);
|
|
5060
|
+
const own = await this.ownPortHolders(entry, holders);
|
|
4468
5061
|
const suffix = holders.length > 0 ? ` (pid ${holders.join(", ")})` : "";
|
|
4469
|
-
if (entry.config.onPortConflict === "
|
|
5062
|
+
if (own.length > 0 && entry.config.onPortConflict === "follow") return {
|
|
5063
|
+
kind: "adopt",
|
|
5064
|
+
pid: own[0]
|
|
5065
|
+
};
|
|
5066
|
+
if (own.length > 0 && entry.config.onPortConflict === "reclaim") {
|
|
5067
|
+
this.log(entry, "system", `port ${port} is held by pid ${own.join(", ")}, a detached restart of this entry — replacing it with a supervised process`);
|
|
5068
|
+
const { forced } = await terminatePids(own);
|
|
5069
|
+
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5070
|
+
await delay$1(PORT_RELEASE_RECHECK_MS);
|
|
5071
|
+
if (await freeOnAll()) {
|
|
5072
|
+
entry.portState = "free";
|
|
5073
|
+
return { kind: "free" };
|
|
5074
|
+
}
|
|
5075
|
+
entry.status = "conflict";
|
|
5076
|
+
entry.lastError = `port ${port} is still in use after replacing pid ${own.join(", ")}`;
|
|
5077
|
+
this.log(entry, "system", entry.lastError);
|
|
5078
|
+
this.publishServer(entry);
|
|
5079
|
+
return {
|
|
5080
|
+
kind: "blocked",
|
|
5081
|
+
error: entry.lastError
|
|
5082
|
+
};
|
|
5083
|
+
}
|
|
5084
|
+
const hint = own.length > 0 ? ` — pid ${own.join(", ")} is a detached restart of this entry: set onPortConflict to "follow" to adopt it, or "reclaim" to replace it with a supervised process` : "";
|
|
5085
|
+
if (entry.config.onPortConflict !== "warn") {
|
|
4470
5086
|
entry.status = "conflict";
|
|
4471
|
-
entry.lastError = `port ${port} is already in use${suffix}`;
|
|
4472
|
-
this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict:
|
|
5087
|
+
entry.lastError = `port ${port} is already in use${suffix}${hint}`;
|
|
5088
|
+
this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict: ${entry.config.onPortConflict})`);
|
|
4473
5089
|
this.publishServer(entry);
|
|
4474
|
-
return
|
|
5090
|
+
return {
|
|
5091
|
+
kind: "blocked",
|
|
5092
|
+
error: entry.lastError
|
|
5093
|
+
};
|
|
4475
5094
|
}
|
|
4476
|
-
this.log(entry, "system", `warning: port ${port} is already in use${suffix} — starting anyway`);
|
|
4477
|
-
return
|
|
5095
|
+
this.log(entry, "system", `warning: port ${port} is already in use${suffix}${hint} — starting anyway`);
|
|
5096
|
+
return { kind: "free" };
|
|
5097
|
+
}
|
|
5098
|
+
/**
|
|
5099
|
+
* Takes over a detached successor: no spawn, no duplicate. The pid is supervised
|
|
5100
|
+
* from here on (liveness, health probe, resources, stop), while its output stays
|
|
5101
|
+
* wherever it was redirected.
|
|
5102
|
+
*/
|
|
5103
|
+
adoptEntry(entry, pid) {
|
|
5104
|
+
entry.adopted = true;
|
|
5105
|
+
entry.pid = pid;
|
|
5106
|
+
entry.child = null;
|
|
5107
|
+
entry.status = "running";
|
|
5108
|
+
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
5109
|
+
entry.startedAt = Date.now();
|
|
5110
|
+
entry.lastError = null;
|
|
5111
|
+
this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
|
|
5112
|
+
this.publishServer(entry);
|
|
5113
|
+
return { ok: true };
|
|
5114
|
+
}
|
|
5115
|
+
/** An adopted successor disappeared: fall back to the normal lifecycle. */
|
|
5116
|
+
handleAdoptedExit(entry) {
|
|
5117
|
+
if (entry.pid !== null) this.sampler.forget(entry.pid);
|
|
5118
|
+
const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
|
|
5119
|
+
entry.adopted = false;
|
|
5120
|
+
entry.pid = null;
|
|
5121
|
+
entry.resources = null;
|
|
5122
|
+
entry.responseMs = null;
|
|
5123
|
+
entry.exitCode = null;
|
|
5124
|
+
entry.exitSignal = null;
|
|
5125
|
+
this.options.history.record(entry.config.id, {
|
|
5126
|
+
type: "exit",
|
|
5127
|
+
detail: "adopted process exited",
|
|
5128
|
+
runtimeMs: ranForMs
|
|
5129
|
+
});
|
|
5130
|
+
this.log(entry, "system", `the adopted process is gone after ${Math.max(1, Math.round(ranForMs / 1e3))}s`);
|
|
5131
|
+
this.afterExit(entry, "adopted process exited", ranForMs, false);
|
|
4478
5132
|
}
|
|
4479
5133
|
async runBootstrap(entry) {
|
|
4480
5134
|
const spec = entry.config.bootstrap;
|
|
@@ -4650,6 +5304,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4650
5304
|
if (entry.pid !== null) this.sampler.forget(entry.pid);
|
|
4651
5305
|
entry.child = null;
|
|
4652
5306
|
entry.pid = null;
|
|
5307
|
+
entry.adopted = false;
|
|
4653
5308
|
entry.resources = null;
|
|
4654
5309
|
entry.responseMs = null;
|
|
4655
5310
|
entry.exitCode = code;
|
|
@@ -4657,12 +5312,19 @@ var init_supervisor = __esmMin((() => {
|
|
|
4657
5312
|
const neverStarted = code === null && signal === null && entry.lastError !== null;
|
|
4658
5313
|
const detail = neverStarted ? entry.lastError : signal !== null ? `signal ${signal}` : `code ${code}`;
|
|
4659
5314
|
const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
|
|
4660
|
-
const ranFor = `${Math.max(1, Math.round(ranForMs / 1e3))}s`;
|
|
4661
5315
|
this.options.history.record(entry.config.id, {
|
|
4662
5316
|
type: "exit",
|
|
4663
5317
|
detail,
|
|
4664
5318
|
runtimeMs: ranForMs
|
|
4665
5319
|
});
|
|
5320
|
+
this.afterExit(entry, detail, ranForMs, neverStarted);
|
|
5321
|
+
}
|
|
5322
|
+
/**
|
|
5323
|
+
* What happens once a process is gone, whether it was a child we spawned or a
|
|
5324
|
+
* detached successor we adopted: back off and retry, or record the crash.
|
|
5325
|
+
*/
|
|
5326
|
+
afterExit(entry, detail, ranForMs, neverStarted) {
|
|
5327
|
+
const ranFor = `${Math.max(1, Math.round(ranForMs / 1e3))}s`;
|
|
4666
5328
|
if (entry.stopping) {
|
|
4667
5329
|
entry.status = "stopped";
|
|
4668
5330
|
this.publishServer(entry);
|
|
@@ -4704,6 +5366,10 @@ var init_supervisor = __esmMin((() => {
|
|
|
4704
5366
|
await this.sampleResources(now);
|
|
4705
5367
|
await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
|
|
4706
5368
|
for (const entry of this.entries.values()) {
|
|
5369
|
+
if (entry.adopted && entry.pid !== null && !isProcessAlive(entry.pid)) {
|
|
5370
|
+
this.handleAdoptedExit(entry);
|
|
5371
|
+
continue;
|
|
5372
|
+
}
|
|
4707
5373
|
if (await this.enforceMemoryLimit(entry)) continue;
|
|
4708
5374
|
if (this.shouldForceRestart(entry, now)) {
|
|
4709
5375
|
await this.restart(entry.config.id);
|
|
@@ -4715,7 +5381,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4715
5381
|
}
|
|
4716
5382
|
/** One scan of /proc covers every server; only live processes are sampled. */
|
|
4717
5383
|
async sampleResources(now) {
|
|
4718
|
-
const due = [...this.entries.values()].filter((entry) => entry.pid !== null && entry.child !== null && now - entry.resourcesSampledAt >= RESOURCE_SAMPLE_INTERVAL_MS);
|
|
5384
|
+
const due = [...this.entries.values()].filter((entry) => entry.pid !== null && (entry.child !== null || entry.adopted) && now - entry.resourcesSampledAt >= RESOURCE_SAMPLE_INTERVAL_MS);
|
|
4719
5385
|
if (due.length === 0) return;
|
|
4720
5386
|
for (const entry of due) entry.resourcesSampledAt = now;
|
|
4721
5387
|
try {
|
|
@@ -4817,6 +5483,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4817
5483
|
health: entry.health,
|
|
4818
5484
|
portState: entry.portState,
|
|
4819
5485
|
pid: entry.pid,
|
|
5486
|
+
...entry.adopted ? { adopted: true } : {},
|
|
4820
5487
|
startedAt: entry.startedAt,
|
|
4821
5488
|
exitCode: entry.exitCode,
|
|
4822
5489
|
exitSignal: entry.exitSignal,
|
|
@@ -4867,7 +5534,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
4867
5534
|
server.restarts,
|
|
4868
5535
|
server.nextRetryAt,
|
|
4869
5536
|
server.lastError,
|
|
4870
|
-
server.bufferedLines
|
|
5537
|
+
server.bufferedLines,
|
|
5538
|
+
server.responseMs,
|
|
5539
|
+
server.resources?.sampledAt
|
|
4871
5540
|
].join(":"))].join("|");
|
|
4872
5541
|
if (signature === this.lastStateSignature) return;
|
|
4873
5542
|
this.lastStateSignature = signature;
|
|
@@ -4957,11 +5626,15 @@ function controlBlock(configText) {
|
|
|
4957
5626
|
return null;
|
|
4958
5627
|
}
|
|
4959
5628
|
}
|
|
4960
|
-
/**
|
|
5629
|
+
/**
|
|
5630
|
+
* A restored config is accepted when this release can read it — through the same
|
|
5631
|
+
* tolerant parser the store uses, so an archive from a newer release keeps only
|
|
5632
|
+
* the keys this one understands instead of being refused outright.
|
|
5633
|
+
*/
|
|
4961
5634
|
function isUsableConfig(text) {
|
|
4962
5635
|
if (text === null) return false;
|
|
4963
5636
|
try {
|
|
4964
|
-
return
|
|
5637
|
+
return parseConfig(JSON.parse(text)).config !== null;
|
|
4965
5638
|
} catch {
|
|
4966
5639
|
return false;
|
|
4967
5640
|
}
|
|
@@ -4974,12 +5647,9 @@ function isUsableConfig(text) {
|
|
|
4974
5647
|
function archiveTargets(configText) {
|
|
4975
5648
|
if (configText === null) return [];
|
|
4976
5649
|
try {
|
|
4977
|
-
const parsed =
|
|
4978
|
-
if (parsed
|
|
4979
|
-
return resolveBackupPaths(parsed.servers.map((
|
|
4980
|
-
...server,
|
|
4981
|
-
port: server.port ?? null
|
|
4982
|
-
})), parsed.backups.includePaths).filter((entry) => entry.included).map((entry) => ({
|
|
5650
|
+
const parsed = parseConfig(JSON.parse(configText)).config;
|
|
5651
|
+
if (parsed === null) return [];
|
|
5652
|
+
return resolveBackupPaths(parsed.servers, parsed.backups.includePaths).filter((entry) => entry.included).map((entry) => ({
|
|
4983
5653
|
path: entry.path,
|
|
4984
5654
|
origin: entry.origin
|
|
4985
5655
|
}));
|
|
@@ -5007,7 +5677,7 @@ function slugifyPath(target) {
|
|
|
5007
5677
|
}
|
|
5008
5678
|
var MANIFEST, ALLOWED_ROOTS, SUFFIX, BackupService;
|
|
5009
5679
|
var init_backups = __esmMin((() => {
|
|
5010
|
-
|
|
5680
|
+
init_parse();
|
|
5011
5681
|
init_atomic();
|
|
5012
5682
|
init_env_file();
|
|
5013
5683
|
init_paths();
|
|
@@ -6552,7 +7222,7 @@ function probeHostFor(bindHost) {
|
|
|
6552
7222
|
*/
|
|
6553
7223
|
async function runControlPlane(options) {
|
|
6554
7224
|
const existing = readRuntime();
|
|
6555
|
-
if (existing !== null && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
|
|
7225
|
+
if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
|
|
6556
7226
|
logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
|
|
6557
7227
|
process.exit(1);
|
|
6558
7228
|
}
|
|
@@ -6561,6 +7231,16 @@ async function runControlPlane(options) {
|
|
|
6561
7231
|
const store = new ConfigStore(configPath, SEED_CONFIG);
|
|
6562
7232
|
store.load();
|
|
6563
7233
|
store.writeJsonSchema();
|
|
7234
|
+
if (store.configError !== null) {
|
|
7235
|
+
logger.error(`refusing to start: ${store.configError}`);
|
|
7236
|
+
logger.info(`fix ${store.path}, or install the release that wrote it`);
|
|
7237
|
+
process.exit(1);
|
|
7238
|
+
}
|
|
7239
|
+
if (store.pendingMigrations.length > 0) {
|
|
7240
|
+
logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
|
|
7241
|
+
logger.info("run `home-hosted migrate` to see and apply them");
|
|
7242
|
+
process.exit(1);
|
|
7243
|
+
}
|
|
6564
7244
|
const secrets = new SecretsStore(defaultSecretsPath);
|
|
6565
7245
|
const auth = new AuthService(secrets, () => store.config.control.auth);
|
|
6566
7246
|
const tls = new TlsStore(defaultTlsDir);
|
|
@@ -6586,7 +7266,7 @@ async function runControlPlane(options) {
|
|
|
6586
7266
|
});
|
|
6587
7267
|
if (!auth.passwordSet) {
|
|
6588
7268
|
auth.ensureDefaultPassword("hh");
|
|
6589
|
-
logger.warn(`no password was set — created the default "hh"; change it in Settings →
|
|
7269
|
+
logger.warn(`no password was set — created the default "hh"; change it in Settings → Authentication`);
|
|
6590
7270
|
}
|
|
6591
7271
|
const configured = store.config.control;
|
|
6592
7272
|
const intendedHost = options.host === void 0 ? configured.host : parseBind(options.host);
|
|
@@ -6711,14 +7391,14 @@ async function runControlPlane(options) {
|
|
|
6711
7391
|
logger.box(`home-hosted ${runtime.version}\n${endpoint.url}`);
|
|
6712
7392
|
logger.info(`config: ${store.path}`);
|
|
6713
7393
|
logger.info(`secrets: ${secrets.path}${auth.passwordSet ? "" : " (no password set)"}`);
|
|
6714
|
-
logger.info(`auth: ${auth.isRequired() ? "required" : "disabled"}${auth.usingDefaultPassword ? " (default password)" : ""}${exposure.exposed ? " · exposed beyond loopback" : ""}`);
|
|
7394
|
+
logger.info(`auth: ${auth.isRequired() ? "required" : "disabled"}${auth.usingDefaultPassword ? " (default password)" : ""}${auth.apiTokenSet ? " · API token set" : ""}${exposure.exposed ? " · exposed beyond loopback" : ""}`);
|
|
6715
7395
|
logger.info(`logs: ${store.config.logs.persist ? `${logFiles.directory} (max ${store.config.logs.maxBytes} B x ${store.config.logs.keep})` : "memory only"}`);
|
|
6716
7396
|
logger.info(`project: ${projectDir}`);
|
|
6717
7397
|
if (ui.custom) {
|
|
6718
7398
|
const meta = ui.status().meta;
|
|
6719
7399
|
logger.warn(`custom UI in use${meta === null ? "" : ` (${meta.name}${meta.version === null ? "" : ` ${meta.version}`})`} — if it breaks, run \`home-hosted ui-revert\``);
|
|
6720
7400
|
}
|
|
6721
|
-
|
|
7401
|
+
for (const warning of store.configWarnings) logger.warn(warning);
|
|
6722
7402
|
for (const entry of supervisor.views()) logger.info(` ${entry.id.padEnd(12)} ${entry.config.command} ${entry.config.args.join(" ")}`.trimEnd());
|
|
6723
7403
|
if (configured.openBrowser || options.open) openBrowser(endpoint.url);
|
|
6724
7404
|
if (options.autostart) supervisor.startAll({ autostartOnly: true }).catch((error) => {
|
|
@@ -6751,6 +7431,7 @@ var init_src = __esmMin((() => {
|
|
|
6751
7431
|
init_open();
|
|
6752
7432
|
init_paths();
|
|
6753
7433
|
init_template();
|
|
7434
|
+
init_version();
|
|
6754
7435
|
init_port();
|
|
6755
7436
|
init_auth$1();
|
|
6756
7437
|
init_backups();
|
|
@@ -6769,6 +7450,113 @@ var init_src = __esmMin((() => {
|
|
|
6769
7450
|
packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
6770
7451
|
}));
|
|
6771
7452
|
//#endregion
|
|
7453
|
+
//#region src/services/init.ts
|
|
7454
|
+
var init_exports = /* @__PURE__ */ __exportAll({
|
|
7455
|
+
PACKAGE_MANAGERS: () => PACKAGE_MANAGERS,
|
|
7456
|
+
detectPackageManager: () => detectPackageManager,
|
|
7457
|
+
installArgs: () => installArgs,
|
|
7458
|
+
isEmptyDir: () => isEmptyDir,
|
|
7459
|
+
projectGitignore: () => projectGitignore,
|
|
7460
|
+
projectManifest: () => projectManifest,
|
|
7461
|
+
projectScripts: () => projectScripts,
|
|
7462
|
+
runCommand: () => runCommand,
|
|
7463
|
+
scaffold: () => scaffold
|
|
7464
|
+
});
|
|
7465
|
+
/** The scripts every scaffolded project gets; all of them keep state inside the project. */
|
|
7466
|
+
function projectScripts() {
|
|
7467
|
+
const withState = (args) => `home-hosted ${args} --home ./state`;
|
|
7468
|
+
return {
|
|
7469
|
+
"up": withState("up"),
|
|
7470
|
+
"down": withState("down"),
|
|
7471
|
+
"restart": withState("restart"),
|
|
7472
|
+
"status": withState("status"),
|
|
7473
|
+
"set-password": withState("set-password"),
|
|
7474
|
+
"set-token": withState("set-token"),
|
|
7475
|
+
"migrate": withState("migrate")
|
|
7476
|
+
};
|
|
7477
|
+
}
|
|
7478
|
+
/** `home-hosted` is pinned to the version that wrote the project, and open to newer ones. */
|
|
7479
|
+
function projectManifest(name) {
|
|
7480
|
+
return `${JSON.stringify({
|
|
7481
|
+
name,
|
|
7482
|
+
version: "0.1.0",
|
|
7483
|
+
private: true,
|
|
7484
|
+
description: "Servers supervised by home-hosted.",
|
|
7485
|
+
type: "module",
|
|
7486
|
+
engines: { node: ">=24.0.0" },
|
|
7487
|
+
scripts: projectScripts(),
|
|
7488
|
+
dependencies: { "home-hosted": `^${appVersion()}` }
|
|
7489
|
+
}, null, 2)}\n`;
|
|
7490
|
+
}
|
|
7491
|
+
/**
|
|
7492
|
+
* State is generated, so it stays out — except the file that declares the servers,
|
|
7493
|
+
* which is the one thing worth committing.
|
|
7494
|
+
*/
|
|
7495
|
+
function projectGitignore() {
|
|
7496
|
+
return `node_modules/
|
|
7497
|
+
|
|
7498
|
+
# home-hosted: state is local, the server definitions are tracked
|
|
7499
|
+
state/*
|
|
7500
|
+
!state/servers.config.json
|
|
7501
|
+
data/
|
|
7502
|
+
`;
|
|
7503
|
+
}
|
|
7504
|
+
function isEmptyDir(dir) {
|
|
7505
|
+
try {
|
|
7506
|
+
return fs.readdirSync(dir).every((entry) => entry === ".git");
|
|
7507
|
+
} catch {
|
|
7508
|
+
return true;
|
|
7509
|
+
}
|
|
7510
|
+
}
|
|
7511
|
+
function assertUsableDir(dir) {
|
|
7512
|
+
let stats = null;
|
|
7513
|
+
try {
|
|
7514
|
+
stats = fs.statSync(dir);
|
|
7515
|
+
} catch {
|
|
7516
|
+
return;
|
|
7517
|
+
}
|
|
7518
|
+
if (!stats.isDirectory()) throw new Error(`${dir} exists and is not a directory`);
|
|
7519
|
+
if (!isEmptyDir(dir)) throw new Error(`${dir} is not empty — pick another directory, or empty it first`);
|
|
7520
|
+
}
|
|
7521
|
+
/** Writes the project skeleton. Nothing is installed or initialized here. */
|
|
7522
|
+
function scaffold(options) {
|
|
7523
|
+
const dir = path.resolve(options.dir);
|
|
7524
|
+
assertUsableDir(dir);
|
|
7525
|
+
const existed = fs.existsSync(dir);
|
|
7526
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
7527
|
+
const files = [["package.json", projectManifest(options.name)], [".gitignore", projectGitignore()]];
|
|
7528
|
+
for (const [name, contents] of files) fs.writeFileSync(path.join(dir, name), contents);
|
|
7529
|
+
return {
|
|
7530
|
+
dir,
|
|
7531
|
+
created: !existed,
|
|
7532
|
+
files: files.map(([name]) => name),
|
|
7533
|
+
installed: false,
|
|
7534
|
+
git: false
|
|
7535
|
+
};
|
|
7536
|
+
}
|
|
7537
|
+
/** The package manager this machine actually has, preferring pnpm. */
|
|
7538
|
+
function detectPackageManager(exists) {
|
|
7539
|
+
return PACKAGE_MANAGERS.find((pm) => exists(pm)) ?? "npm";
|
|
7540
|
+
}
|
|
7541
|
+
/** How to run one of the project's scripts with a given package manager. */
|
|
7542
|
+
function runCommand(pm, script) {
|
|
7543
|
+
return pm === "npm" ? `npm run ${script}` : `${pm} run ${script}`;
|
|
7544
|
+
}
|
|
7545
|
+
/** Install arguments for a scaffolded project (the manifest already lists the dependency). */
|
|
7546
|
+
function installArgs(pm) {
|
|
7547
|
+
return pm === "yarn" ? [] : ["install"];
|
|
7548
|
+
}
|
|
7549
|
+
var PACKAGE_MANAGERS;
|
|
7550
|
+
var init_init = __esmMin((() => {
|
|
7551
|
+
init_version();
|
|
7552
|
+
PACKAGE_MANAGERS = [
|
|
7553
|
+
"pnpm",
|
|
7554
|
+
"npm",
|
|
7555
|
+
"yarn",
|
|
7556
|
+
"bun"
|
|
7557
|
+
];
|
|
7558
|
+
}));
|
|
7559
|
+
//#endregion
|
|
6772
7560
|
//#region src/cli.ts
|
|
6773
7561
|
/**
|
|
6774
7562
|
* The command line. Everything here works before the state directories are known
|
|
@@ -6786,6 +7574,9 @@ Usage
|
|
|
6786
7574
|
home-hosted restart [options] down, then up
|
|
6787
7575
|
home-hosted status [--json] is it running, where, and how to reach it
|
|
6788
7576
|
home-hosted set-password set the panel password without the API
|
|
7577
|
+
home-hosted set-token set the API token that scripts and agents use
|
|
7578
|
+
home-hosted migrate bring the config up to this release's schema
|
|
7579
|
+
home-hosted init scaffold a project that keeps its state in the repo
|
|
6789
7580
|
home-hosted ui-revert go back to the stock control panel UI
|
|
6790
7581
|
|
|
6791
7582
|
Options for up/restart
|
|
@@ -6797,6 +7588,21 @@ Options for up/restart
|
|
|
6797
7588
|
--foreground run in this process instead of detaching (systemd/docker)
|
|
6798
7589
|
--print-config print the effective config and exit
|
|
6799
7590
|
|
|
7591
|
+
Options for set-token
|
|
7592
|
+
--generate create a strong token and print it once
|
|
7593
|
+
--clear remove the token, so it stops working
|
|
7594
|
+
|
|
7595
|
+
Options for migrate
|
|
7596
|
+
--dry-run print what would change, write nothing
|
|
7597
|
+
-y, --yes apply without asking (or set HHOSTED_MIGRATE=allow)
|
|
7598
|
+
|
|
7599
|
+
Options for init
|
|
7600
|
+
--dir <dir> where to scaffold (default: ./my-servers)
|
|
7601
|
+
--name <name> package name (default: the directory name)
|
|
7602
|
+
--pm <manager> pnpm | npm | yarn | bun (default: the first one installed)
|
|
7603
|
+
--no-install write the files, install nothing
|
|
7604
|
+
-y, --yes take every default, ask nothing
|
|
7605
|
+
|
|
6800
7606
|
Everywhere
|
|
6801
7607
|
--home <dir> state directory (default: $HHOSTED_HOME or ~/.home-hosted)
|
|
6802
7608
|
--project <dir> base for relative entry paths (default: the current directory)
|
|
@@ -6806,6 +7612,8 @@ Everywhere
|
|
|
6806
7612
|
Environment
|
|
6807
7613
|
HHOSTED_HOME where config, secrets, logs, TLS and backups live
|
|
6808
7614
|
HHOSTED_PROJECT base for relative entry paths
|
|
7615
|
+
HHOSTED_PASSWORD the password for a non-interactive set-password
|
|
7616
|
+
HHOSTED_TOKEN the token for a non-interactive set-token
|
|
6809
7617
|
`;
|
|
6810
7618
|
var isTty = () => process.stdout.isTTY === true;
|
|
6811
7619
|
var paint = (code, text) => isTty() ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
@@ -7110,6 +7918,19 @@ function formatDuration(ms) {
|
|
|
7110
7918
|
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
7111
7919
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
7112
7920
|
}
|
|
7921
|
+
/** A plain y/N question, for decisions that are not secrets. */
|
|
7922
|
+
function prompt(question) {
|
|
7923
|
+
return new Promise((resolve) => {
|
|
7924
|
+
const rl = readline.createInterface({
|
|
7925
|
+
input: process.stdin,
|
|
7926
|
+
output: process.stdout
|
|
7927
|
+
});
|
|
7928
|
+
rl.question(question, (answer) => {
|
|
7929
|
+
rl.close();
|
|
7930
|
+
resolve(answer);
|
|
7931
|
+
});
|
|
7932
|
+
});
|
|
7933
|
+
}
|
|
7113
7934
|
/** Reads a line with echo suppressed, so the password never lands in scrollback. */
|
|
7114
7935
|
function promptHidden(question) {
|
|
7115
7936
|
return new Promise((resolve) => {
|
|
@@ -7132,6 +7953,165 @@ function promptHidden(question) {
|
|
|
7132
7953
|
});
|
|
7133
7954
|
});
|
|
7134
7955
|
}
|
|
7956
|
+
/**
|
|
7957
|
+
* Brings `servers.config.json` up to the schema this release understands.
|
|
7958
|
+
*
|
|
7959
|
+
* Deliberately loud and deliberate: it prints every step first, backs the file up
|
|
7960
|
+
* before writing, refuses to write a config it cannot read, and never runs on its
|
|
7961
|
+
* own — a detached daemon cannot prompt, so consent comes from `--yes`, from
|
|
7962
|
+
* `HHOSTED_MIGRATE=allow`, or from a person at a terminal.
|
|
7963
|
+
*/
|
|
7964
|
+
async function migrateConfig(argv) {
|
|
7965
|
+
const { values } = parseArgs({
|
|
7966
|
+
args: argv,
|
|
7967
|
+
options: {
|
|
7968
|
+
"config": {
|
|
7969
|
+
type: "string",
|
|
7970
|
+
short: "c"
|
|
7971
|
+
},
|
|
7972
|
+
"dry-run": { type: "boolean" },
|
|
7973
|
+
"yes": {
|
|
7974
|
+
type: "boolean",
|
|
7975
|
+
short: "y"
|
|
7976
|
+
}
|
|
7977
|
+
},
|
|
7978
|
+
allowPositionals: false
|
|
7979
|
+
});
|
|
7980
|
+
const { defaultConfigPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
7981
|
+
const { applyConfigMigrations, CONFIG_SCHEMA, planConfigMigrations } = await Promise.resolve().then(() => (init_migrations(), migrations_exports));
|
|
7982
|
+
const { parseConfig, stampConfig } = await Promise.resolve().then(() => (init_parse(), parse_exports));
|
|
7983
|
+
const { writeFileAtomic } = await Promise.resolve().then(() => (init_atomic(), atomic_exports));
|
|
7984
|
+
const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
|
|
7985
|
+
const file = values.config ?? defaultConfigPath;
|
|
7986
|
+
if (!fs.existsSync(file)) fail(`no config at ${file} — nothing to migrate`);
|
|
7987
|
+
let raw;
|
|
7988
|
+
try {
|
|
7989
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
7990
|
+
} catch (error) {
|
|
7991
|
+
fail(`cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7992
|
+
}
|
|
7993
|
+
const meta = typeof raw.meta === "object" && raw.meta !== null ? raw.meta : {};
|
|
7994
|
+
const from = typeof meta.schema === "number" ? meta.schema : CONFIG_SCHEMA;
|
|
7995
|
+
const plan = planConfigMigrations(from);
|
|
7996
|
+
if (plan.tooNew) fail(`${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.`);
|
|
7997
|
+
const stamped = stampConfig(raw);
|
|
7998
|
+
const upToDate = plan.steps.length === 0;
|
|
7999
|
+
if (upToDate && values["dry-run"] !== true && JSON.stringify(stamped) !== JSON.stringify(raw)) {
|
|
8000
|
+
writeFileAtomic(file, `${JSON.stringify(stamped, null, 2)}\n`);
|
|
8001
|
+
process.stdout.write(`${green("config stamped")} in ${file} — written by home-hosted ${appVersion()}, schema ${plan.to}\n`);
|
|
8002
|
+
return;
|
|
8003
|
+
}
|
|
8004
|
+
if (upToDate) {
|
|
8005
|
+
process.stdout.write(`config schema ${from} is already what home-hosted ${appVersion()} understands — nothing to migrate\n`);
|
|
8006
|
+
return;
|
|
8007
|
+
}
|
|
8008
|
+
process.stdout.write(`migrating ${file}: config schema ${from} → ${plan.to}\n`);
|
|
8009
|
+
for (const [index, step] of plan.steps.entries()) process.stdout.write(` ${index + 1}. ${step.describe}\n`);
|
|
8010
|
+
if (values["dry-run"] === true) {
|
|
8011
|
+
process.stdout.write(`${dim(`nothing was written (--dry-run, ${plan.steps.length} step(s) pending)`)}\n`);
|
|
8012
|
+
return;
|
|
8013
|
+
}
|
|
8014
|
+
if (!(values.yes === true || (process.env.HHOSTED_MIGRATE ?? "").toLowerCase() === "allow")) {
|
|
8015
|
+
if (process.stdin.isTTY !== true) fail(`this config needs ${plan.steps.length} migration(s) and this session cannot ask.\n re-run with --yes, or set HHOSTED_MIGRATE=allow for unattended runs`);
|
|
8016
|
+
const answer = await prompt(`Apply ${plan.steps.length} migration(s) to ${path.basename(file)}? [y/N] `);
|
|
8017
|
+
if (!/^yes$|^y$/i.test(answer.trim())) {
|
|
8018
|
+
process.stdout.write("cancelled — nothing was written\n");
|
|
8019
|
+
return;
|
|
8020
|
+
}
|
|
8021
|
+
}
|
|
8022
|
+
const { config, applied } = applyConfigMigrations(raw, from);
|
|
8023
|
+
const parsed = parseConfig(config);
|
|
8024
|
+
if (parsed.config === null) fail(`the migration produced a config this release cannot read:\n ${parsed.errors.join("\n ")}`);
|
|
8025
|
+
const backup = `${file}.bak`;
|
|
8026
|
+
fs.copyFileSync(file, backup);
|
|
8027
|
+
writeFileAtomic(file, `${JSON.stringify(stampConfig(config), null, 2)}\n`);
|
|
8028
|
+
process.stdout.write(`${green(`migrated to schema ${plan.to}`)} (${applied.length} step(s)) in ${file}\n`);
|
|
8029
|
+
process.stdout.write(` ${dim(`previous file kept at ${backup}`)}\n`);
|
|
8030
|
+
for (const key of parsed.unknownKeys) process.stdout.write(` ${dim(`still ignoring an unrecognized key: ${key}`)}\n`);
|
|
8031
|
+
}
|
|
8032
|
+
/** Asks a yes/no question with a default, so an empty answer is a real answer. */
|
|
8033
|
+
async function confirm(question, fallback) {
|
|
8034
|
+
const answer = (await prompt(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
8035
|
+
if (answer.length === 0) return fallback;
|
|
8036
|
+
return answer === "y" || answer === "yes";
|
|
8037
|
+
}
|
|
8038
|
+
function which(command) {
|
|
8039
|
+
return spawnSync(command, ["--version"], {
|
|
8040
|
+
stdio: "ignore",
|
|
8041
|
+
shell: process.platform === "win32"
|
|
8042
|
+
}).status === 0;
|
|
8043
|
+
}
|
|
8044
|
+
/**
|
|
8045
|
+
* Scaffolds a project that keeps its whole setup — state, data and the server
|
|
8046
|
+
* definitions — inside its own directory. Interactive by nature, but `--yes`
|
|
8047
|
+
* takes every default so an agent or a CI job can run it unattended.
|
|
8048
|
+
*/
|
|
8049
|
+
async function initProject(argv) {
|
|
8050
|
+
const { values } = parseArgs({
|
|
8051
|
+
args: argv,
|
|
8052
|
+
options: {
|
|
8053
|
+
"dir": { type: "string" },
|
|
8054
|
+
"name": { type: "string" },
|
|
8055
|
+
"pm": { type: "string" },
|
|
8056
|
+
"no-install": { type: "boolean" },
|
|
8057
|
+
"yes": {
|
|
8058
|
+
type: "boolean",
|
|
8059
|
+
short: "y"
|
|
8060
|
+
}
|
|
8061
|
+
},
|
|
8062
|
+
allowPositionals: false
|
|
8063
|
+
});
|
|
8064
|
+
const { detectPackageManager, installArgs, PACKAGE_MANAGERS, runCommand, scaffold } = await Promise.resolve().then(() => (init_init(), init_exports));
|
|
8065
|
+
const assumeYes = values.yes === true;
|
|
8066
|
+
if (!assumeYes && process.stdin.isTTY !== true) fail("init needs a terminal to ask in.\n take the defaults with: home-hosted init --yes [--dir <dir>]");
|
|
8067
|
+
const defaultDir = values.dir ?? "./my-servers";
|
|
8068
|
+
const dir = assumeYes ? defaultDir : (await prompt(`Project directory (${defaultDir}) `)).trim() || defaultDir;
|
|
8069
|
+
const defaultName = path.basename(path.resolve(dir));
|
|
8070
|
+
const name = values.name ?? (assumeYes ? defaultName : (await prompt(`Package name (${defaultName}) `)).trim() || defaultName);
|
|
8071
|
+
let pm = values.pm;
|
|
8072
|
+
if (pm !== void 0 && !PACKAGE_MANAGERS.includes(pm)) fail(`unknown package manager: ${pm} (expected one of ${PACKAGE_MANAGERS.join(", ")})`);
|
|
8073
|
+
const detected = detectPackageManager(which);
|
|
8074
|
+
if (pm === void 0) pm = assumeYes ? detected : (await prompt(`Package manager (${detected}) `)).trim() || detected;
|
|
8075
|
+
const install = values["no-install"] === true ? false : assumeYes || await confirm("Install the dependencies now?", true);
|
|
8076
|
+
const git = assumeYes ? which("git") : which("git") && await confirm("Initialize a git repository?", true);
|
|
8077
|
+
try {
|
|
8078
|
+
const result = scaffold({
|
|
8079
|
+
dir,
|
|
8080
|
+
name,
|
|
8081
|
+
pm,
|
|
8082
|
+
install,
|
|
8083
|
+
git
|
|
8084
|
+
});
|
|
8085
|
+
process.stdout.write(`${green("project created")} in ${result.dir}\n`);
|
|
8086
|
+
for (const file of result.files) process.stdout.write(` ${dim(file)}\n`);
|
|
8087
|
+
} catch (error) {
|
|
8088
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
8089
|
+
}
|
|
8090
|
+
const target = path.resolve(dir);
|
|
8091
|
+
if (git) {
|
|
8092
|
+
spawnSync("git", ["init", "-q"], {
|
|
8093
|
+
cwd: target,
|
|
8094
|
+
stdio: "inherit",
|
|
8095
|
+
shell: process.platform === "win32"
|
|
8096
|
+
});
|
|
8097
|
+
process.stdout.write(` ${dim("git repository initialized")}\n`);
|
|
8098
|
+
}
|
|
8099
|
+
if (install) {
|
|
8100
|
+
process.stdout.write(`${dim(`installing with ${pm}…`)}\n`);
|
|
8101
|
+
if (spawnSync(pm, installArgs(pm), {
|
|
8102
|
+
cwd: target,
|
|
8103
|
+
stdio: "inherit",
|
|
8104
|
+
shell: process.platform === "win32"
|
|
8105
|
+
}).status !== 0) process.stdout.write(`${paint("33", "install failed")} — run it yourself in ${target}\n`);
|
|
8106
|
+
}
|
|
8107
|
+
const relative = path.relative(process.cwd(), target);
|
|
8108
|
+
const where = relative.length === 0 || relative.startsWith("..") ? target : relative;
|
|
8109
|
+
const cd = relative.length === 0 ? "" : `cd ${where} && `;
|
|
8110
|
+
process.stdout.write(`\nNext:\n`);
|
|
8111
|
+
process.stdout.write(` ${bold(`${cd}${runCommand(pm, "up")}`)} start the panel (default password \`hh\`)\n`);
|
|
8112
|
+
process.stdout.write(` ${dim("then change that password under Settings → Authentication, and add your servers")}\n`);
|
|
8113
|
+
process.stdout.write(` ${dim(`${runCommand(pm, "set-token")} --generate for scripts and agents`)}\n`);
|
|
8114
|
+
}
|
|
7135
8115
|
/** Drops a user-installed UI so the stock panel serves again. */
|
|
7136
8116
|
async function uiRevert() {
|
|
7137
8117
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
@@ -7172,6 +8152,69 @@ async function setPassword(argv) {
|
|
|
7172
8152
|
if (password.length < 8) process.stdout.write(`${dim(`"${password}" is short — easy to guess if the panel is reachable beyond loopback`)}\n`);
|
|
7173
8153
|
process.stdout.write(`${dim("restart the panel for it to take effect: home-hosted restart")}\n`);
|
|
7174
8154
|
}
|
|
8155
|
+
/**
|
|
8156
|
+
* The bearer credential for scripts and agents. Unlike a session it survives a
|
|
8157
|
+
* restart, so an agent can set it up once and then just send the header.
|
|
8158
|
+
*/
|
|
8159
|
+
async function setToken(argv) {
|
|
8160
|
+
const { values } = parseArgs({
|
|
8161
|
+
args: argv,
|
|
8162
|
+
options: {
|
|
8163
|
+
generate: { type: "boolean" },
|
|
8164
|
+
clear: { type: "boolean" }
|
|
8165
|
+
},
|
|
8166
|
+
allowPositionals: false
|
|
8167
|
+
});
|
|
8168
|
+
if (values.generate === true && values.clear === true) fail("use either --generate or --clear, not both");
|
|
8169
|
+
const { defaultSecretsPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
8170
|
+
const { generateApiToken, SecretsStore } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
|
|
8171
|
+
const store = new SecretsStore(defaultSecretsPath);
|
|
8172
|
+
if (values.clear === true) {
|
|
8173
|
+
if (!store.apiTokenSet) {
|
|
8174
|
+
process.stdout.write("no API token is set — nothing to clear\n");
|
|
8175
|
+
return;
|
|
8176
|
+
}
|
|
8177
|
+
store.clearApiToken();
|
|
8178
|
+
process.stdout.write(`${green("API token cleared")} in ${defaultSecretsPath} — it stops working immediately\n`);
|
|
8179
|
+
return;
|
|
8180
|
+
}
|
|
8181
|
+
let token = process.env.HHOSTED_TOKEN ?? null;
|
|
8182
|
+
if (values.generate === true) token = generateApiToken();
|
|
8183
|
+
else if (token === null && process.stdin.isTTY === true) token = await promptHidden("API token: ");
|
|
8184
|
+
if (token !== null) token = token.trim();
|
|
8185
|
+
if (token === null || token.length === 0) fail("no token given: run `home-hosted set-token --generate`, set HHOSTED_TOKEN, or paste one interactively");
|
|
8186
|
+
store.setApiToken(token);
|
|
8187
|
+
const generated = values.generate === true;
|
|
8188
|
+
process.stdout.write(`${green(generated ? "token generated" : "token stored")} in ${defaultSecretsPath} (mode 0600)\n`);
|
|
8189
|
+
if (generated) {
|
|
8190
|
+
process.stdout.write(` ${bold(token)}\n`);
|
|
8191
|
+
process.stdout.write(`${dim(" shown once — only its SHA-256 is kept on disk, so copy it now")}\n`);
|
|
8192
|
+
} else process.stdout.write(`${dim(` stored as ${token.slice(0, 8)}… — the file keeps only its hash`)}\n`);
|
|
8193
|
+
const { readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
8194
|
+
const runtime = readRuntime();
|
|
8195
|
+
const base = (runtime?.url ?? `http://127.0.0.1:${DEFAULT_PORT}`).replace(/\/+$/, "");
|
|
8196
|
+
if (runtime !== null) {
|
|
8197
|
+
const verified = await verifyToken(base, token);
|
|
8198
|
+
if (verified === true) process.stdout.write(`${dim(`verified: ${base}/api/auth/session accepted it`)}\n`);
|
|
8199
|
+
else if (verified === false) process.stdout.write(`${dim(`the panel at ${base} did not accept it — is it running with this state directory?`)}\n`);
|
|
8200
|
+
}
|
|
8201
|
+
process.stdout.write(`Use it from a script or an agent:\n`);
|
|
8202
|
+
process.stdout.write(` ${bold(`curl -H "Authorization: Bearer ${generated ? token : "<token>"}" ${base}/api/state`)}\n`);
|
|
8203
|
+
process.stdout.write(`${dim("It needs no restart, outlives sessions, and holds the same access as a signed-in browser.")}\n`);
|
|
8204
|
+
process.stdout.write(`${dim("Remove it any time with: home-hosted set-token --clear")}\n`);
|
|
8205
|
+
if (store.usingDefaultPassword) process.stdout.write(`${dim("The panel password is still the default — change it before the panel is reachable beyond loopback.")}\n`);
|
|
8206
|
+
}
|
|
8207
|
+
/** Asks the live panel whether it accepts the token, without failing when it cannot. */
|
|
8208
|
+
async function verifyToken(base, token) {
|
|
8209
|
+
if (!base.startsWith("http://")) return null;
|
|
8210
|
+
try {
|
|
8211
|
+
const response = await fetch(`${base}/api/auth/session`, { headers: { authorization: `Bearer ${token}` } });
|
|
8212
|
+
if (!response.ok) return false;
|
|
8213
|
+
return (await response.json()).authenticated === true;
|
|
8214
|
+
} catch {
|
|
8215
|
+
return null;
|
|
8216
|
+
}
|
|
8217
|
+
}
|
|
7175
8218
|
function version() {
|
|
7176
8219
|
const manifest = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
7177
8220
|
process.stdout.write(`${manifest.version ?? "0.0.0"}\n`);
|
|
@@ -7205,6 +8248,18 @@ async function main() {
|
|
|
7205
8248
|
applyDirFlags(dirFlags);
|
|
7206
8249
|
await setPassword(args);
|
|
7207
8250
|
return;
|
|
8251
|
+
case "set-token":
|
|
8252
|
+
applyDirFlags(dirFlags);
|
|
8253
|
+
await setToken(args);
|
|
8254
|
+
return;
|
|
8255
|
+
case "migrate":
|
|
8256
|
+
applyDirFlags(dirFlags);
|
|
8257
|
+
await migrateConfig(args);
|
|
8258
|
+
return;
|
|
8259
|
+
case "init":
|
|
8260
|
+
applyDirFlags(dirFlags);
|
|
8261
|
+
await initProject(args);
|
|
8262
|
+
return;
|
|
7208
8263
|
case "ui-revert":
|
|
7209
8264
|
applyDirFlags(dirFlags);
|
|
7210
8265
|
await uiRevert();
|