@openparachute/hub 0.7.3-rc.9 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +433 -0
- package/src/__tests__/hub-origins-env-set.test.ts +74 -0
- package/src/__tests__/hub-server.test.ts +240 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/setup-wizard.test.ts +121 -1
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +409 -0
- package/src/commands/init.ts +63 -0
- package/src/commands/serve.ts +39 -3
- package/src/help.ts +8 -0
- package/src/hub-server.ts +46 -0
- package/src/setup-wizard.ts +20 -0
|
@@ -3,6 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
_resetBootstrapTokenForTests,
|
|
8
|
+
generateBootstrapToken,
|
|
9
|
+
} from "../bootstrap-token.ts";
|
|
6
10
|
import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
7
11
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
8
12
|
import { createDbHolder } from "../hub-db-liveness.ts";
|
|
@@ -3676,6 +3680,74 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
|
|
|
3676
3680
|
});
|
|
3677
3681
|
expect(layerOf(r)).toBe("public");
|
|
3678
3682
|
});
|
|
3683
|
+
|
|
3684
|
+
// Caddy/nginx-direct deploy (hub#704). A same-box reverse proxy dials
|
|
3685
|
+
// loopback (peer 127.0.0.1) and stamps NO cf/tailscale header — but it DOES
|
|
3686
|
+
// set a standard reverse-proxy forwarding header. That header is the
|
|
3687
|
+
// discriminator: a loopback peer carrying one is a proxied PUBLIC request and
|
|
3688
|
+
// must NOT be "loopback" (which would leak the bootstrap token + drop the
|
|
3689
|
+
// loopback-exposure cloak for every public visitor on a Caddy-direct box).
|
|
3690
|
+
test("loopback peer + X-Forwarded-For → public (Caddy-direct, NOT loopback) [#704]", () => {
|
|
3691
|
+
const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3692
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3693
|
+
});
|
|
3694
|
+
|
|
3695
|
+
test("loopback peer + X-Forwarded-Host → public (Caddy-direct) [#704]", () => {
|
|
3696
|
+
const r = req("/", { headers: { "X-Forwarded-Host": "hub.example.com" } });
|
|
3697
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3698
|
+
});
|
|
3699
|
+
|
|
3700
|
+
test("loopback peer + Forwarded (RFC 7239) → public (Caddy-direct) [#704]", () => {
|
|
3701
|
+
const r = req("/", { headers: { Forwarded: "for=203.0.113.7;host=hub.example.com" } });
|
|
3702
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
test("IPv6-mapped loopback peer + X-Forwarded-For → public (Caddy-direct) [#704]", () => {
|
|
3706
|
+
const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3707
|
+
expect(layerOf(r, "::ffff:127.0.0.1")).toBe("public");
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
// Security edge: an EMPTY / whitespace-only forwarding header (a misconfigured
|
|
3711
|
+
// or stripped-but-present proxy header) must still err toward "public", never
|
|
3712
|
+
// back to "loopback". `layerOf` uses a presence check (`!== null`), not a
|
|
3713
|
+
// trim — keep it that way: the safe direction for a trust classifier is to
|
|
3714
|
+
// DOWNGRADE on ambiguity. A future "tidy up with .trim()" refactor that
|
|
3715
|
+
// flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
|
|
3716
|
+
test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
|
|
3717
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
|
|
3718
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
|
|
3719
|
+
});
|
|
3720
|
+
|
|
3721
|
+
// The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
|
|
3722
|
+
// probe `curl 127.0.0.1/admin/setup`, hub self-requests) sets NO forwarding
|
|
3723
|
+
// header and MUST stay loopback. This is the not-broken half of the fix.
|
|
3724
|
+
test("loopback peer + NO forwarding header → loopback (genuine on-box caller) [#704]", () => {
|
|
3725
|
+
expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
|
|
3726
|
+
expect(layerOf(req("/"), "::1")).toBe("loopback");
|
|
3727
|
+
});
|
|
3728
|
+
|
|
3729
|
+
// No spoof vector: forwarding headers can only DOWNGRADE a loopback caller.
|
|
3730
|
+
// A NON-loopback peer is already "public" regardless of headers, so adding
|
|
3731
|
+
// X-Forwarded-* never upgrades a network peer to a more-trusted layer.
|
|
3732
|
+
test("non-loopback peer + X-Forwarded-For → public (no upgrade; spoof-proof) [#704]", () => {
|
|
3733
|
+
const r = req("/", { headers: { "X-Forwarded-For": "127.0.0.1" } });
|
|
3734
|
+
expect(layerOf(r, "203.0.113.7")).toBe("public");
|
|
3735
|
+
});
|
|
3736
|
+
|
|
3737
|
+
// CF/TS header checks run FIRST — a Caddy-style forwarding header alongside a
|
|
3738
|
+
// cf/tailscale header doesn't change those (still public/tailnet). Order
|
|
3739
|
+
// preserved.
|
|
3740
|
+
test("CF-Ray + X-Forwarded-For from loopback peer → public (CF check wins, order preserved)", () => {
|
|
3741
|
+
const r = req("/", { headers: { "CF-Ray": "abc123-DEN", "X-Forwarded-For": "203.0.113.7" } });
|
|
3742
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3743
|
+
});
|
|
3744
|
+
|
|
3745
|
+
test("Tailscale-User-Login + X-Forwarded-For from loopback peer → tailnet (TS check wins)", () => {
|
|
3746
|
+
const r = req("/", {
|
|
3747
|
+
headers: { "Tailscale-User-Login": "alice@example.com", "X-Forwarded-For": "100.64.0.1" },
|
|
3748
|
+
});
|
|
3749
|
+
expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
|
|
3750
|
+
});
|
|
3679
3751
|
});
|
|
3680
3752
|
|
|
3681
3753
|
describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
@@ -3823,6 +3895,74 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3823
3895
|
}
|
|
3824
3896
|
});
|
|
3825
3897
|
|
|
3898
|
+
// hub#704 — Caddy/nginx-direct. A same-box reverse proxy dials loopback
|
|
3899
|
+
// (peer 127.0.0.1) and stamps NO cf/tailscale header, but DOES set
|
|
3900
|
+
// X-Forwarded-For. That request is a PUBLIC visitor proxied to the box and
|
|
3901
|
+
// must hit the loopback-exposure cloak (404) — otherwise a Caddy-direct box
|
|
3902
|
+
// leaks every loopback-only service/vault to the network.
|
|
3903
|
+
test("publicExposure: loopback + X-Forwarded-For + loopback peer → 404 (Caddy-direct cloak fires) [#704]", async () => {
|
|
3904
|
+
const h = makeHarness();
|
|
3905
|
+
const upstream = startUpstream("loopback-only");
|
|
3906
|
+
try {
|
|
3907
|
+
writeManifest(
|
|
3908
|
+
{
|
|
3909
|
+
services: [
|
|
3910
|
+
{
|
|
3911
|
+
name: "loopback-only",
|
|
3912
|
+
port: upstream.port,
|
|
3913
|
+
paths: ["/loopback-only"],
|
|
3914
|
+
health: "/loopback-only/health",
|
|
3915
|
+
version: "0.1.0",
|
|
3916
|
+
publicExposure: "loopback",
|
|
3917
|
+
},
|
|
3918
|
+
],
|
|
3919
|
+
},
|
|
3920
|
+
h.manifestPath,
|
|
3921
|
+
);
|
|
3922
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3923
|
+
// Caddy: peer is 127.0.0.1 (reverse_proxy 127.0.0.1:1939) but the request
|
|
3924
|
+
// carries X-Forwarded-For for the real public client.
|
|
3925
|
+
const r = req("/loopback-only/health", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3926
|
+
const res = await fetcher(r, fakeServer("127.0.0.1"));
|
|
3927
|
+
expect(res.status).toBe(404);
|
|
3928
|
+
} finally {
|
|
3929
|
+
upstream.stop();
|
|
3930
|
+
h.cleanup();
|
|
3931
|
+
}
|
|
3932
|
+
});
|
|
3933
|
+
|
|
3934
|
+
// The not-broken half: a header-less loopback peer (genuine on-box caller)
|
|
3935
|
+
// still reaches the loopback-only service through the cloak.
|
|
3936
|
+
test("publicExposure: loopback + NO forwarding header + loopback peer → reaches upstream [#704]", async () => {
|
|
3937
|
+
const h = makeHarness();
|
|
3938
|
+
const upstream = startUpstream("loopback-only");
|
|
3939
|
+
try {
|
|
3940
|
+
writeManifest(
|
|
3941
|
+
{
|
|
3942
|
+
services: [
|
|
3943
|
+
{
|
|
3944
|
+
name: "loopback-only",
|
|
3945
|
+
port: upstream.port,
|
|
3946
|
+
paths: ["/loopback-only"],
|
|
3947
|
+
health: "/loopback-only/health",
|
|
3948
|
+
version: "0.1.0",
|
|
3949
|
+
publicExposure: "loopback",
|
|
3950
|
+
},
|
|
3951
|
+
],
|
|
3952
|
+
},
|
|
3953
|
+
h.manifestPath,
|
|
3954
|
+
);
|
|
3955
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3956
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
|
|
3957
|
+
expect(res.status).toBe(200);
|
|
3958
|
+
const body = (await res.json()) as { tag: string };
|
|
3959
|
+
expect(body.tag).toBe("loopback-only");
|
|
3960
|
+
} finally {
|
|
3961
|
+
upstream.stop();
|
|
3962
|
+
h.cleanup();
|
|
3963
|
+
}
|
|
3964
|
+
});
|
|
3965
|
+
|
|
3826
3966
|
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
3827
3967
|
const h = makeHarness();
|
|
3828
3968
|
const upstream = startUpstream("allowed");
|
|
@@ -5850,3 +5990,103 @@ describe("resolveClientIp (H2)", () => {
|
|
|
5850
5990
|
expect(resolveClientIp(r, null)).toBeNull();
|
|
5851
5991
|
});
|
|
5852
5992
|
});
|
|
5993
|
+
|
|
5994
|
+
describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + Caddy-direct #704)", () => {
|
|
5995
|
+
// The hub#576 token probe: a LOOPBACK caller (the on-box operator's own
|
|
5996
|
+
// shell / `parachute init`) reading GET /admin/setup with Accept:
|
|
5997
|
+
// application/json gets the actual bootstrap token in the JSON envelope; a
|
|
5998
|
+
// public/tailnet caller does not. The hub derives requestIsLoopback from
|
|
5999
|
+
// `layerOf(req, peerAddr) === "loopback"` (hub-server.ts ~2296), so the
|
|
6000
|
+
// Caddy-direct fix (#704) flows straight through: a same-box reverse proxy
|
|
6001
|
+
// carrying X-Forwarded-For now classifies "public" and the token is withheld
|
|
6002
|
+
// even though the peer is 127.0.0.1.
|
|
6003
|
+
|
|
6004
|
+
// Fake Bun Server handle (mirrors the cloak suite) so the fetch fn resolves
|
|
6005
|
+
// the peer address. The on-box operator connects from 127.0.0.1; Caddy
|
|
6006
|
+
// reverse_proxy also dials 127.0.0.1 but stamps X-Forwarded-For.
|
|
6007
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
6008
|
+
|
|
6009
|
+
test("loopback peer + NO forwarding header → bootstrapToken present (on-box operator) [#576]", async () => {
|
|
6010
|
+
const h = makeHarness();
|
|
6011
|
+
_resetBootstrapTokenForTests();
|
|
6012
|
+
const token = generateBootstrapToken();
|
|
6013
|
+
try {
|
|
6014
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6015
|
+
try {
|
|
6016
|
+
// No admin row → wizard mode → GET /admin/setup JSON probe is live.
|
|
6017
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6018
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
6019
|
+
fakeServer("127.0.0.1"),
|
|
6020
|
+
);
|
|
6021
|
+
expect(res.status).toBe(200);
|
|
6022
|
+
const body = (await res.json()) as {
|
|
6023
|
+
requireBootstrapToken?: boolean;
|
|
6024
|
+
bootstrapToken?: string;
|
|
6025
|
+
};
|
|
6026
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
6027
|
+
expect(body.bootstrapToken).toBe(token);
|
|
6028
|
+
} finally {
|
|
6029
|
+
db.close();
|
|
6030
|
+
}
|
|
6031
|
+
} finally {
|
|
6032
|
+
_resetBootstrapTokenForTests();
|
|
6033
|
+
h.cleanup();
|
|
6034
|
+
}
|
|
6035
|
+
});
|
|
6036
|
+
|
|
6037
|
+
test("loopback peer + X-Forwarded-For → bootstrapToken WITHHELD (Caddy-direct public visitor) [#704]", async () => {
|
|
6038
|
+
const h = makeHarness();
|
|
6039
|
+
_resetBootstrapTokenForTests();
|
|
6040
|
+
generateBootstrapToken();
|
|
6041
|
+
try {
|
|
6042
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6043
|
+
try {
|
|
6044
|
+
// Caddy: peer 127.0.0.1, but the request carries the public client's
|
|
6045
|
+
// X-Forwarded-For. Pre-fix this leaked the token to any public visitor.
|
|
6046
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6047
|
+
req("/admin/setup", {
|
|
6048
|
+
headers: { accept: "application/json", "x-forwarded-for": "203.0.113.7" },
|
|
6049
|
+
}),
|
|
6050
|
+
fakeServer("127.0.0.1"),
|
|
6051
|
+
);
|
|
6052
|
+
expect(res.status).toBe(200);
|
|
6053
|
+
const body = (await res.json()) as {
|
|
6054
|
+
requireBootstrapToken?: boolean;
|
|
6055
|
+
bootstrapToken?: string;
|
|
6056
|
+
};
|
|
6057
|
+
// The gate still SAYS a token is required (so the form renders the
|
|
6058
|
+
// field) — it just doesn't hand the value to the public caller.
|
|
6059
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
6060
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
6061
|
+
} finally {
|
|
6062
|
+
db.close();
|
|
6063
|
+
}
|
|
6064
|
+
} finally {
|
|
6065
|
+
_resetBootstrapTokenForTests();
|
|
6066
|
+
h.cleanup();
|
|
6067
|
+
}
|
|
6068
|
+
});
|
|
6069
|
+
|
|
6070
|
+
test("non-loopback peer → bootstrapToken WITHHELD (direct public/tailnet)", async () => {
|
|
6071
|
+
const h = makeHarness();
|
|
6072
|
+
_resetBootstrapTokenForTests();
|
|
6073
|
+
generateBootstrapToken();
|
|
6074
|
+
try {
|
|
6075
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6076
|
+
try {
|
|
6077
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6078
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
6079
|
+
fakeServer("203.0.113.9"),
|
|
6080
|
+
);
|
|
6081
|
+
expect(res.status).toBe(200);
|
|
6082
|
+
const body = (await res.json()) as { bootstrapToken?: string };
|
|
6083
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
6084
|
+
} finally {
|
|
6085
|
+
db.close();
|
|
6086
|
+
}
|
|
6087
|
+
} finally {
|
|
6088
|
+
_resetBootstrapTokenForTests();
|
|
6089
|
+
h.cleanup();
|
|
6090
|
+
}
|
|
6091
|
+
});
|
|
6092
|
+
});
|
|
@@ -960,6 +960,154 @@ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () =>
|
|
|
960
960
|
});
|
|
961
961
|
});
|
|
962
962
|
|
|
963
|
+
describe("init --hub-origin (Caddy-direct zero-SSH boot, onboarding-streamline 2026-06-25)", () => {
|
|
964
|
+
test("persists the origin BEFORE ensureHub so modules spawn with it in one pass", async () => {
|
|
965
|
+
const h = makeHarness();
|
|
966
|
+
try {
|
|
967
|
+
const order: string[] = [];
|
|
968
|
+
const code = await init({
|
|
969
|
+
configDir: h.configDir,
|
|
970
|
+
ensureHubVersion: async () => ({
|
|
971
|
+
outcome: "match" as const,
|
|
972
|
+
installedVersion: "test",
|
|
973
|
+
messages: [],
|
|
974
|
+
}),
|
|
975
|
+
manifestPath: h.manifestPath,
|
|
976
|
+
log: () => {},
|
|
977
|
+
alive: () => false,
|
|
978
|
+
hubOrigin: "https://box.sslip.io",
|
|
979
|
+
// The load-bearing ordering assertion: the origin write MUST happen
|
|
980
|
+
// before the hub unit starts (which boots + spawns vault/scribe), so
|
|
981
|
+
// the boot-time issuer + child env pick it up without a restart.
|
|
982
|
+
setHubOriginImpl: (_dir, origin) => {
|
|
983
|
+
order.push(`set-origin:${origin}`);
|
|
984
|
+
},
|
|
985
|
+
ensureHub: async () => {
|
|
986
|
+
order.push("ensureHub");
|
|
987
|
+
writeHubPort(1939, h.configDir);
|
|
988
|
+
return { pid: 5555, port: 1939, started: true };
|
|
989
|
+
},
|
|
990
|
+
readExposeStateFn: () => undefined,
|
|
991
|
+
isTty: false,
|
|
992
|
+
platform: "linux",
|
|
993
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
994
|
+
});
|
|
995
|
+
expect(code).toBe(0);
|
|
996
|
+
expect(order).toEqual(["set-origin:https://box.sslip.io", "ensureHub"]);
|
|
997
|
+
} finally {
|
|
998
|
+
h.cleanup();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("default impl writes hub_settings.hub_origin to the real DB", async () => {
|
|
1003
|
+
const h = makeHarness();
|
|
1004
|
+
try {
|
|
1005
|
+
const code = await init({
|
|
1006
|
+
configDir: h.configDir,
|
|
1007
|
+
ensureHubVersion: async () => ({
|
|
1008
|
+
outcome: "match" as const,
|
|
1009
|
+
installedVersion: "test",
|
|
1010
|
+
messages: [],
|
|
1011
|
+
}),
|
|
1012
|
+
manifestPath: h.manifestPath,
|
|
1013
|
+
log: () => {},
|
|
1014
|
+
alive: () => false,
|
|
1015
|
+
hubOrigin: "https://box.sslip.io",
|
|
1016
|
+
// No setHubOriginImpl override → exercise the production default
|
|
1017
|
+
// (openHubDb + setHubOrigin) against the harness's temp configDir.
|
|
1018
|
+
ensureHub: async () => {
|
|
1019
|
+
writeHubPort(1939, h.configDir);
|
|
1020
|
+
return { pid: 5555, port: 1939, started: true };
|
|
1021
|
+
},
|
|
1022
|
+
readExposeStateFn: () => undefined,
|
|
1023
|
+
isTty: false,
|
|
1024
|
+
platform: "linux",
|
|
1025
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
1026
|
+
});
|
|
1027
|
+
expect(code).toBe(0);
|
|
1028
|
+
const { hubDbPath, openHubDb } = await import("../hub-db.ts");
|
|
1029
|
+
const { getHubOrigin } = await import("../hub-settings.ts");
|
|
1030
|
+
const db = openHubDb(hubDbPath(h.configDir));
|
|
1031
|
+
try {
|
|
1032
|
+
expect(getHubOrigin(db)).toBe("https://box.sslip.io");
|
|
1033
|
+
} finally {
|
|
1034
|
+
db.close();
|
|
1035
|
+
}
|
|
1036
|
+
} finally {
|
|
1037
|
+
h.cleanup();
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("no --hub-origin → no persistence call (laptop/loopback path unchanged)", async () => {
|
|
1042
|
+
const h = makeHarness();
|
|
1043
|
+
try {
|
|
1044
|
+
let setCalls = 0;
|
|
1045
|
+
const code = await init({
|
|
1046
|
+
configDir: h.configDir,
|
|
1047
|
+
ensureHubVersion: async () => ({
|
|
1048
|
+
outcome: "match" as const,
|
|
1049
|
+
installedVersion: "test",
|
|
1050
|
+
messages: [],
|
|
1051
|
+
}),
|
|
1052
|
+
manifestPath: h.manifestPath,
|
|
1053
|
+
log: () => {},
|
|
1054
|
+
alive: () => false,
|
|
1055
|
+
setHubOriginImpl: () => {
|
|
1056
|
+
setCalls++;
|
|
1057
|
+
},
|
|
1058
|
+
ensureHub: async () => {
|
|
1059
|
+
writeHubPort(1939, h.configDir);
|
|
1060
|
+
return { pid: 5555, port: 1939, started: true };
|
|
1061
|
+
},
|
|
1062
|
+
readExposeStateFn: () => undefined,
|
|
1063
|
+
isTty: false,
|
|
1064
|
+
platform: "linux",
|
|
1065
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
1066
|
+
});
|
|
1067
|
+
expect(code).toBe(0);
|
|
1068
|
+
expect(setCalls).toBe(0);
|
|
1069
|
+
} finally {
|
|
1070
|
+
h.cleanup();
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("a persistence failure is non-fatal — init still reaches the wizard", async () => {
|
|
1075
|
+
const h = makeHarness();
|
|
1076
|
+
try {
|
|
1077
|
+
const logs: string[] = [];
|
|
1078
|
+
const code = await init({
|
|
1079
|
+
configDir: h.configDir,
|
|
1080
|
+
ensureHubVersion: async () => ({
|
|
1081
|
+
outcome: "match" as const,
|
|
1082
|
+
installedVersion: "test",
|
|
1083
|
+
messages: [],
|
|
1084
|
+
}),
|
|
1085
|
+
manifestPath: h.manifestPath,
|
|
1086
|
+
log: (l) => logs.push(l),
|
|
1087
|
+
alive: () => false,
|
|
1088
|
+
hubOrigin: "https://box.sslip.io",
|
|
1089
|
+
setHubOriginImpl: () => {
|
|
1090
|
+
throw new Error("disk full");
|
|
1091
|
+
},
|
|
1092
|
+
ensureHub: async () => {
|
|
1093
|
+
writeHubPort(1939, h.configDir);
|
|
1094
|
+
return { pid: 5555, port: 1939, started: true };
|
|
1095
|
+
},
|
|
1096
|
+
readExposeStateFn: () => undefined,
|
|
1097
|
+
isTty: false,
|
|
1098
|
+
platform: "linux",
|
|
1099
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
1100
|
+
});
|
|
1101
|
+
expect(code).toBe(0);
|
|
1102
|
+
const joined = logs.join("\n");
|
|
1103
|
+
expect(joined).toContain("Couldn't persist the hub origin");
|
|
1104
|
+
expect(joined).toContain("http://127.0.0.1:1939/admin/");
|
|
1105
|
+
} finally {
|
|
1106
|
+
h.cleanup();
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
963
1111
|
describe("init exposure chain", () => {
|
|
964
1112
|
test("TTY + no exposure + no flags → prompt is shown", async () => {
|
|
965
1113
|
const h = makeHarness();
|
|
@@ -21,7 +21,9 @@ describe("hubServeOptions — the production listener wires the WS bridge", () =
|
|
|
21
21
|
// upgrades (the channel in-page terminal) 500'd through the hub
|
|
22
22
|
// ("set the websocket object in Bun.serve({})"). The listener MUST declare a
|
|
23
23
|
// websocket handler or `server.upgrade()` throws.
|
|
24
|
-
const fakeFetch = (() => new Response("ok")) as unknown as Parameters<
|
|
24
|
+
const fakeFetch = (() => new Response("ok")) as unknown as Parameters<
|
|
25
|
+
typeof hubServeOptions
|
|
26
|
+
>[0]["fetch"];
|
|
25
27
|
|
|
26
28
|
test("declares a websocket handler set (open/message/close)", () => {
|
|
27
29
|
const o = hubServeOptions({ port: 0, hostname: "127.0.0.1", fetch: fakeFetch });
|
|
@@ -404,6 +406,70 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
404
406
|
test("FLY_APP_NAME empty string → no fallback", () => {
|
|
405
407
|
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" }, noExpose)).toBeUndefined();
|
|
406
408
|
});
|
|
409
|
+
|
|
410
|
+
// onboarding-streamline 2026-06-25 — the Caddy-direct zero-SSH boot-issuer
|
|
411
|
+
// fix. The operator-set `hub_settings.hub_origin` (tier-1 in resolveIssuer)
|
|
412
|
+
// MUST also drive the boot-time issuer, else a box whose ONLY canonical-
|
|
413
|
+
// origin source is the DB row boots without an issuer and injects only
|
|
414
|
+
// loopback into supervised children's PARACHUTE_HUB_ORIGINS.
|
|
415
|
+
describe("dbHubOrigin tier (DB hub_settings.hub_origin)", () => {
|
|
416
|
+
test("dbHubOrigin wins over env/platform/expose (mirrors resolveIssuer tier-1)", () => {
|
|
417
|
+
const got = resolveStartupIssuer(
|
|
418
|
+
{ dbHubOrigin: "https://box.sslip.io" },
|
|
419
|
+
{
|
|
420
|
+
PARACHUTE_HUB_ORIGIN: "https://env.example",
|
|
421
|
+
RENDER_EXTERNAL_URL: "https://render.example.onrender.com",
|
|
422
|
+
},
|
|
423
|
+
() => "https://exposed.example",
|
|
424
|
+
);
|
|
425
|
+
expect(got).toBe("https://box.sslip.io");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("dbHubOrigin even wins over explicit opts.issuer (DB is the operator's deliberate canonical)", () => {
|
|
429
|
+
const got = resolveStartupIssuer(
|
|
430
|
+
{ issuer: "https://flag.example", dbHubOrigin: "https://box.sslip.io" },
|
|
431
|
+
{},
|
|
432
|
+
noExpose,
|
|
433
|
+
);
|
|
434
|
+
expect(got).toBe("https://box.sslip.io");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("dbHubOrigin trailing slash is stripped", () => {
|
|
438
|
+
expect(resolveStartupIssuer({ dbHubOrigin: "https://box.sslip.io/" }, {}, noExpose)).toBe(
|
|
439
|
+
"https://box.sslip.io",
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a loopback dbHubOrigin is rejected (sanitized) → falls through to next tier", () => {
|
|
444
|
+
// A stray loopback value in the DB must NOT pin the issuer to a non-
|
|
445
|
+
// public origin; fall through to env so the box keeps a usable issuer.
|
|
446
|
+
const got = resolveStartupIssuer(
|
|
447
|
+
{ dbHubOrigin: "http://127.0.0.1:1939" },
|
|
448
|
+
{ PARACHUTE_HUB_ORIGIN: "https://env.example" },
|
|
449
|
+
noExpose,
|
|
450
|
+
);
|
|
451
|
+
expect(got).toBe("https://env.example");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a non-http(s) dbHubOrigin is rejected → falls through", () => {
|
|
455
|
+
const got = resolveStartupIssuer(
|
|
456
|
+
{ dbHubOrigin: "ftp://box.example" },
|
|
457
|
+
{},
|
|
458
|
+
() => "https://exposed.example",
|
|
459
|
+
);
|
|
460
|
+
expect(got).toBe("https://exposed.example");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("undefined dbHubOrigin is a no-op (unchanged precedence)", () => {
|
|
464
|
+
expect(
|
|
465
|
+
resolveStartupIssuer(
|
|
466
|
+
{ dbHubOrigin: undefined },
|
|
467
|
+
{ PARACHUTE_HUB_ORIGIN: "https://e.x" },
|
|
468
|
+
noExpose,
|
|
469
|
+
),
|
|
470
|
+
).toBe("https://e.x");
|
|
471
|
+
});
|
|
472
|
+
});
|
|
407
473
|
});
|
|
408
474
|
|
|
409
475
|
describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
|
|
@@ -26,7 +26,7 @@ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
|
26
26
|
import { type ExposeState, readExposeState, writeExposeState } from "../expose-state.ts";
|
|
27
27
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
28
28
|
import { hubFetch } from "../hub-server.ts";
|
|
29
|
-
import { getSetting, setSetting } from "../hub-settings.ts";
|
|
29
|
+
import { getSetting, setHubOrigin, setSetting } from "../hub-settings.ts";
|
|
30
30
|
import { validateAccessToken } from "../jwt-sign.ts";
|
|
31
31
|
import {
|
|
32
32
|
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
@@ -537,6 +537,126 @@ describe("deriveWizardState", () => {
|
|
|
537
537
|
db.close();
|
|
538
538
|
}
|
|
539
539
|
});
|
|
540
|
+
|
|
541
|
+
// --- Caddy-direct: a configured public origin already answers "how reached?"
|
|
542
|
+
// Seed a real vault row so the only step left to decide is expose.
|
|
543
|
+
const seedAdminAndVault = async (db: ReturnType<typeof openHubDb>) => {
|
|
544
|
+
await createUser(db, "owner", "pw");
|
|
545
|
+
writeManifest(
|
|
546
|
+
{
|
|
547
|
+
services: [
|
|
548
|
+
{
|
|
549
|
+
name: "parachute-vault",
|
|
550
|
+
version: "0.1.0",
|
|
551
|
+
port: 1940,
|
|
552
|
+
paths: ["/vault/default"],
|
|
553
|
+
health: "/health",
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
h.manifestPath,
|
|
558
|
+
);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
test("auto-skips expose when a public hub_origin is configured (Caddy-direct / init --hub-origin)", async () => {
|
|
562
|
+
// Caddy-direct box: `parachute init --hub-origin https://host` persisted a
|
|
563
|
+
// real public origin to hub_settings.hub_origin BEFORE the wizard opened.
|
|
564
|
+
// No Render/Fly env var, no live tailscale exposure. That origin already
|
|
565
|
+
// answers "how will this hub be reached?" — the wizard must skip the expose
|
|
566
|
+
// step (account → vault → done) rather than re-asking.
|
|
567
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
568
|
+
try {
|
|
569
|
+
await seedAdminAndVault(db);
|
|
570
|
+
setHubOrigin(db, "https://box.example.com");
|
|
571
|
+
const s = deriveWizardState({
|
|
572
|
+
db,
|
|
573
|
+
manifestPath: h.manifestPath,
|
|
574
|
+
env: {},
|
|
575
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
576
|
+
});
|
|
577
|
+
expect(s.step).toBe("done");
|
|
578
|
+
expect(s.hasExposeMode).toBe(true);
|
|
579
|
+
// Seeded "public" (not localhost/tailnet) — a Caddy-fronted box is public.
|
|
580
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
581
|
+
} finally {
|
|
582
|
+
db.close();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("auto-skips expose when PARACHUTE_HUB_ORIGIN env is a public origin (env-var Caddy box)", async () => {
|
|
587
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
588
|
+
try {
|
|
589
|
+
await seedAdminAndVault(db);
|
|
590
|
+
const s = deriveWizardState({
|
|
591
|
+
db,
|
|
592
|
+
manifestPath: h.manifestPath,
|
|
593
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://box.example.com" },
|
|
594
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
595
|
+
});
|
|
596
|
+
expect(s.step).toBe("done");
|
|
597
|
+
expect(s.hasExposeMode).toBe(true);
|
|
598
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
599
|
+
} finally {
|
|
600
|
+
db.close();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("does NOT skip expose when hub_origin is loopback (laptop / dev box still asks)", async () => {
|
|
605
|
+
// A loopback hub_origin must NOT count as "reached publicly" —
|
|
606
|
+
// sanitizePublicOrigin rejects 127.0.0.1/localhost/::1/0.0.0.0, so the
|
|
607
|
+
// expose step still renders and the operator chooses.
|
|
608
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
609
|
+
try {
|
|
610
|
+
await seedAdminAndVault(db);
|
|
611
|
+
setHubOrigin(db, "http://127.0.0.1:1939");
|
|
612
|
+
const s = deriveWizardState({
|
|
613
|
+
db,
|
|
614
|
+
manifestPath: h.manifestPath,
|
|
615
|
+
env: {},
|
|
616
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
617
|
+
});
|
|
618
|
+
expect(s.step).toBe("expose");
|
|
619
|
+
expect(s.hasExposeMode).toBe(false);
|
|
620
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
621
|
+
} finally {
|
|
622
|
+
db.close();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("does NOT skip expose when no origin is configured (unset → still asks)", async () => {
|
|
627
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
628
|
+
try {
|
|
629
|
+
await seedAdminAndVault(db);
|
|
630
|
+
const s = deriveWizardState({
|
|
631
|
+
db,
|
|
632
|
+
manifestPath: h.manifestPath,
|
|
633
|
+
env: {},
|
|
634
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
635
|
+
});
|
|
636
|
+
expect(s.step).toBe("expose");
|
|
637
|
+
expect(s.hasExposeMode).toBe(false);
|
|
638
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
639
|
+
} finally {
|
|
640
|
+
db.close();
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("does NOT skip expose when PARACHUTE_HUB_ORIGIN env is loopback", async () => {
|
|
645
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
646
|
+
try {
|
|
647
|
+
await seedAdminAndVault(db);
|
|
648
|
+
const s = deriveWizardState({
|
|
649
|
+
db,
|
|
650
|
+
manifestPath: h.manifestPath,
|
|
651
|
+
env: { PARACHUTE_HUB_ORIGIN: "http://localhost:1939" },
|
|
652
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
653
|
+
});
|
|
654
|
+
expect(s.step).toBe("expose");
|
|
655
|
+
expect(s.hasExposeMode).toBe(false);
|
|
656
|
+
} finally {
|
|
657
|
+
db.close();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
540
660
|
});
|
|
541
661
|
|
|
542
662
|
// --- GET /admin/setup ----------------------------------------------------
|