@openparachute/hub 0.5.1 → 0.5.7
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__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- package/src/sessions.ts +19 -0
package/src/port-assign.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
|
-
import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
|
|
2
1
|
import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN, PORT_RESERVATIONS } from "./service-spec.ts";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
* The
|
|
6
|
-
* picks a port for each service
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* The hub is the port authority for Parachute services. At install time it
|
|
5
|
+
* picks a port for each service and reflects the chosen port in
|
|
6
|
+
* `services.json`. That manifest is the single source of truth at boot
|
|
7
|
+
* (parachute-scribe#41 / parachute-agent#146 / parachute-agent#148): each
|
|
8
|
+
* service reads `services.json` first and only falls through to lower-tier
|
|
9
|
+
* sources (its own config, the bare `PORT` env, the compiled-in canonical
|
|
10
|
+
* default) when the manifest doesn't pin a port. So writing PORT into the
|
|
11
|
+
* service's `.env` is no longer load-bearing — services.json wins.
|
|
12
|
+
*
|
|
13
|
+
* Pre-hub#206, install also wrote `PORT=<port>` into `~/.parachute/<svc>/.env`
|
|
14
|
+
* and preserved any pre-existing value across re-installs ("operator-edited
|
|
15
|
+
* port survives upgrade"). Post-#206 (option A from the design discussion):
|
|
16
|
+
* we stop writing PORT to `.env` entirely. The duplicate state was at best
|
|
17
|
+
* dead weight and at worst a source of drift — operators editing
|
|
18
|
+
* `services.json` would get re-stamped by a stale `.env` PORT on the next
|
|
19
|
+
* `parachute install`. Existing `.env` PORT lines stay where they are
|
|
20
|
+
* (harmless — service-side resolvePort reads services.json first; the bare
|
|
21
|
+
* PORT env tier is the lowest priority).
|
|
11
22
|
*
|
|
12
23
|
* Why up-front assignment instead of detect-on-collision-at-boot:
|
|
13
24
|
* - Two services racing to bind the same port produces an opaque "address in
|
|
14
|
-
* use" deep inside one of them. Assigning at install lets the
|
|
25
|
+
* use" deep inside one of them. Assigning at install lets the hub keep
|
|
15
26
|
* a single coherent picture of who owns what.
|
|
16
27
|
* - The hub's reverse-proxy targets are computed from services.json. If a
|
|
17
28
|
* service silently falls back to a different port at runtime, the hub
|
|
18
29
|
* proxies to a dead port and the user sees a 502 with no explanation.
|
|
19
|
-
* - Re-installs stay idempotent: the existing `PORT=` in .env wins, so a
|
|
20
|
-
* user who edited their port keeps it across upgrades.
|
|
21
30
|
*/
|
|
22
31
|
|
|
23
32
|
export type AssignmentSource = "canonical" | "fallback-in-range" | "fallback-out-of-range";
|
|
@@ -72,8 +81,6 @@ export function assignPort(
|
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
export interface AssignServicePortOpts {
|
|
75
|
-
/** Path to the service's `.env` file. */
|
|
76
|
-
readonly envPath: string;
|
|
77
84
|
/** Canonical default for this service, or undefined for third-party. */
|
|
78
85
|
readonly canonical?: number;
|
|
79
86
|
/** Ports we already know to be taken. */
|
|
@@ -82,41 +89,27 @@ export interface AssignServicePortOpts {
|
|
|
82
89
|
|
|
83
90
|
export interface AssignServicePortResult {
|
|
84
91
|
readonly port: number;
|
|
85
|
-
|
|
86
|
-
* source from `assignPort`. */
|
|
87
|
-
readonly source: "preserved" | AssignmentSource;
|
|
88
|
-
/** True when we wrote PORT into .env on this call. */
|
|
89
|
-
readonly written: boolean;
|
|
92
|
+
readonly source: AssignmentSource;
|
|
90
93
|
/** Warning to surface to the user, if any. */
|
|
91
94
|
readonly warning?: string;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/**
|
|
95
|
-
*
|
|
96
|
-
* - If PORT is already set in .env, preserve it (`source: "preserved"`).
|
|
97
|
-
* Re-installs and user-edited ports survive across upgrades.
|
|
98
|
-
* - Otherwise call `assignPort` and write `PORT=<port>` into .env.
|
|
98
|
+
* Assign a port for a service at install time.
|
|
99
99
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* As of hub#206 this is a thin wrapper over `assignPort`: services.json is
|
|
101
|
+
* the source of truth for service ports (parachute-scribe#41 /
|
|
102
|
+
* parachute-agent#146 / parachute-agent#148 / parachute-patterns#45), so the
|
|
103
|
+
* install path no longer touches the service's `.env`. The wrapper still
|
|
104
|
+
* exists to give the install path a stable seam — `collectOccupiedPorts`
|
|
105
|
+
* feeds into the same shape regardless of how the underlying picker
|
|
106
|
+
* evolves — and to keep the warning return path centralized.
|
|
102
107
|
*/
|
|
103
108
|
export function assignServicePort(opts: AssignServicePortOpts): AssignServicePortResult {
|
|
104
|
-
const env = parseEnvFile(opts.envPath);
|
|
105
|
-
const existing = env.values.PORT;
|
|
106
|
-
if (existing !== undefined && /^[1-9]\d{0,4}$/.test(existing)) {
|
|
107
|
-
const port = Number(existing);
|
|
108
|
-
if (port > 0 && port < 65536) {
|
|
109
|
-
return { port, source: "preserved", written: false };
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
109
|
const assignment = assignPort(opts.canonical, opts.occupied);
|
|
114
|
-
const nextLines = upsertEnvLine(env.lines, "PORT", String(assignment.port));
|
|
115
|
-
writeEnvFile(opts.envPath, nextLines);
|
|
116
110
|
const result: AssignServicePortResult = {
|
|
117
111
|
port: assignment.port,
|
|
118
112
|
source: assignment.source,
|
|
119
|
-
written: true,
|
|
120
113
|
};
|
|
121
114
|
if (assignment.warning) {
|
|
122
115
|
return { ...result, warning: assignment.warning };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-IP rate-limit on `POST /admin/login`. Lands as a floor under brute-force
|
|
3
|
+
* after hub#187 collapsed the public-reach matrix: with a cloudflare tunnel
|
|
4
|
+
* up, `/admin/login` is now reachable from the open internet, and 2FA (#186)
|
|
5
|
+
* is the next PR rather than this one. A 5-attempts-per-15-minute bucket per
|
|
6
|
+
* IP is the standard login-form floor; it's not the primary defense, just the
|
|
7
|
+
* one that turns "infinite credential grinding" into "rotate IPs".
|
|
8
|
+
*
|
|
9
|
+
* Shape: sliding window. Each key keeps the last N attempt timestamps; on a
|
|
10
|
+
* new attempt we prune anything older than the window, count what remains,
|
|
11
|
+
* decide allow / deny, and (on allow) append the current timestamp. Sliding
|
|
12
|
+
* gives an exact `Retry-After` (seconds until the *oldest* in-window
|
|
13
|
+
* timestamp falls off) rather than the rough next-refill of a token bucket.
|
|
14
|
+
*
|
|
15
|
+
* Storage: process-local `Map`. Persistence isn't worth a SQLite write per
|
|
16
|
+
* attempt — process restart is itself a defense (the attacker loses all
|
|
17
|
+
* progress against any one bucket). Memory is bounded by an opportunistic
|
|
18
|
+
* sweep of empty buckets every time we touch the map, so an attacker
|
|
19
|
+
* cycling through IPs can't grow the map without also leaving timestamps in
|
|
20
|
+
* each.
|
|
21
|
+
*
|
|
22
|
+
* Auth-stage independence: callers MUST gate via `checkAndRecord` *before*
|
|
23
|
+
* the credential check. A 2FA (or password) failure should count toward the
|
|
24
|
+
* same bucket as a wrong password — an attacker who knows the password
|
|
25
|
+
* shouldn't get unlimited grinding against backup codes.
|
|
26
|
+
*
|
|
27
|
+
* Layer-independent: the limiter applies on every layer (loopback included).
|
|
28
|
+
* A buggy script hammering loopback gets 429'd just like a public attacker.
|
|
29
|
+
* The one wrinkle is `tailscale serve` proxying from `127.0.0.1`, so all
|
|
30
|
+
* tailnet logins share the loopback bucket — acceptable because tailnet is
|
|
31
|
+
* authed at the network layer and brute-force isn't the threat model there.
|
|
32
|
+
*
|
|
33
|
+
* Testable: inject the clock via `now` so the tests can advance time
|
|
34
|
+
* deterministically without `setTimeout`. Module-level state is exported via
|
|
35
|
+
* `__resetForTests` so the test file can reset between cases without
|
|
36
|
+
* recreating the module.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Window length: 15 minutes. */
|
|
40
|
+
export const WINDOW_MS = 15 * 60 * 1000;
|
|
41
|
+
/** Attempts allowed per window. 6th attempt within the window is denied. */
|
|
42
|
+
export const MAX_ATTEMPTS = 5;
|
|
43
|
+
/** Sentinel for the IP-extraction priority chain when nothing parsed. */
|
|
44
|
+
export const UNKNOWN_IP_SENTINEL = "unknown";
|
|
45
|
+
|
|
46
|
+
export interface RateLimitResult {
|
|
47
|
+
/** True if the attempt is admitted; caller proceeds to credential check. */
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Seconds until the bucket reset (oldest in-window timestamp falls off).
|
|
51
|
+
* Only set when `allowed` is false. Always >= 1: the deny branch only
|
|
52
|
+
* fires when the oldest in-window timestamp is strictly inside the
|
|
53
|
+
* window, so `Math.ceil(positiveMs / 1000) >= 1` naturally. The
|
|
54
|
+
* `Math.max(1, ...)` clamp inside `checkAndRecord` is a defense-in-depth
|
|
55
|
+
* floor in case the filter logic is ever loosened.
|
|
56
|
+
*/
|
|
57
|
+
retryAfterSeconds?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Module-level state. `Map<key, attemptsTimestampsMs[]>`. Each array holds
|
|
62
|
+
* raw `Date.now()`-style millisecond timestamps for in-window attempts.
|
|
63
|
+
*/
|
|
64
|
+
const buckets: Map<string, number[]> = new Map();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Record an attempt and return whether it's admitted. `key` is typically a
|
|
68
|
+
* client IP from `clientIpFromRequest`. `now` is injected for testability;
|
|
69
|
+
* production callers pass `new Date()`.
|
|
70
|
+
*
|
|
71
|
+
* Behavior:
|
|
72
|
+
* - Prune timestamps older than `now - WINDOW_MS`.
|
|
73
|
+
* - If remaining count >= MAX_ATTEMPTS, deny with `retryAfterSeconds`
|
|
74
|
+
* pointing at the oldest in-window timestamp's age-out moment. The
|
|
75
|
+
* denied attempt is NOT recorded — we don't want a flood of denials
|
|
76
|
+
* pushing the reset further into the future. The window stays anchored
|
|
77
|
+
* to the actual 5 admitted attempts.
|
|
78
|
+
* - Otherwise admit, append the current timestamp, return allowed.
|
|
79
|
+
*/
|
|
80
|
+
export function checkAndRecord(key: string, now: Date): RateLimitResult {
|
|
81
|
+
const cutoff = now.getTime() - WINDOW_MS;
|
|
82
|
+
const existing = buckets.get(key) ?? [];
|
|
83
|
+
// Drop anything that fell out of the window. Mutating a copy keeps the
|
|
84
|
+
// semantics clear: `pruned` is always the in-window slice.
|
|
85
|
+
const pruned = existing.filter((t) => t > cutoff);
|
|
86
|
+
|
|
87
|
+
if (pruned.length >= MAX_ATTEMPTS) {
|
|
88
|
+
// Reset moment = oldest in-window attempt + WINDOW_MS. `pruned[0]` is
|
|
89
|
+
// the oldest because timestamps are appended in order. Subtract `now`
|
|
90
|
+
// for seconds-until-reset. The unclamped value is provably >= 1 in this
|
|
91
|
+
// branch (see below), but `Math.max(1, ...)` stays as a defense-in-depth
|
|
92
|
+
// floor so Retry-After never reads 0 if the filter logic is ever
|
|
93
|
+
// loosened. Reasoning: the deny branch requires `pruned.length >=
|
|
94
|
+
// MAX_ATTEMPTS`, which implies every entry survived the `t > cutoff`
|
|
95
|
+
// filter, i.e. `pruned[0] > now - WINDOW_MS` strictly, i.e. `resetAtMs -
|
|
96
|
+
// now > 0` strictly, i.e. `Math.ceil(positive / 1000) >= 1`.
|
|
97
|
+
const resetAtMs = (pruned[0] ?? now.getTime()) + WINDOW_MS;
|
|
98
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((resetAtMs - now.getTime()) / 1000));
|
|
99
|
+
// Denied attempt: the bucket is still full (>= MAX_ATTEMPTS in-window
|
|
100
|
+
// entries), so unconditionally re-store the pruned slice. Persisting the
|
|
101
|
+
// prune keeps stale entries from leaking forward past their window. We
|
|
102
|
+
// do NOT delete here — that branch is structurally unreachable in deny
|
|
103
|
+
// because deny requires a non-empty `pruned`.
|
|
104
|
+
buckets.set(key, pruned);
|
|
105
|
+
return { allowed: false, retryAfterSeconds };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pruned.push(now.getTime());
|
|
109
|
+
buckets.set(key, pruned);
|
|
110
|
+
return { allowed: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Test-only escape hatch. Production code never calls this; the test file
|
|
115
|
+
* uses it between cases so module-level state doesn't leak across tests.
|
|
116
|
+
*/
|
|
117
|
+
export function __resetForTests(): void {
|
|
118
|
+
buckets.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extract the client IP from request headers, in priority order:
|
|
123
|
+
* 1. `CF-Connecting-IP` — cloudflared sets this on every forwarded request,
|
|
124
|
+
* and it's the actual client IP (not the cloudflare edge). This is the
|
|
125
|
+
* authoritative source on cloudflare-fronted hubs.
|
|
126
|
+
* 2. `X-Forwarded-For` first hop — defensive fallback for any non-cloudflare
|
|
127
|
+
* proxy fronting hub. The first comma-separated value is the original
|
|
128
|
+
* client; later values are intermediate proxies.
|
|
129
|
+
* 3. `Forwarded` (RFC 7239) — not parsed; covered by the comment in the
|
|
130
|
+
* issue's IP-priority list as deferred. `X-Forwarded-For` covers the
|
|
131
|
+
* operator-deploy reality (every common reverse proxy sets it).
|
|
132
|
+
* 4. Fall through to `UNKNOWN_IP_SENTINEL`. Hub binds `127.0.0.1`, so the
|
|
133
|
+
* "request remote addr" case the spec mentions doesn't materialize at
|
|
134
|
+
* this layer (Bun's `requestIP` is on `Server`, not `Request`, and
|
|
135
|
+
* everything reaching here is either loopback or proxy-injected). The
|
|
136
|
+
* sentinel ensures the limiter always has a key — all sentinel
|
|
137
|
+
* requests share one bucket, which is the intended bound for
|
|
138
|
+
* direct-loopback callers (curl from the same host).
|
|
139
|
+
*
|
|
140
|
+
* Returns a trimmed string. Empty / whitespace-only header values are
|
|
141
|
+
* treated as absent.
|
|
142
|
+
*/
|
|
143
|
+
export function clientIpFromRequest(req: Request): string {
|
|
144
|
+
const cfConnectingIp = trimOrNull(req.headers.get("cf-connecting-ip"));
|
|
145
|
+
if (cfConnectingIp) return cfConnectingIp;
|
|
146
|
+
|
|
147
|
+
const xff = req.headers.get("x-forwarded-for");
|
|
148
|
+
if (xff) {
|
|
149
|
+
// First hop only. RFC 7239 / X-Forwarded-For convention is
|
|
150
|
+
// `client, proxy1, proxy2`; the leftmost entry is the original client.
|
|
151
|
+
const first = xff.split(",")[0];
|
|
152
|
+
const trimmed = trimOrNull(first ?? "");
|
|
153
|
+
if (trimmed) return trimmed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return UNKNOWN_IP_SENTINEL;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function trimOrNull(s: string | null | undefined): string | null {
|
|
160
|
+
if (s == null) return null;
|
|
161
|
+
const t = s.trim();
|
|
162
|
+
return t.length > 0 ? t : null;
|
|
163
|
+
}
|
package/src/service-spec.ts
CHANGED
|
@@ -19,20 +19,29 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
19
19
|
* (see hub-control.ts) — if something else is on 1939 we fail loudly rather
|
|
20
20
|
* than walking up into a service's slot.
|
|
21
21
|
*
|
|
22
|
-
* **
|
|
23
|
-
* install time and
|
|
24
|
-
*
|
|
25
|
-
* boot binds the port the CLI assigned. Algorithm (see port-assign.ts):
|
|
22
|
+
* **Hub is the port authority.** `parachute install <svc>` picks the port
|
|
23
|
+
* at install time and reflects it in `services.json`. Algorithm (see
|
|
24
|
+
* port-assign.ts):
|
|
26
25
|
*
|
|
27
26
|
* 1. Prefer the canonical slot (`spec.seedEntry().port`).
|
|
28
27
|
* 2. On collision, walk the unassigned range (1944–1949 today).
|
|
29
28
|
* 3. Range exhausted: assign past 1949 with a warning.
|
|
30
29
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* install
|
|
30
|
+
* `services.json` is the single source of truth at boot: each service
|
|
31
|
+
* follows a 4-tier resolvePort ladder (services.json → service config →
|
|
32
|
+
* bare PORT env → compiled-in canonical default), per parachute-scribe#41,
|
|
33
|
+
* parachute-agent#146, parachute-agent#148, and parachute-patterns#45.
|
|
34
|
+
* Pre-hub#206 the install path also wrote `PORT=<port>` into the service's
|
|
35
|
+
* `~/.parachute/<svc>/.env`; post-#206 it doesn't — services.json wins,
|
|
36
|
+
* the duplicate `.env` PORT was at best dead weight and at worst a source
|
|
37
|
+
* of drift on re-install (a stale `.env` PORT would re-stamp services.json
|
|
38
|
+
* even after an operator had fixed it).
|
|
39
|
+
*
|
|
40
|
+
* Operator override is now "edit services.json" (or `parachute config`
|
|
41
|
+
* once that lands), not "edit `.env`". Pre-#206 stale `.env` PORT lines on
|
|
42
|
+
* existing operator machines stay where they are — harmless, since the
|
|
43
|
+
* boot-time ladder reads services.json before falling through to the bare
|
|
44
|
+
* PORT env tier — and future installs no longer touch them.
|
|
36
45
|
*
|
|
37
46
|
* **No speculative reservations.** Future first-party modules claim a slot
|
|
38
47
|
* the moment they ship, not before — pre-reservation for unbuilt things has
|
|
@@ -199,6 +208,7 @@ export function seedEntryFromManifest(manifest: ModuleManifest): ServiceEntry {
|
|
|
199
208
|
};
|
|
200
209
|
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
201
210
|
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
211
|
+
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
202
212
|
return entry;
|
|
203
213
|
}
|
|
204
214
|
|
|
@@ -319,6 +329,13 @@ const SCRIBE_FALLBACK: FirstPartyFallback = {
|
|
|
319
329
|
paths: ["/scribe"],
|
|
320
330
|
health: "/scribe/health",
|
|
321
331
|
startCmd: ["parachute-scribe", "serve"],
|
|
332
|
+
// Scribe's HTTP routes are bare (`/health`, `/v1/...`), unlike notes /
|
|
333
|
+
// agent which strip the mount themselves. Until scribe ships a `--mount`
|
|
334
|
+
// flag (tracked upstream in parachute-scribe), the hub strips the
|
|
335
|
+
// `/scribe` prefix before forwarding so a request to
|
|
336
|
+
// `hub:1939/scribe/v1/audio/transcriptions` reaches scribe as
|
|
337
|
+
// `/v1/audio/transcriptions`.
|
|
338
|
+
stripPrefix: true,
|
|
322
339
|
},
|
|
323
340
|
extras: {
|
|
324
341
|
// No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
|
|
@@ -376,10 +393,20 @@ export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
|
376
393
|
/**
|
|
377
394
|
* Effective publicExposure for a service, given what's on its services.json
|
|
378
395
|
* entry. Explicit wins. If absent, derive from the spec: known api/tool
|
|
379
|
-
* services without declared auth fall back to "auth-required"
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
396
|
+
* services without declared auth fall back to "auth-required"; everything
|
|
397
|
+
* else defaults to "allowed" — so vault, notes, channel and unknown
|
|
398
|
+
* third-party services continue to be exposed without needing to opt in.
|
|
399
|
+
*
|
|
400
|
+
* Layer behavior (post-#187 layer-aware proxy):
|
|
401
|
+
* "allowed" — reaches all layers (loopback / tailnet / public);
|
|
402
|
+
* service self-gates if it has any auth.
|
|
403
|
+
* "loopback" — hub layer-gates; tailnet/public requests 404 at
|
|
404
|
+
* proxyToService / proxyToVault before reaching the
|
|
405
|
+
* service.
|
|
406
|
+
* "auth-required" — reaches all layers; service self-gates. Same gate
|
|
407
|
+
* behavior as "allowed" today; the field documents
|
|
408
|
+
* operator/UI intent ("requires auth before exposing")
|
|
409
|
+
* separately from the loopback hard-block.
|
|
383
410
|
*/
|
|
384
411
|
export function effectivePublicExposure(
|
|
385
412
|
entry: ServiceEntry,
|
|
@@ -401,6 +428,32 @@ export function knownServices(): string[] {
|
|
|
401
428
|
return Object.keys(FIRST_PARTY_FALLBACKS);
|
|
402
429
|
}
|
|
403
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Canonical port assignment for a known short name, or `undefined` for
|
|
433
|
+
* third-party services we don't have a fallback for. Drives the
|
|
434
|
+
* canonical-port drift warning in `parachute status` (hub#195) — when an
|
|
435
|
+
* entry's actual port doesn't match the canonical, we surface it without
|
|
436
|
+
* blocking. Operators may have intentionally moved a service off canonical
|
|
437
|
+
* (e.g. to dodge a third-party clash), so the drift is a warning, not an
|
|
438
|
+
* error.
|
|
439
|
+
*
|
|
440
|
+
* Known gap (intentional, tracked separately): multi-vault instance rows
|
|
441
|
+
* (`parachute-vault-default`, `parachute-vault-techne`, etc.) don't match
|
|
442
|
+
* any `manifestName` in `FIRST_PARTY_FALLBACKS` — only the canonical
|
|
443
|
+
* `parachute-vault` does — so `shortNameForManifest` returns undefined and
|
|
444
|
+
* drift warnings never fire for them. That's tolerable: multi-vault is the
|
|
445
|
+
* deliberate exception in the duplicate-port gate (one process, N mounts,
|
|
446
|
+
* one port), and no operator-actionable drift signal is well-defined when
|
|
447
|
+
* N rows share a port. Documented here so the gap doesn't read as an
|
|
448
|
+
* oversight; revisit if a clean drift shape for multi-vault emerges.
|
|
449
|
+
*/
|
|
450
|
+
export function canonicalPortForManifest(manifestName: string): number | undefined {
|
|
451
|
+
const short = shortNameForManifest(manifestName);
|
|
452
|
+
if (short === undefined) return undefined;
|
|
453
|
+
const fb = FIRST_PARTY_FALLBACKS[short];
|
|
454
|
+
return fb?.manifest.port;
|
|
455
|
+
}
|
|
456
|
+
|
|
404
457
|
/**
|
|
405
458
|
* Resolve the runtime spec for a known short name. Returns undefined for
|
|
406
459
|
* unknown names; third-party modules installed via `module.json` resolve
|
package/src/services-manifest.ts
CHANGED
|
@@ -45,6 +45,22 @@ export interface ServiceEntry {
|
|
|
45
45
|
* can use clean relative paths in their `startCmd`.
|
|
46
46
|
*/
|
|
47
47
|
installDir?: string;
|
|
48
|
+
/**
|
|
49
|
+
* When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
|
|
50
|
+
* before forwarding so the backend sees a bare path (e.g. `/health` rather
|
|
51
|
+
* than `/scribe/health`). Default `false` keeps the prefix intact, which
|
|
52
|
+
* matches what notes / agent / vault expect today.
|
|
53
|
+
*
|
|
54
|
+
* Per-module rather than uniform because conventions differ:
|
|
55
|
+
* - notes-serve.ts strips internally via `--mount`; expects the prefix.
|
|
56
|
+
* - parachute-agent reads PARACHUTE_AGENT_WEB_MOUNT and strips itself.
|
|
57
|
+
* - parachute-vault routes by `/vault/<name>/...` and expects the prefix.
|
|
58
|
+
* - parachute-scribe serves bare paths (`/health`, `/v1/...`); the proxy
|
|
59
|
+
* must strip. Eventually scribe should accept its own `--mount` flag
|
|
60
|
+
* and join the always-prefixed convention; until then this opt-in
|
|
61
|
+
* bridges the gap. Tracked in parachute-scribe (separate issue).
|
|
62
|
+
*/
|
|
63
|
+
stripPrefix?: boolean;
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
export interface ServicesManifest {
|
|
@@ -105,14 +121,70 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
105
121
|
if (installDir !== undefined && (typeof installDir !== "string" || installDir.length === 0)) {
|
|
106
122
|
throw new ServicesManifestError(`${where}: "installDir" must be a non-empty string if present`);
|
|
107
123
|
}
|
|
124
|
+
const stripPrefix = e.stripPrefix;
|
|
125
|
+
if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
|
|
126
|
+
throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
127
|
+
}
|
|
108
128
|
const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
|
|
109
129
|
if (displayName !== undefined) entry.displayName = displayName;
|
|
110
130
|
if (tagline !== undefined) entry.tagline = tagline;
|
|
111
131
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
112
132
|
if (installDir !== undefined) entry.installDir = installDir;
|
|
133
|
+
if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
|
|
113
134
|
return entry;
|
|
114
135
|
}
|
|
115
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Vault is a multi-instance service: one parachute-vault process serves
|
|
139
|
+
* every vault on a single port at distinct mount paths (`/vault/default`,
|
|
140
|
+
* `/vault/techne`, …). Every multi-vault row carries a `parachute-vault*`
|
|
141
|
+
* name. Sharing a port between vault rows is intentional and not a
|
|
142
|
+
* collision; sharing a port between two non-vault services (or between a
|
|
143
|
+
* vault and a non-vault) is.
|
|
144
|
+
*
|
|
145
|
+
* Inlined rather than imported from `well-known.ts` to keep the parser
|
|
146
|
+
* self-contained — well-known.ts already imports from this file.
|
|
147
|
+
*/
|
|
148
|
+
function isVaultName(name: string): boolean {
|
|
149
|
+
return name === "parachute-vault" || name.startsWith("parachute-vault-");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Reject manifests where two distinct services share a port. Without this
|
|
154
|
+
* gate, both services land in services.json, the OS lets only one bind,
|
|
155
|
+
* and the hub reverse-proxy quietly routes everyone to whichever service
|
|
156
|
+
* won the race. That's exactly how parachute-hub#195 (scribe + agent both
|
|
157
|
+
* at 1944) produced a silent /agent → scribe miswire. The underlying
|
|
158
|
+
* overwrite bugs are fixed in parachute-scribe#41 + parachute-agent#146;
|
|
159
|
+
* this is the hub-side gate so the same class can't recur silently.
|
|
160
|
+
*
|
|
161
|
+
* Multi-vault is the deliberate exception: one parachute-vault process
|
|
162
|
+
* serves N vault instances on a single port at distinct mount paths, so
|
|
163
|
+
* multiple `parachute-vault*` rows sharing a port is intentional, not a
|
|
164
|
+
* collision. The check fires only when the conflicting names aren't
|
|
165
|
+
* both vault rows.
|
|
166
|
+
*
|
|
167
|
+
* Pulled out of `validateManifest` so the write side (`upsertService`) can
|
|
168
|
+
* apply the same gate after merging without re-validating every entry's
|
|
169
|
+
* shape — the merged manifest's entries are already typed `ServiceEntry`,
|
|
170
|
+
* but a duplicate-port collision is a property of the merged set, not of
|
|
171
|
+
* any individual entry. Read-side path runs this after `validateEntry`
|
|
172
|
+
* across the array; write-side path runs this on the post-merge entries.
|
|
173
|
+
* Both surface the same `ServicesManifestError` shape.
|
|
174
|
+
*/
|
|
175
|
+
function assertNoDuplicatePorts(entries: ServiceEntry[], where: string): void {
|
|
176
|
+
const portsSeen = new Map<number, string>();
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
const prev = portsSeen.get(entry.port);
|
|
179
|
+
if (prev !== undefined && !(isVaultName(prev) && isVaultName(entry.name))) {
|
|
180
|
+
throw new ServicesManifestError(
|
|
181
|
+
`${where}: duplicate port ${entry.port} — claimed by both "${prev}" and "${entry.name}". Edit services.json to give each service a unique port.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (prev === undefined) portsSeen.set(entry.port, entry.name);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
116
188
|
function validateManifest(raw: unknown, where: string): ServicesManifest {
|
|
117
189
|
if (!raw || typeof raw !== "object") {
|
|
118
190
|
throw new ServicesManifestError(`${where}: root must be an object`);
|
|
@@ -121,9 +193,9 @@ function validateManifest(raw: unknown, where: string): ServicesManifest {
|
|
|
121
193
|
if (!Array.isArray(services)) {
|
|
122
194
|
throw new ServicesManifestError(`${where}: "services" must be an array`);
|
|
123
195
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
196
|
+
const entries = services.map((s, i) => validateEntry(s, `${where} services[${i}]`));
|
|
197
|
+
assertNoDuplicatePorts(entries, where);
|
|
198
|
+
return { services: entries };
|
|
127
199
|
}
|
|
128
200
|
|
|
129
201
|
export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesManifest {
|
|
@@ -201,6 +273,14 @@ export function upsertService(
|
|
|
201
273
|
} else {
|
|
202
274
|
current.services.push(entry);
|
|
203
275
|
}
|
|
276
|
+
// Symmetric port-collision gate (closes hub#205). Read-time validation
|
|
277
|
+
// (`validateManifest` → `assertNoDuplicatePorts`) catches duplicates the
|
|
278
|
+
// next time `services.json` is read, but without this write-side check the
|
|
279
|
+
// bad state lives on disk for that window. A buggy service boot calling
|
|
280
|
+
// `upsertService({ name: "agent", port: 1944 })` while scribe is already
|
|
281
|
+
// at 1944 would otherwise succeed and corrupt the manifest. Same
|
|
282
|
+
// multi-vault carve-out as the read path.
|
|
283
|
+
assertNoDuplicatePorts(current.services, path);
|
|
204
284
|
writeManifest(current, path);
|
|
205
285
|
return current;
|
|
206
286
|
}
|
package/src/sessions.ts
CHANGED
|
@@ -113,3 +113,22 @@ export function parseSessionCookie(cookieHeader: string | null): string | null {
|
|
|
113
113
|
}
|
|
114
114
|
return null;
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns the active (un-expired) session for this request, or null. The
|
|
119
|
+
* canonical "is the operator logged in to this hub?" check — combines
|
|
120
|
+
* `parseSessionCookie` + `findSession` (which already enforces expiry) so
|
|
121
|
+
* callers don't repeat the parse+find+null-check dance.
|
|
122
|
+
*
|
|
123
|
+
* Caller decides what to do on null — admin pages redirect to
|
|
124
|
+
* `/admin/login?next=<path>`, OAuth's DCR endpoint falls through to
|
|
125
|
+
* status=`pending` (closes #199).
|
|
126
|
+
*/
|
|
127
|
+
export function findActiveSession(
|
|
128
|
+
db: Database,
|
|
129
|
+
req: Request,
|
|
130
|
+
now: () => Date = () => new Date(),
|
|
131
|
+
): Session | null {
|
|
132
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
133
|
+
return sid ? findSession(db, sid, now) : null;
|
|
134
|
+
}
|