@openparachute/hub 0.5.2 → 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 +648 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- 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 +341 -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 +147 -10
- 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 +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
package/src/oauth-ui.ts
CHANGED
|
@@ -97,6 +97,33 @@ export interface ErrorViewProps {
|
|
|
97
97
|
status: number;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Props for the "App not yet approved" view rendered when an unapproved
|
|
102
|
+
* client lands on `/oauth/authorize`. When `session` is true the operator is
|
|
103
|
+
* authenticated to this hub from the browser making the request, so we render
|
|
104
|
+
* an inline approve form (closes #208). When false we fall back to the
|
|
105
|
+
* pre-#208 CLI-only message.
|
|
106
|
+
*/
|
|
107
|
+
export interface ApprovePendingViewProps {
|
|
108
|
+
/** Display name to show — falls back to client_id when no name was supplied at DCR. */
|
|
109
|
+
clientName: string;
|
|
110
|
+
clientId: string;
|
|
111
|
+
redirectUris: string[];
|
|
112
|
+
/** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
|
|
113
|
+
requestedScopes: string[];
|
|
114
|
+
/**
|
|
115
|
+
* When set, render the inline approve form. The form posts to
|
|
116
|
+
* `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
|
|
117
|
+
* server will redirect to after the approve commits — the original
|
|
118
|
+
* `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
|
|
119
|
+
* approved client and lands on the consent screen.
|
|
120
|
+
*/
|
|
121
|
+
approveForm?: {
|
|
122
|
+
csrfToken: string;
|
|
123
|
+
returnTo: string;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
100
127
|
export function renderLogin(props: LoginViewProps): string {
|
|
101
128
|
const { params, errorMessage, csrfToken } = props;
|
|
102
129
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
@@ -204,6 +231,79 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
204
231
|
</section>`;
|
|
205
232
|
}
|
|
206
233
|
|
|
234
|
+
/**
|
|
235
|
+
* "App not yet approved" page (#74). When the request carries a valid
|
|
236
|
+
* operator session (#208), render the inline approve form so one click lands
|
|
237
|
+
* the client as `approved` and re-enters the OAuth flow at consent. Without
|
|
238
|
+
* a session, fall back to the original CLI-only message — anyone hitting
|
|
239
|
+
* /oauth/authorize unauthenticated to the hub itself can't be trusted to
|
|
240
|
+
* approve a DCR client from the browser, so they need to drop to a terminal
|
|
241
|
+
* and run `parachute auth approve-client <id>`.
|
|
242
|
+
*
|
|
243
|
+
* The CLI fallback hint is shown in BOTH branches: a button-equipped operator
|
|
244
|
+
* may still want the CLI invocation handy (different machine, scriptable
|
|
245
|
+
* context). The button is the easy path; the CLI is always-available.
|
|
246
|
+
*/
|
|
247
|
+
export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
248
|
+
const { clientName, clientId, redirectUris, requestedScopes, approveForm } = props;
|
|
249
|
+
const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
|
|
250
|
+
const scopeRows =
|
|
251
|
+
requestedScopes.length === 0
|
|
252
|
+
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
253
|
+
: requestedScopes.map(renderScopeRow).join("\n");
|
|
254
|
+
const formSection = approveForm
|
|
255
|
+
? `
|
|
256
|
+
<form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
|
|
257
|
+
${renderCsrfHiddenInput(approveForm.csrfToken)}
|
|
258
|
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
|
|
259
|
+
<input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
|
|
260
|
+
<button type="submit" class="btn btn-primary">Approve and continue</button>
|
|
261
|
+
</form>
|
|
262
|
+
<p class="approve-cli-hint">
|
|
263
|
+
Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
|
|
264
|
+
</p>`
|
|
265
|
+
: `
|
|
266
|
+
<p class="approve-cli-hint">
|
|
267
|
+
Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
|
|
268
|
+
from a terminal, then try again.
|
|
269
|
+
</p>`;
|
|
270
|
+
const body = `
|
|
271
|
+
<div class="card">
|
|
272
|
+
<div class="card-header">
|
|
273
|
+
<div class="brand">
|
|
274
|
+
<span class="brand-mark">⌬</span>
|
|
275
|
+
<span class="brand-name">Parachute</span>
|
|
276
|
+
</div>
|
|
277
|
+
<h1>App not yet approved</h1>
|
|
278
|
+
<p class="subtitle">
|
|
279
|
+
${escapeHtml(clientName)} is registered with this hub but hasn't been approved yet.
|
|
280
|
+
Review the details below before approving.
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
<section class="approve-meta">
|
|
284
|
+
<h2 class="scopes-title">Application</h2>
|
|
285
|
+
<p class="approve-meta-row">
|
|
286
|
+
<span class="approve-meta-label">name</span>
|
|
287
|
+
<code class="approve-meta-value">${escapeHtml(clientName)}</code>
|
|
288
|
+
</p>
|
|
289
|
+
<p class="approve-meta-row">
|
|
290
|
+
<span class="approve-meta-label">client_id</span>
|
|
291
|
+
<code class="approve-meta-value">${escapeHtml(clientId)}</code>
|
|
292
|
+
</p>
|
|
293
|
+
<div class="approve-meta-row approve-meta-row-block">
|
|
294
|
+
<span class="approve-meta-label">redirect_uris</span>
|
|
295
|
+
<ul class="approve-redirect-list">${redirectList}</ul>
|
|
296
|
+
</div>
|
|
297
|
+
</section>
|
|
298
|
+
<section class="scopes">
|
|
299
|
+
<h2 class="scopes-title">Permissions requested</h2>
|
|
300
|
+
<ul class="scope-list">${scopeRows}</ul>
|
|
301
|
+
</section>
|
|
302
|
+
${formSection}
|
|
303
|
+
</div>`;
|
|
304
|
+
return baseDocument("App not yet approved", body);
|
|
305
|
+
}
|
|
306
|
+
|
|
207
307
|
export function renderError(props: ErrorViewProps): string {
|
|
208
308
|
const body = `
|
|
209
309
|
<div class="card">
|
|
@@ -542,6 +642,73 @@ const STYLES = `
|
|
|
542
642
|
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
543
643
|
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
544
644
|
|
|
645
|
+
.approve-meta {
|
|
646
|
+
margin: 0 0 1.25rem;
|
|
647
|
+
padding: 0.75rem 0.85rem;
|
|
648
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
649
|
+
border-radius: 6px;
|
|
650
|
+
background: ${PALETTE.bgSoft};
|
|
651
|
+
}
|
|
652
|
+
.approve-meta .scopes-title { margin-bottom: 0.5rem; }
|
|
653
|
+
.approve-meta-row {
|
|
654
|
+
margin: 0 0 0.4rem;
|
|
655
|
+
display: flex;
|
|
656
|
+
gap: 0.5rem;
|
|
657
|
+
align-items: baseline;
|
|
658
|
+
flex-wrap: wrap;
|
|
659
|
+
}
|
|
660
|
+
.approve-meta-row:last-child { margin-bottom: 0; }
|
|
661
|
+
.approve-meta-row-block { flex-direction: column; gap: 0.25rem; }
|
|
662
|
+
.approve-meta-label {
|
|
663
|
+
text-transform: uppercase;
|
|
664
|
+
letter-spacing: 0.05em;
|
|
665
|
+
font-size: 0.7rem;
|
|
666
|
+
color: ${PALETTE.fgDim};
|
|
667
|
+
}
|
|
668
|
+
.approve-meta-value {
|
|
669
|
+
font-family: ${FONT_MONO};
|
|
670
|
+
font-size: 0.82rem;
|
|
671
|
+
background: ${PALETTE.cardBg};
|
|
672
|
+
padding: 0.1rem 0.4rem;
|
|
673
|
+
border-radius: 4px;
|
|
674
|
+
color: ${PALETTE.fg};
|
|
675
|
+
word-break: break-all;
|
|
676
|
+
}
|
|
677
|
+
.approve-redirect-list {
|
|
678
|
+
list-style: none;
|
|
679
|
+
margin: 0;
|
|
680
|
+
padding: 0;
|
|
681
|
+
display: flex;
|
|
682
|
+
flex-direction: column;
|
|
683
|
+
gap: 0.25rem;
|
|
684
|
+
}
|
|
685
|
+
.approve-redirect-list li code {
|
|
686
|
+
font-family: ${FONT_MONO};
|
|
687
|
+
font-size: 0.82rem;
|
|
688
|
+
background: ${PALETTE.cardBg};
|
|
689
|
+
padding: 0.1rem 0.4rem;
|
|
690
|
+
border-radius: 4px;
|
|
691
|
+
color: ${PALETTE.fg};
|
|
692
|
+
word-break: break-all;
|
|
693
|
+
}
|
|
694
|
+
.approve-form { gap: 0; }
|
|
695
|
+
.approve-cli-hint {
|
|
696
|
+
margin-top: 1rem;
|
|
697
|
+
padding-top: 0.85rem;
|
|
698
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
699
|
+
color: ${PALETTE.fgMuted};
|
|
700
|
+
font-size: 0.85rem;
|
|
701
|
+
}
|
|
702
|
+
.approve-cli-hint code {
|
|
703
|
+
font-family: ${FONT_MONO};
|
|
704
|
+
font-size: 0.8rem;
|
|
705
|
+
background: ${PALETTE.bgSoft};
|
|
706
|
+
padding: 0.1rem 0.4rem;
|
|
707
|
+
border-radius: 4px;
|
|
708
|
+
color: ${PALETTE.fg};
|
|
709
|
+
word-break: break-all;
|
|
710
|
+
}
|
|
711
|
+
|
|
545
712
|
.badge {
|
|
546
713
|
display: inline-block;
|
|
547
714
|
font-size: 0.7rem;
|
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
|
|
@@ -384,10 +393,20 @@ export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
|
384
393
|
/**
|
|
385
394
|
* Effective publicExposure for a service, given what's on its services.json
|
|
386
395
|
* entry. Explicit wins. If absent, derive from the spec: known api/tool
|
|
387
|
-
* services without declared auth fall back to "auth-required"
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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.
|
|
391
410
|
*/
|
|
392
411
|
export function effectivePublicExposure(
|
|
393
412
|
entry: ServiceEntry,
|
|
@@ -409,6 +428,32 @@ export function knownServices(): string[] {
|
|
|
409
428
|
return Object.keys(FIRST_PARTY_FALLBACKS);
|
|
410
429
|
}
|
|
411
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
|
+
|
|
412
457
|
/**
|
|
413
458
|
* Resolve the runtime spec for a known short name. Returns undefined for
|
|
414
459
|
* unknown names; third-party modules installed via `module.json` resolve
|
package/src/services-manifest.ts
CHANGED
|
@@ -134,6 +134,57 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
134
134
|
return entry;
|
|
135
135
|
}
|
|
136
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
|
+
|
|
137
188
|
function validateManifest(raw: unknown, where: string): ServicesManifest {
|
|
138
189
|
if (!raw || typeof raw !== "object") {
|
|
139
190
|
throw new ServicesManifestError(`${where}: root must be an object`);
|
|
@@ -142,9 +193,9 @@ function validateManifest(raw: unknown, where: string): ServicesManifest {
|
|
|
142
193
|
if (!Array.isArray(services)) {
|
|
143
194
|
throw new ServicesManifestError(`${where}: "services" must be an array`);
|
|
144
195
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
};
|
|
196
|
+
const entries = services.map((s, i) => validateEntry(s, `${where} services[${i}]`));
|
|
197
|
+
assertNoDuplicatePorts(entries, where);
|
|
198
|
+
return { services: entries };
|
|
148
199
|
}
|
|
149
200
|
|
|
150
201
|
export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesManifest {
|
|
@@ -222,6 +273,14 @@ export function upsertService(
|
|
|
222
273
|
} else {
|
|
223
274
|
current.services.push(entry);
|
|
224
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);
|
|
225
284
|
writeManifest(current, path);
|
|
226
285
|
return current;
|
|
227
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
|
+
}
|