@openparachute/hub 0.7.6-rc.1 → 0.7.6-rc.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__/doctor.test.ts +131 -0
- package/src/__tests__/hub-instance.test.ts +297 -0
- package/src/__tests__/hub-server.test.ts +169 -0
- package/src/__tests__/status-supervisor.test.ts +112 -0
- package/src/commands/doctor.ts +167 -4
- package/src/commands/serve.ts +52 -0
- package/src/commands/status.ts +42 -1
- package/src/hub-instance.ts +365 -0
- package/src/hub-server.ts +89 -1
package/package.json
CHANGED
|
@@ -753,3 +753,134 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
|
|
|
753
753
|
}
|
|
754
754
|
});
|
|
755
755
|
});
|
|
756
|
+
|
|
757
|
+
describe("doctor — loopback-hijack check (hub#737)", () => {
|
|
758
|
+
test("no hub-instance.json → PASS (benign; the Hub check owns 'down') — #717", async () => {
|
|
759
|
+
const h = makeHarness();
|
|
760
|
+
try {
|
|
761
|
+
seedCurrentManifest(h.manifestPath);
|
|
762
|
+
seedOperatorToken(h.configDir);
|
|
763
|
+
// No instance file seeded, no seams overridden — defaults read the empty
|
|
764
|
+
// sandbox and short-circuit before any real network/lsof.
|
|
765
|
+
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
766
|
+
expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
|
|
767
|
+
expect(code).toBe(0);
|
|
768
|
+
} finally {
|
|
769
|
+
h.cleanup();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("loopback nonce matches ours + single listener → PASS", async () => {
|
|
774
|
+
const h = makeHarness();
|
|
775
|
+
try {
|
|
776
|
+
seedCurrentManifest(h.manifestPath);
|
|
777
|
+
seedOperatorToken(h.configDir);
|
|
778
|
+
const { code, checks } = await runDoctor(
|
|
779
|
+
h,
|
|
780
|
+
healthyDeps({
|
|
781
|
+
readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
|
|
782
|
+
probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
|
|
783
|
+
countHubListeners: () => 1,
|
|
784
|
+
}),
|
|
785
|
+
);
|
|
786
|
+
const c = byName(checks, "loopback-hijack");
|
|
787
|
+
expect(c?.status).toBe("pass");
|
|
788
|
+
expect(c?.detail).toContain("instance nonce");
|
|
789
|
+
expect(code).toBe(0);
|
|
790
|
+
} finally {
|
|
791
|
+
h.cleanup();
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("loopback nonce MISMATCH → FAIL with lsof/orb remediation + incident ref", async () => {
|
|
796
|
+
const h = makeHarness();
|
|
797
|
+
try {
|
|
798
|
+
seedCurrentManifest(h.manifestPath);
|
|
799
|
+
seedOperatorToken(h.configDir);
|
|
800
|
+
const { code, checks } = await runDoctor(
|
|
801
|
+
h,
|
|
802
|
+
healthyDeps({
|
|
803
|
+
readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
|
|
804
|
+
probeLoopbackInstance: async () => ({
|
|
805
|
+
reachable: true,
|
|
806
|
+
status: 200,
|
|
807
|
+
instance: "rogue-hub",
|
|
808
|
+
}),
|
|
809
|
+
countHubListeners: () => 2,
|
|
810
|
+
}),
|
|
811
|
+
);
|
|
812
|
+
const c = byName(checks, "loopback-hijack");
|
|
813
|
+
expect(c?.status).toBe("fail");
|
|
814
|
+
expect(c?.detail).toContain("rogue-hub");
|
|
815
|
+
expect(c?.detail).toContain("2 listeners");
|
|
816
|
+
expect(c?.detail).toContain("hub#737");
|
|
817
|
+
expect(c?.fix).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
|
|
818
|
+
expect(c?.fix).toContain("orb list");
|
|
819
|
+
expect(code).toBe(1);
|
|
820
|
+
} finally {
|
|
821
|
+
h.cleanup();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("foreign process answering with NO nonce → FAIL (the OrbStack container-hub shape)", async () => {
|
|
826
|
+
const h = makeHarness();
|
|
827
|
+
try {
|
|
828
|
+
seedCurrentManifest(h.manifestPath);
|
|
829
|
+
seedOperatorToken(h.configDir);
|
|
830
|
+
const { checks } = await runDoctor(
|
|
831
|
+
h,
|
|
832
|
+
healthyDeps({
|
|
833
|
+
readInstanceRecord: () => ({ instance: "ours", pid: 1, port: 1939, startedAt: "" }),
|
|
834
|
+
probeLoopbackInstance: async () => ({ reachable: true, status: 200 }),
|
|
835
|
+
countHubListeners: () => undefined, // lsof indeterminate — still FAILs on the nonce alone
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
const c = byName(checks, "loopback-hijack");
|
|
839
|
+
expect(c?.status).toBe("fail");
|
|
840
|
+
expect(c?.detail).toContain("foreign process");
|
|
841
|
+
} finally {
|
|
842
|
+
h.cleanup();
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("nonce matches but a SECOND listener exists → WARN (latent shadow, not FAIL)", async () => {
|
|
847
|
+
const h = makeHarness();
|
|
848
|
+
try {
|
|
849
|
+
seedCurrentManifest(h.manifestPath);
|
|
850
|
+
seedOperatorToken(h.configDir);
|
|
851
|
+
const { code, checks } = await runDoctor(
|
|
852
|
+
h,
|
|
853
|
+
healthyDeps({
|
|
854
|
+
readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
|
|
855
|
+
probeLoopbackInstance: async () => ({ reachable: true, status: 200, instance: "n1" }),
|
|
856
|
+
countHubListeners: () => 2,
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
859
|
+
const c = byName(checks, "loopback-hijack");
|
|
860
|
+
expect(c?.status).toBe("warn");
|
|
861
|
+
expect(c?.detail).toContain("2 listeners");
|
|
862
|
+
// WARN never fails the exit code.
|
|
863
|
+
expect(code).toBe(0);
|
|
864
|
+
} finally {
|
|
865
|
+
h.cleanup();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("record present but loopback unreachable → PASS (defers to the Hub check)", async () => {
|
|
870
|
+
const h = makeHarness();
|
|
871
|
+
try {
|
|
872
|
+
seedCurrentManifest(h.manifestPath);
|
|
873
|
+
seedOperatorToken(h.configDir);
|
|
874
|
+
const { checks } = await runDoctor(
|
|
875
|
+
h,
|
|
876
|
+
healthyDeps({
|
|
877
|
+
readInstanceRecord: () => ({ instance: "n1", pid: 1, port: 1939, startedAt: "" }),
|
|
878
|
+
probeLoopbackInstance: async () => ({ reachable: false }),
|
|
879
|
+
}),
|
|
880
|
+
);
|
|
881
|
+
expect(byName(checks, "loopback-hijack")?.status).toBe("pass");
|
|
882
|
+
} finally {
|
|
883
|
+
h.cleanup();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type HubInstanceRecord,
|
|
7
|
+
type LoopbackProbe,
|
|
8
|
+
type SelfProbeState,
|
|
9
|
+
armHubSelfProbe,
|
|
10
|
+
classifyLoopback,
|
|
11
|
+
clearHubInstanceFile,
|
|
12
|
+
generateInstanceNonce,
|
|
13
|
+
hijackAlertMessage,
|
|
14
|
+
hubInstancePath,
|
|
15
|
+
probeLoopbackInstance,
|
|
16
|
+
readHubInstanceFile,
|
|
17
|
+
writeHubInstanceFile,
|
|
18
|
+
} from "../hub-instance.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* hub#737 loopback-hijack detection. The nonce file is the linchpin — an
|
|
22
|
+
* external process learns THIS hub's true identity from disk, then compares it
|
|
23
|
+
* to what a (possibly hijacked) loopback /health returns. Every side effect
|
|
24
|
+
* (fs, network) is exercised against a tmp dir / injected fetch.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function makeDir(): { configDir: string; cleanup: () => void } {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "parachute-instance-test-"));
|
|
29
|
+
return { configDir: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function record(over: Partial<HubInstanceRecord> = {}): HubInstanceRecord {
|
|
33
|
+
return {
|
|
34
|
+
instance: "nonce-1",
|
|
35
|
+
pid: 4242,
|
|
36
|
+
port: 1939,
|
|
37
|
+
startedAt: "2026-07-02T00:00:00.000Z",
|
|
38
|
+
...over,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("nonce + instance file", () => {
|
|
43
|
+
test("generateInstanceNonce yields distinct UUID-shaped values", () => {
|
|
44
|
+
const a = generateInstanceNonce();
|
|
45
|
+
const b = generateInstanceNonce();
|
|
46
|
+
expect(a).not.toBe(b);
|
|
47
|
+
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("write → read round-trips the record (incl. selfProbe)", () => {
|
|
51
|
+
const { configDir, cleanup } = makeDir();
|
|
52
|
+
try {
|
|
53
|
+
const sp: SelfProbeState = {
|
|
54
|
+
status: "ok",
|
|
55
|
+
checkedAt: "2026-07-02T00:01:00.000Z",
|
|
56
|
+
};
|
|
57
|
+
expect(writeHubInstanceFile(record({ selfProbe: sp }), { configDir })).toBe(true);
|
|
58
|
+
const back = readHubInstanceFile(configDir);
|
|
59
|
+
expect(back?.instance).toBe("nonce-1");
|
|
60
|
+
expect(back?.port).toBe(1939);
|
|
61
|
+
expect(back?.pid).toBe(4242);
|
|
62
|
+
expect(back?.selfProbe?.status).toBe("ok");
|
|
63
|
+
// Written 0644 (world-readable diagnostic aid, not a secret).
|
|
64
|
+
const raw = readFileSync(hubInstancePath(configDir), "utf8");
|
|
65
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
66
|
+
} finally {
|
|
67
|
+
cleanup();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("read returns null on absent / malformed / instance-less files", () => {
|
|
72
|
+
const { configDir, cleanup } = makeDir();
|
|
73
|
+
try {
|
|
74
|
+
expect(readHubInstanceFile(configDir)).toBeNull(); // absent
|
|
75
|
+
writeFileSync(hubInstancePath(configDir), "not json{");
|
|
76
|
+
expect(readHubInstanceFile(configDir)).toBeNull(); // malformed
|
|
77
|
+
writeFileSync(hubInstancePath(configDir), JSON.stringify({ port: 1939 }));
|
|
78
|
+
expect(readHubInstanceFile(configDir)).toBeNull(); // no instance field
|
|
79
|
+
} finally {
|
|
80
|
+
cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("clearHubInstanceFile removes the file (graceful-shutdown hygiene) + no-ops when absent", () => {
|
|
85
|
+
const { configDir, cleanup } = makeDir();
|
|
86
|
+
try {
|
|
87
|
+
writeHubInstanceFile(record(), { configDir });
|
|
88
|
+
expect(readHubInstanceFile(configDir)).not.toBeNull();
|
|
89
|
+
clearHubInstanceFile(configDir);
|
|
90
|
+
expect(readHubInstanceFile(configDir)).toBeNull();
|
|
91
|
+
// Idempotent — clearing an already-absent file must not throw.
|
|
92
|
+
expect(() => clearHubInstanceFile(configDir)).not.toThrow();
|
|
93
|
+
} finally {
|
|
94
|
+
cleanup();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("write failure is swallowed (returns false, logs) — never throws", () => {
|
|
99
|
+
const logs: string[] = [];
|
|
100
|
+
// An un-writable path (a file where a dir must be) forces the mkdir/write to
|
|
101
|
+
// fail; the helper must degrade, not throw.
|
|
102
|
+
const { configDir, cleanup } = makeDir();
|
|
103
|
+
try {
|
|
104
|
+
const filePath = join(configDir, "blocker");
|
|
105
|
+
writeFileSync(filePath, "x");
|
|
106
|
+
const ok = writeHubInstanceFile(record(), {
|
|
107
|
+
configDir: join(filePath, "nested"),
|
|
108
|
+
log: (l) => logs.push(l),
|
|
109
|
+
});
|
|
110
|
+
expect(ok).toBe(false);
|
|
111
|
+
expect(logs.length).toBe(1);
|
|
112
|
+
} finally {
|
|
113
|
+
cleanup();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("classifyLoopback", () => {
|
|
119
|
+
test("unreachable when the probe didn't answer", () => {
|
|
120
|
+
expect(classifyLoopback("n1", { reachable: false })).toBe("unreachable");
|
|
121
|
+
});
|
|
122
|
+
test("ok when the returned instance matches ours", () => {
|
|
123
|
+
expect(classifyLoopback("n1", { reachable: true, status: 200, instance: "n1" })).toBe("ok");
|
|
124
|
+
});
|
|
125
|
+
test("hijacked when a different instance answers", () => {
|
|
126
|
+
expect(classifyLoopback("n1", { reachable: true, status: 200, instance: "n2" })).toBe(
|
|
127
|
+
"hijacked",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
test("hijacked when a reachable process carries NO instance (old/foreign hub)", () => {
|
|
131
|
+
expect(classifyLoopback("n1", { reachable: true, status: 200 })).toBe("hijacked");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("probeLoopbackInstance", () => {
|
|
136
|
+
test("extracts instance + isHub from a well-formed /health body", async () => {
|
|
137
|
+
const fetchFn = (async () =>
|
|
138
|
+
new Response(JSON.stringify({ service: "parachute-hub", instance: "xyz" }), {
|
|
139
|
+
status: 200,
|
|
140
|
+
})) as unknown as typeof fetch;
|
|
141
|
+
const out = await probeLoopbackInstance(1939, { fetchFn });
|
|
142
|
+
expect(out).toEqual({ reachable: true, status: 200, instance: "xyz", isHub: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("reachable-but-junk body → reachable with no instance (foreign server shape)", async () => {
|
|
146
|
+
const fetchFn = (async () =>
|
|
147
|
+
new Response("<html>not a hub</html>", { status: 200 })) as unknown as typeof fetch;
|
|
148
|
+
const out = await probeLoopbackInstance(1939, { fetchFn });
|
|
149
|
+
expect(out.reachable).toBe(true);
|
|
150
|
+
expect(out.instance).toBeUndefined();
|
|
151
|
+
expect(out.isHub).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("network error → not reachable, never throws", async () => {
|
|
155
|
+
const fetchFn = (async () => {
|
|
156
|
+
throw new Error("ECONNREFUSED");
|
|
157
|
+
}) as unknown as typeof fetch;
|
|
158
|
+
const out = await probeLoopbackInstance(1939, { fetchFn });
|
|
159
|
+
expect(out).toEqual({ reachable: false });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("armHubSelfProbe", () => {
|
|
164
|
+
/** A probe stub that returns a fixed LoopbackProbe. */
|
|
165
|
+
function fixedProbe(p: LoopbackProbe): (port: number) => Promise<LoopbackProbe> {
|
|
166
|
+
return async () => p;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test("ok verdict: writes state, stays quiet (no loud log)", async () => {
|
|
170
|
+
const logs: string[] = [];
|
|
171
|
+
const states: SelfProbeState[] = [];
|
|
172
|
+
const probe = armHubSelfProbe(
|
|
173
|
+
{ port: 1939, nonce: "n1", record: record() },
|
|
174
|
+
{
|
|
175
|
+
probe: fixedProbe({ reachable: true, status: 200, instance: "n1" }),
|
|
176
|
+
writeState: (s) => states.push(s),
|
|
177
|
+
log: (l) => logs.push(l),
|
|
178
|
+
setIntervalFn: () => 0,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
const verdict = await probe.probeOnce();
|
|
182
|
+
expect(verdict).toBe("ok");
|
|
183
|
+
expect(logs).toEqual([]);
|
|
184
|
+
expect(states.at(-1)?.status).toBe("ok");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("hijacked verdict: LOUD alert on EVERY probe + observedInstance persisted", async () => {
|
|
188
|
+
const logs: string[] = [];
|
|
189
|
+
const states: SelfProbeState[] = [];
|
|
190
|
+
const probe = armHubSelfProbe(
|
|
191
|
+
{ port: 1939, nonce: "n1", record: record() },
|
|
192
|
+
{
|
|
193
|
+
probe: fixedProbe({ reachable: true, status: 200, instance: "rogue-9" }),
|
|
194
|
+
writeState: (s) => states.push(s),
|
|
195
|
+
log: (l) => logs.push(l),
|
|
196
|
+
setIntervalFn: () => 0,
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
expect(await probe.probeOnce()).toBe("hijacked");
|
|
200
|
+
expect(await probe.probeOnce()).toBe("hijacked");
|
|
201
|
+
// Repeated verbatim — a hijack is a standing emergency, not a one-shot.
|
|
202
|
+
expect(logs.length).toBe(2);
|
|
203
|
+
expect(logs[0]).toContain("LOOPBACK HIJACK");
|
|
204
|
+
expect(logs[0]).toContain("lsof -nP -iTCP:1939");
|
|
205
|
+
expect(states.at(-1)?.observedInstance).toBe("rogue-9");
|
|
206
|
+
expect(states.at(-1)?.status).toBe("hijacked");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("unreachable verdict: logs ONCE per state change, not per tick", async () => {
|
|
210
|
+
const logs: string[] = [];
|
|
211
|
+
const probe = armHubSelfProbe(
|
|
212
|
+
{ port: 1939, nonce: "n1", record: record() },
|
|
213
|
+
{
|
|
214
|
+
probe: fixedProbe({ reachable: false }),
|
|
215
|
+
writeState: () => {},
|
|
216
|
+
log: (l) => logs.push(l),
|
|
217
|
+
setIntervalFn: () => 0,
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
await probe.probeOnce();
|
|
221
|
+
await probe.probeOnce();
|
|
222
|
+
expect(logs.length).toBe(1); // second consecutive unreachable is silent
|
|
223
|
+
expect(logs[0]).toContain("did not answer");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("recovery: hijacked → ok logs a single 'cleared' line", async () => {
|
|
227
|
+
const logs: string[] = [];
|
|
228
|
+
let current: LoopbackProbe = { reachable: true, status: 200, instance: "rogue" };
|
|
229
|
+
const probe = armHubSelfProbe(
|
|
230
|
+
{ port: 1939, nonce: "n1", record: record() },
|
|
231
|
+
{
|
|
232
|
+
probe: async () => current,
|
|
233
|
+
writeState: () => {},
|
|
234
|
+
log: (l) => logs.push(l),
|
|
235
|
+
setIntervalFn: () => 0,
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
expect(await probe.probeOnce()).toBe("hijacked");
|
|
239
|
+
current = { reachable: true, status: 200, instance: "n1" };
|
|
240
|
+
expect(await probe.probeOnce()).toBe("ok");
|
|
241
|
+
expect(logs.length).toBe(2);
|
|
242
|
+
expect(logs[1]).toContain("Hijack cleared");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("stop() clears the interval handle", () => {
|
|
246
|
+
let cleared: unknown;
|
|
247
|
+
const probe = armHubSelfProbe(
|
|
248
|
+
{ port: 1939, nonce: "n1", record: record() },
|
|
249
|
+
{
|
|
250
|
+
probe: fixedProbe({ reachable: true, instance: "n1" }),
|
|
251
|
+
writeState: () => {},
|
|
252
|
+
setIntervalFn: () => "the-handle",
|
|
253
|
+
clearIntervalFn: (h) => {
|
|
254
|
+
cleared = h;
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
probe.stop();
|
|
259
|
+
expect(cleared).toBe("the-handle");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("default writeState patches selfProbe into the real instance file", async () => {
|
|
263
|
+
const { configDir, cleanup } = makeDir();
|
|
264
|
+
try {
|
|
265
|
+
const rec = record();
|
|
266
|
+
writeHubInstanceFile(rec, { configDir });
|
|
267
|
+
const probe = armHubSelfProbe(
|
|
268
|
+
{ port: 1939, nonce: "n1", record: rec, configDir },
|
|
269
|
+
{
|
|
270
|
+
probe: fixedProbe({ reachable: true, status: 200, instance: "someone-else" }),
|
|
271
|
+
log: () => {},
|
|
272
|
+
setIntervalFn: () => 0,
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
await probe.probeOnce();
|
|
276
|
+
const back = readHubInstanceFile(configDir);
|
|
277
|
+
expect(back?.selfProbe?.status).toBe("hijacked");
|
|
278
|
+
expect(back?.instance).toBe("nonce-1"); // base record preserved
|
|
279
|
+
} finally {
|
|
280
|
+
cleanup();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("hijackAlertMessage", () => {
|
|
286
|
+
test("names the observed instance + the diagnosis commands + incident ref", () => {
|
|
287
|
+
const msg = hijackAlertMessage(1939, "rogue-42");
|
|
288
|
+
expect(msg).toContain("instance=rogue-42");
|
|
289
|
+
expect(msg).toContain("lsof -nP -iTCP:1939 -sTCP:LISTEN");
|
|
290
|
+
expect(msg).toContain("orb list");
|
|
291
|
+
expect(msg).toContain("hub#737");
|
|
292
|
+
});
|
|
293
|
+
test("degrades gracefully when the foreign process carries no nonce", () => {
|
|
294
|
+
const msg = hijackAlertMessage(1939);
|
|
295
|
+
expect(msg).toContain("no hub instance nonce");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
layerOf,
|
|
16
16
|
parseArgs,
|
|
17
17
|
resolveClientIp,
|
|
18
|
+
stripHopByHopHeaders,
|
|
18
19
|
} from "../hub-server.ts";
|
|
19
20
|
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
20
21
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
@@ -139,6 +140,27 @@ describe("hubFetch routing", () => {
|
|
|
139
140
|
}
|
|
140
141
|
});
|
|
141
142
|
|
|
143
|
+
test("/health echoes the instance nonce when set, omits it when absent (hub#737)", async () => {
|
|
144
|
+
const h = makeHarness();
|
|
145
|
+
try {
|
|
146
|
+
// With a nonce threaded in, /health carries it as `instance`.
|
|
147
|
+
const withNonce = (await (
|
|
148
|
+
await hubFetch(h.dir, { instanceNonce: "nonce-abc-123" })(req("/health"))
|
|
149
|
+
).json()) as { service: string; instance?: string };
|
|
150
|
+
expect(withNonce.service).toBe("parachute-hub");
|
|
151
|
+
expect(withNonce.instance).toBe("nonce-abc-123");
|
|
152
|
+
|
|
153
|
+
// Without one (DB-less / dev entrypoint), the field is omitted — additive,
|
|
154
|
+
// so no strict /health consumer breaks.
|
|
155
|
+
const withoutNonce = (await (await hubFetch(h.dir, {})(req("/health"))).json()) as {
|
|
156
|
+
instance?: string;
|
|
157
|
+
};
|
|
158
|
+
expect(withoutNonce.instance).toBeUndefined();
|
|
159
|
+
} finally {
|
|
160
|
+
h.cleanup();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
142
164
|
test("/health surfaces db:error:path-gone when the proactive probe sees a wiped path (#610)", async () => {
|
|
143
165
|
// The ghost-fd lie: SELECT 1 still succeeds against the unlinked inode, so
|
|
144
166
|
// probeDbLiveness alone would report ok. probeDbPath stat()s the PATH and
|
|
@@ -5963,6 +5985,153 @@ describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP
|
|
|
5963
5985
|
});
|
|
5964
5986
|
});
|
|
5965
5987
|
|
|
5988
|
+
describe("stripHopByHopHeaders (hub#738)", () => {
|
|
5989
|
+
test("drops the standard hop-by-hop set", () => {
|
|
5990
|
+
const h = new Headers({
|
|
5991
|
+
connection: "keep-alive",
|
|
5992
|
+
"keep-alive": "timeout=5",
|
|
5993
|
+
"proxy-authorization": "Basic xxx",
|
|
5994
|
+
te: "trailers",
|
|
5995
|
+
"transfer-encoding": "chunked",
|
|
5996
|
+
upgrade: "h2c",
|
|
5997
|
+
authorization: "Bearer keep-me",
|
|
5998
|
+
"content-type": "application/json",
|
|
5999
|
+
});
|
|
6000
|
+
stripHopByHopHeaders(h);
|
|
6001
|
+
expect(h.has("connection")).toBe(false);
|
|
6002
|
+
expect(h.has("keep-alive")).toBe(false);
|
|
6003
|
+
expect(h.has("proxy-authorization")).toBe(false);
|
|
6004
|
+
expect(h.has("te")).toBe(false);
|
|
6005
|
+
expect(h.has("transfer-encoding")).toBe(false);
|
|
6006
|
+
expect(h.has("upgrade")).toBe(false);
|
|
6007
|
+
// End-to-end headers survive.
|
|
6008
|
+
expect(h.get("authorization")).toBe("Bearer keep-me");
|
|
6009
|
+
expect(h.get("content-type")).toBe("application/json");
|
|
6010
|
+
});
|
|
6011
|
+
|
|
6012
|
+
test("drops headers NAMED in the Connection field-value (RFC 9110 §7.6.1)", () => {
|
|
6013
|
+
const h = new Headers({
|
|
6014
|
+
connection: "close, X-Custom-Hop",
|
|
6015
|
+
"x-custom-hop": "should-go",
|
|
6016
|
+
"x-real-header": "should-stay",
|
|
6017
|
+
});
|
|
6018
|
+
stripHopByHopHeaders(h);
|
|
6019
|
+
expect(h.has("connection")).toBe(false);
|
|
6020
|
+
expect(h.has("x-custom-hop")).toBe(false);
|
|
6021
|
+
expect(h.get("x-real-header")).toBe("should-stay");
|
|
6022
|
+
});
|
|
6023
|
+
});
|
|
6024
|
+
|
|
6025
|
+
describe("proxy upstream keep-alive (hub#738)", () => {
|
|
6026
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
6027
|
+
|
|
6028
|
+
function writeUpstreamService(h: Harness, port: number): void {
|
|
6029
|
+
writeManifest(
|
|
6030
|
+
{
|
|
6031
|
+
services: [
|
|
6032
|
+
{
|
|
6033
|
+
name: "keepalive-svc",
|
|
6034
|
+
port,
|
|
6035
|
+
paths: ["/keepalive-svc"],
|
|
6036
|
+
health: "/keepalive-svc/health",
|
|
6037
|
+
version: "0.1.0",
|
|
6038
|
+
},
|
|
6039
|
+
],
|
|
6040
|
+
},
|
|
6041
|
+
h.manifestPath,
|
|
6042
|
+
);
|
|
6043
|
+
}
|
|
6044
|
+
|
|
6045
|
+
test("200 proxied requests reuse a handful of upstream sockets even when the client sends Connection: close", async () => {
|
|
6046
|
+
// Regression for hub#738: the proxy forwarded the client's hop-by-hop
|
|
6047
|
+
// `Connection: close` verbatim, so Bun opened a FRESH ephemeral socket per
|
|
6048
|
+
// proxied request instead of reusing its per-origin pool → 16k+ TIME_WAIT
|
|
6049
|
+
// → host-wide port exhaustion (the 2026-07-02 incident, amplifying an agent
|
|
6050
|
+
// reconciler 401 loop). We count the distinct client (ephemeral) ports the
|
|
6051
|
+
// upstream sees: a working pool reuses one (occasionally a couple); the bug
|
|
6052
|
+
// produced ~N distinct ports.
|
|
6053
|
+
const seenPorts = new Set<number>();
|
|
6054
|
+
const upstream = Bun.serve({
|
|
6055
|
+
port: 0,
|
|
6056
|
+
hostname: "127.0.0.1",
|
|
6057
|
+
fetch: (r, server) => {
|
|
6058
|
+
const ip = server.requestIP(r);
|
|
6059
|
+
if (ip) seenPorts.add(ip.port);
|
|
6060
|
+
return new Response("unauthorized", { status: 401 }); // the incident's 401 loop
|
|
6061
|
+
},
|
|
6062
|
+
});
|
|
6063
|
+
const h = makeHarness();
|
|
6064
|
+
try {
|
|
6065
|
+
writeUpstreamService(h, upstream.port as number);
|
|
6066
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
6067
|
+
const N = 200;
|
|
6068
|
+
for (let i = 0; i < N; i++) {
|
|
6069
|
+
const res = await fetcher(
|
|
6070
|
+
req("/keepalive-svc/x", { headers: { connection: "close" } }),
|
|
6071
|
+
fakeServer("127.0.0.1"),
|
|
6072
|
+
);
|
|
6073
|
+
await res.arrayBuffer(); // fully drain so the socket returns to the pool
|
|
6074
|
+
}
|
|
6075
|
+
expect(seenPorts.size).toBeLessThanOrEqual(8);
|
|
6076
|
+
} finally {
|
|
6077
|
+
upstream.stop(true);
|
|
6078
|
+
h.cleanup();
|
|
6079
|
+
}
|
|
6080
|
+
});
|
|
6081
|
+
|
|
6082
|
+
test("a client hang-up mid-stream aborts the upstream fetch (socket released, not left streaming to nobody)", async () => {
|
|
6083
|
+
// Without forwarding req.signal, an aborted client left the upstream
|
|
6084
|
+
// streaming its whole body to a gone client and holding the socket. We
|
|
6085
|
+
// forward the signal so the upstream is torn down (cancel() fires or the
|
|
6086
|
+
// enqueue loop errors) instead of running to completion.
|
|
6087
|
+
let tornDown = false;
|
|
6088
|
+
const upstream = Bun.serve({
|
|
6089
|
+
port: 0,
|
|
6090
|
+
hostname: "127.0.0.1",
|
|
6091
|
+
fetch: () =>
|
|
6092
|
+
new Response(
|
|
6093
|
+
new ReadableStream({
|
|
6094
|
+
async start(controller) {
|
|
6095
|
+
try {
|
|
6096
|
+
for (let i = 0; i < 40; i++) {
|
|
6097
|
+
controller.enqueue(new TextEncoder().encode(`chunk ${i}\n`));
|
|
6098
|
+
await Bun.sleep(10);
|
|
6099
|
+
}
|
|
6100
|
+
controller.close();
|
|
6101
|
+
} catch {
|
|
6102
|
+
tornDown = true; // enqueue threw after the peer dropped
|
|
6103
|
+
}
|
|
6104
|
+
},
|
|
6105
|
+
cancel() {
|
|
6106
|
+
tornDown = true; // Bun cancelled the source on disconnect
|
|
6107
|
+
},
|
|
6108
|
+
}),
|
|
6109
|
+
{ headers: { "content-type": "text/plain" } },
|
|
6110
|
+
),
|
|
6111
|
+
});
|
|
6112
|
+
const h = makeHarness();
|
|
6113
|
+
try {
|
|
6114
|
+
writeUpstreamService(h, upstream.port as number);
|
|
6115
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
6116
|
+
const ac = new AbortController();
|
|
6117
|
+
const res = await fetcher(
|
|
6118
|
+
req("/keepalive-svc/stream", { signal: ac.signal }),
|
|
6119
|
+
fakeServer("127.0.0.1"),
|
|
6120
|
+
);
|
|
6121
|
+
const reader = res.body!.getReader();
|
|
6122
|
+
await reader.read(); // consume one chunk, then hang up
|
|
6123
|
+
ac.abort();
|
|
6124
|
+
// Full stream is 40 * 10ms = 400ms; wait past it. With the signal
|
|
6125
|
+
// forwarded the upstream tears down early; without, it runs to close.
|
|
6126
|
+
await Bun.sleep(700);
|
|
6127
|
+
expect(tornDown).toBe(true);
|
|
6128
|
+
} finally {
|
|
6129
|
+
upstream.stop(true);
|
|
6130
|
+
h.cleanup();
|
|
6131
|
+
}
|
|
6132
|
+
});
|
|
6133
|
+
});
|
|
6134
|
+
|
|
5966
6135
|
describe("resolveClientIp (H2)", () => {
|
|
5967
6136
|
test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
|
|
5968
6137
|
const r = req("/", {
|