Skip to content

Commit ea06d79

Browse files
endolinbotendolinbot
authored andcommitted
feat(gateway): separate admin sock from bootstrap sock with ACL guarantee (#389)
Phase 3's admin facet was reachable through `bootstrap.getAdmin()`, which collapsed registration authority (anyone with the bootstrap sock) and admin authority onto a single capability path. Any local user daemon that connects to the bootstrap sock to call `register` could call `getAdmin` on the same exo and reach the administrator facet. Split the two authorities onto separate socks: - `bootstrap.sock` (existing) carries the `GatewayBootstrap` exo only. Its `getAdmin` method is removed; the `GatewayAdmin` typedef no longer flows through the bootstrap module's import graph. - `admin.sock` (new) is a sibling sock, distinct file path, mode 0600. Deployment is responsible for placing it under a parent directory mode 0700 so only the administrator OS account can `connect(2)`. The listener for both socks lands in a follow-on PR; the path resolver and the surface contract land here. Surface changes: - `src/sock-paths.js`: factor the resolution rules into a shared helper; export `resolveAdminSocketPath` alongside `resolveBootstrapSocketPath`. `ADMIN_SOCKET_BASENAME` is the `admin.sock` constant; `ENDO_GATEWAY_ADMIN_SOCK` is the operator override for the admin sock (mirroring the existing `ENDO_GATEWAY_BOOTSTRAP_SOCK`). - `src/bootstrap.js`: drop `getAdmin` from `GatewayBootstrap`'s interface, exo body, deps, typedef, and the GatewayAdmin `@import`. The bootstrap module no longer references the admin module. - `index.js`: wire the admin facet directly from `makeGateway` against the bootstrap's in-process backplane (or an empty backplane when `sockBootstrap` is off). The `gateway.getAdmin` in-process accessor is preserved; the previous "admin requires sockBootstrap" cross-toggle dependency is removed. - `src/config.js`: drop the `adminDaemon depends on sockBootstrap` validator. The two features have independent toggles; a deployment may serve admin reads without exposing the registration channel and vice versa. - `src/admin.js`: docstring reflects the new architecture (admin sock, not bootstrap sock; independent toggles). Tests: - `test/sock-paths.test.js`: admin sock system / user / darwin resolutions; `ENDO_GATEWAY_ADMIN_SOCK` override; regression- evidence saboteur verifying the admin resolver does **not** pick up the bootstrap-override env var; cross-source invariant that bootstrap and admin paths are always distinct files; the two basenames are distinct constants. - `test/admin.test.js`: `bootstrap.getAdmin` is replaced with a `__getMethodNames__` enumeration that pins the absence of the method; the prior cross-toggle "throws when sockBootstrap is disabled" test is rewritten to verify the admin facet works standalone and reports an empty registration view; the "reachable only via gateway.getAdmin and bootstrap.getAdmin" contract becomes "reachable only via gateway.getAdmin", with zero admin-shaped methods on the bootstrap. - `test/config.test.js`: the rejection test for `adminDaemon=true` + `sockBootstrap=false` is rewritten to verify the combination is now accepted; the merged config reflects the requested toggles. Linux/Mac scope (per #388): the new admin sock paths use the same platform-bound resolution as the bootstrap sock (Linux primary, macOS secondary, no Windows-flavored paths).
1 parent e224fd5 commit ea06d79

9 files changed

Lines changed: 533 additions & 265 deletions

File tree

packages/gateway/README.md

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ This is the **phase-3 slice**, building on phase 2's sock bootstrap
2727
registrar (Feature 4) and the phase-1 skeleton's package shape.
2828
Phase 3 adds Feature 7 (admin daemon: the `GatewayAdmin` exo).
2929
The admin facet is reachable only via the in-process accessor
30-
(`gateway.getAdmin()`) and the sock bootstrap
31-
(`bootstrap.getAdmin()`); it is **never** exposed on the public
32-
HTTP / WS surface, keeping admin authority off the network per the
33-
design.
30+
(`gateway.getAdmin()`) and a separate local sock (`admin.sock`,
31+
distinct from the bootstrap sock); it is **never** exposed on the
32+
public HTTP / WS surface, and is **never** reached through the
33+
bootstrap sock. The split keeps registration authority (anyone with
34+
the bootstrap sock) and admin authority (only the OS account that
35+
owns the admin sock) on independent capability paths.
3436

3537
Implemented:
3638

@@ -43,27 +45,32 @@ Implemented:
4345
`lookup` (phase 1, Feature 2).
4446
- Per-feature configuration toggles validated at `make` time.
4547
- `GatewayBootstrap` exo with `challenge`, `register`,
46-
`registerRelay`, `getBindAddress`, `getApps`, `getAdmin`;
48+
`registerRelay`, `getBindAddress`, `getApps`;
4749
`Registration` handle with `publishWeblet`, `unpublishWeblet`,
4850
`addPublicKey`, `deregister`, `listWeblets`, `listPublicKeys`
49-
(phase 2, Feature 4; phase 3 added `getAdmin`).
51+
(phase 2, Feature 4).
5052
- `GatewayAdmin` exo (phase 3, Feature 7) with `listRegistrations`,
5153
`deregisterRelay`, `listVirtualHosts`, `getResourceBalances`,
52-
`getCounters`. Reachable only over the sock bootstrap or the
53-
in-process accessor; refused when `adminDaemon` is off; the
54-
config validator already rejects `adminDaemon=true` with
55-
`sockBootstrap=false`.
54+
`getCounters`. Reachable only via the in-process accessor and
55+
the admin sock; refused when `adminDaemon` is off. The admin
56+
daemon's toggle is independent of `sockBootstrap`; a deployment
57+
may offer admin reads without exposing the bootstrap sock and
58+
vice versa.
5659
- Proof-of-possession nonce registry with domain-separated
5760
challenge hashing (`endo-gateway:registrar:nonce`), 30-second
5861
TTL, single-use semantics, constant-time signature comparison
5962
helper, and a Node-backed `CryptoPowers` adapter
6063
(`src/node-crypto-powers.js`).
61-
- Bootstrap sock path resolver
64+
- Bootstrap and admin sock path resolvers
6265
(`src/sock-paths.js`) covering `/run/endo-gateway/bootstrap.sock`
63-
(system service), `${XDG_RUNTIME_DIR}/endo-gateway/...` (user
64-
Linux), the macOS `Library/Application Support` variant, the
65-
`${TMPDIR}/...` fallback, and `ENDO_GATEWAY_BOOTSTRAP_SOCK`
66-
operator override.
66+
and `/run/endo-gateway/admin.sock` (system service),
67+
`${XDG_RUNTIME_DIR}/endo-gateway/...` (user Linux), the macOS
68+
`Library/Application Support` variant, the `${TMPDIR}/...`
69+
fallback, and the `ENDO_GATEWAY_BOOTSTRAP_SOCK` /
70+
`ENDO_GATEWAY_ADMIN_SOCK` operator overrides. The two socks are
71+
always distinct file paths; deployment is responsible for the
72+
admin sock's stricter parent-directory mode (`0700`) so only the
73+
administrator OS account can `connect(2)` to it.
6774

6875
Deferred to follow-on PRs:
6976

@@ -169,14 +176,16 @@ inventory. The phase-1 through phase-3 slices expose:
169176
`getConfig`, `getBootstrap`, `getAdmin`.
170177
- `AppsNameHub`: `bind`, `unbind`, `list`, `lookup`, `has`.
171178
- `GatewayBootstrap`: `challenge`, `register`, `registerRelay`,
172-
`getBindAddress`, `getApps`, `getAdmin`.
179+
`getBindAddress`, `getApps`. The bootstrap channel carries the
180+
registrar exo only; it does **not** expose the admin facet.
173181
- `Registration`: `publishWeblet`, `unpublishWeblet`,
174182
`addPublicKey`, `deregister`, `listWeblets`, `listPublicKeys`.
175183
- `GatewayAdmin`: `listRegistrations`, `deregisterRelay`,
176184
`listVirtualHosts`, `getResourceBalances`, `getCounters`. The
177185
admin facet is reachable only via `gateway.getAdmin()`
178-
in-process and `bootstrap.getAdmin()` over the sock; the public
179-
HTTP / WS surface does not expose it.
186+
in-process and over the admin sock (`admin.sock`); the public
187+
HTTP / WS surface does not expose it, and the bootstrap sock
188+
does not expose it.
180189

181190
### Bootstrap challenge-response
182191

packages/gateway/index.js

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ export { makeGatewayAdmin } from './src/admin.js';
5959

6060
export {
6161
resolveBootstrapSocketPath,
62+
resolveAdminSocketPath,
6263
BOOTSTRAP_SOCKET_BASENAME,
64+
ADMIN_SOCKET_BASENAME,
6365
SYSTEM_RUNTIME_DIR_LINUX,
6466
USER_RUNTIME_SUBDIR,
6567
} from './src/sock-paths.js';
@@ -119,15 +121,15 @@ const GatewayInterface = M.interface('Gateway', {
119121
* embedding the gateway in-realm calls `getBootstrap` directly.
120122
* @property {() => Promise<GatewayAdmin>} getAdmin Returns the
121123
* `GatewayAdmin` exo (Feature 7). Throws when the `adminDaemon`
122-
* feature toggle is off, or when `sockBootstrap` is off (admin
123-
* depends on the sock bootstrap for its access channel, and the
124-
* config validator already rejects `adminDaemon=true` with
125-
* `sockBootstrap=false`; the in-process accessor mirrors the
126-
* surface contract: there is no admin authority without a sock
127-
* bootstrap to gate it). The admin facet is **never** served on
128-
* the gateway's public HTTP / WS surface; it is reachable only
129-
* in-process (this method) and through the sock bootstrap's
130-
* `getAdmin`.
124+
* feature toggle is off. The admin facet is **never** served on
125+
* the gateway's public HTTP / WS surface, and is **never**
126+
* reached through the bootstrap sock; it is reachable only
127+
* in-process (this method) and over a separate admin sock
128+
* (`admin.sock`) whose listener lands in a follow-on PR alongside
129+
* the bootstrap sock's listener. The two socks are distinct file
130+
* paths and the admin sock's deployment is responsible for
131+
* placing it under a non-world-traversable parent directory so
132+
* only the administrator OS account can `connect(2)`.
131133
*/
132134

133135
/**
@@ -165,6 +167,19 @@ export const makeGateway = ({ powers = {}, config: configIn = {} } = {}) => {
165167
// are the platform-bound primitives. A toggle-on but no-powers
166168
// configuration is treated as a startup error because it would
167169
// otherwise silently behave like toggle-off.
170+
//
171+
// The admin facet (Feature 7) is wired in iff the adminDaemon
172+
// toggle is on. The admin facet uses the bootstrap's
173+
// registration table as its read source, so when both toggles
174+
// are on it shares the bootstrap's in-process backplane; when
175+
// only `adminDaemon` is on (`sockBootstrap` off), the admin
176+
// facet wires against a self-contained empty backplane and
177+
// serves the in-process accessor with a documented empty
178+
// registration view. The admin's access channel is its own sock
179+
// (`admin.sock`), not the bootstrap sock; the two have
180+
// independent toggles so that a deployment can offer
181+
// administrator access without exposing the bootstrap sock and
182+
// vice versa.
168183
/** @type {ReturnType<typeof makeGatewayBootstrap> | undefined} */
169184
let bootstrapHandle;
170185
/** @type {GatewayAdmin | undefined} */
@@ -180,42 +195,37 @@ export const makeGateway = ({ powers = {}, config: configIn = {} } = {}) => {
180195
X`sockBootstrap requires powers.clock; supply a ClockPowers adapter or disable the feature toggle`,
181196
);
182197
}
183-
// The admin facet (Feature 7) is wired in iff both the
184-
// sockBootstrap and adminDaemon toggles are on; the config
185-
// validator already rejects `adminDaemon=true` with
186-
// `sockBootstrap=false`, so the only path here that creates an
187-
// admin facet is the both-on path. We pass a forward-reference
188-
// `getAdmin` thunk to the bootstrap because the bootstrap is
189-
// the holder for the in-process admin backplane (the second
190-
// return value); the admin facet itself is constructed below
191-
// with that backplane in hand.
192198
bootstrapHandle = makeGatewayBootstrap({
193199
crypto: powers.crypto,
194200
clock: powers.clock,
195201
apps,
196202
getBindAddress: renderBindAddress,
197-
getAdmin: mergedConfig.enableFeatures.adminDaemon
198-
? () => {
199-
// adminFacet is assigned immediately below; the thunk
200-
// is only ever invoked after `makeGateway` returns.
201-
if (adminFacet === undefined) {
202-
throw makeError(X`Admin facet was not constructed`);
203-
}
204-
return adminFacet;
203+
});
204+
}
205+
if (mergedConfig.enableFeatures.adminDaemon) {
206+
// When the bootstrap is also on, the admin reads the same
207+
// registration table. When the bootstrap is off, the admin
208+
// facet still exists but sees an empty table; that path is
209+
// useful for an embedder that wants admin reads of virtual
210+
// hosts and the resource ledger without exposing the
211+
// registration channel at all.
212+
const backplane =
213+
bootstrapHandle !== undefined
214+
? {
215+
listRegisteredPeers: bootstrapHandle.listRegisteredPeers,
216+
deregisterByPublicKey: bootstrapHandle.deregisterByPublicKey,
217+
pendingNonces: bootstrapHandle.pendingNonces,
205218
}
206-
: undefined,
219+
: {
220+
listRegisteredPeers: () => harden([]),
221+
deregisterByPublicKey: () => false,
222+
pendingNonces: () => 0,
223+
};
224+
adminFacet = makeGatewayAdmin({
225+
backplane,
226+
apps,
227+
resourceLedger: powers.resourceLedger,
207228
});
208-
if (mergedConfig.enableFeatures.adminDaemon) {
209-
adminFacet = makeGatewayAdmin({
210-
backplane: {
211-
listRegisteredPeers: bootstrapHandle.listRegisteredPeers,
212-
deregisterByPublicKey: bootstrapHandle.deregisterByPublicKey,
213-
pendingNonces: bootstrapHandle.pendingNonces,
214-
},
215-
apps,
216-
resourceLedger: powers.resourceLedger,
217-
});
218-
}
219229
}
220230

221231
const exo = makeExo(
@@ -267,27 +277,19 @@ export const makeGateway = ({ powers = {}, config: configIn = {} } = {}) => {
267277
},
268278
async getAdmin() {
269279
// Per Feature 7: admin authority is reachable in-process
270-
// and over the sock bootstrap, never over the network. The
271-
// two `disabled` errors below preserve that contract by
272-
// refusing to hand out the facet when either toggle is
273-
// off; a refactor that quietly relaxed this would put
274-
// admin authority on the public surface.
280+
// and over the admin sock, never over the network and
281+
// never through the bootstrap sock. The disabled error
282+
// below preserves that contract by refusing to hand out
283+
// the facet when the toggle is off; a refactor that
284+
// quietly relaxed this would put admin authority on the
285+
// public surface.
275286
if (!mergedConfig.enableFeatures.adminDaemon) {
276287
throw makeError(
277288
X`Gateway admin is disabled (set enableFeatures.adminDaemon=true)`,
278289
);
279290
}
280-
if (!mergedConfig.enableFeatures.sockBootstrap) {
281-
// The config validator rejects this combination, so
282-
// reaching this branch implies a refactor that loosened
283-
// the validator. We keep the local check as
284-
// defense-in-depth.
285-
throw makeError(
286-
X`Gateway admin requires sockBootstrap; set enableFeatures.sockBootstrap=true`,
287-
);
288-
}
289291
if (adminFacet === undefined) {
290-
// Unreachable in normal use; both toggles are on yet
292+
// Unreachable in normal use; the toggle is on yet
291293
// construction did not produce a facet. We surface the
292294
// wiring bug loudly rather than returning undefined.
293295
throw makeError(X`Gateway admin facet is not wired`);

packages/gateway/src/admin.js

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,37 @@
44
* @file `GatewayAdmin` exo for the gateway's local administrator
55
* surface (design Feature 7).
66
*
7-
* The administrator's handle is the sock bootstrap from Feature 4
8-
* extended with a `GatewayAdmin` facet. A process that can connect
9-
* to the gateway's bootstrap sock holds the administrator's
10-
* authority: it can inspect the registration table, inspect the
11-
* virtual-host bindings, force-deregister a relay by public key,
12-
* and read per-account resource balances via an injected
13-
* `ResourceLedger` (Feature 1, deferred).
7+
* The administrator's handle is a separate local sock (`admin.sock`,
8+
* see `sock-paths.js`) gated by ACL such that only the administrator
9+
* OS account may connect. A process that can connect to the admin
10+
* sock holds the administrator's authority: it can inspect the
11+
* registration table, inspect the virtual-host bindings,
12+
* force-deregister a relay by public key, and read per-account
13+
* resource balances via an injected `ResourceLedger` (Feature 1,
14+
* deferred).
1415
*
1516
* `GatewayAdmin` is reachable in exactly two ways:
1617
*
1718
* 1. In-process, via `gateway.getAdmin()`. Embedders that already
1819
* speak CapTP hold the exo directly.
19-
* 2. Over the local sock bootstrap, via
20-
* `E(bootstrap).getAdmin()`. The filesystem permissions on the
21-
* sock gate who-may-call.
20+
* 2. Over the local admin sock. The admin sock is mode `0600` and
21+
* its parent directory is mode `0700` (deployment-enforced),
22+
* so only the administrator OS account can `connect(2)` to it.
23+
* The admin sock is **distinct** from the bootstrap sock
24+
* (which any local user daemon may use to register itself);
25+
* the two channels exist precisely so that registration
26+
* authority does not double as admin authority.
2227
*
2328
* The exo is **never** served on the gateway's public HTTP / WS
24-
* surface. The "admin authority off the network" rule lives in the
25-
* surface: the only entry capabilities are the in-process API and
26-
* the sock bootstrap. The HTTP / WS surface (which lands in later
27-
* phases) does not expose `GatewayAdmin`; the gateway's
28-
* `getBootstrap` throws when `sockBootstrap` is disabled, and
29-
* `getAdmin` throws when `adminDaemon` is disabled or when
30-
* `sockBootstrap` is disabled (the admin daemon depends on the sock
31-
* bootstrap for its access channel; the dependency is validated in
32-
* `mergeGatewayConfig`).
29+
* surface, and is **never** reached through the bootstrap sock. The
30+
* "admin authority off the network" rule lives in the surface: the
31+
* only entry capabilities are the in-process API and the admin sock.
32+
* The HTTP / WS surface (which lands in later phases) does not
33+
* expose `GatewayAdmin`; the gateway's `getBootstrap` throws when
34+
* `sockBootstrap` is disabled, and `getAdmin` throws when
35+
* `adminDaemon` is disabled. The admin daemon does **not** depend on
36+
* the bootstrap sock; the two are independent features with their
37+
* own toggles and their own access channels.
3338
*
3439
* Wire shape: byte fields (public keys) follow the `@endo/bytes`
3540
* convention: immutable `ArrayBuffer` on the wire, `Uint8Array`

packages/gateway/src/bootstrap.js

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import { makeNonceRegistry, NONCE_BYTE_LENGTH } from './proof-of-possession.js';
4242

4343
/** @import { AppsNameHub } from './vhost.js' */
4444
/** @import { CryptoPowers, ClockPowers, ChallengeIssued } from './proof-of-possession.js' */
45-
/** @import { GatewayAdmin } from './admin.js' */
4645

4746
/**
4847
* Expected raw Ed25519 public key length in bytes. The bootstrap
@@ -74,7 +73,6 @@ const GatewayBootstrapInterface = M.interface('GatewayBootstrap', {
7473
registerRelay: M.call(M.any()).returns(M.promise()),
7574
getBindAddress: M.call().returns(M.promise()),
7675
getApps: M.call().returns(M.promise()),
77-
getAdmin: M.call().returns(M.promise()),
7876
});
7977

8078
/**
@@ -274,18 +272,22 @@ const publicKeyToHex = bytes => {
274272
/**
275273
* @typedef {object} GatewayBootstrap CapTP-facing exo. Methods are
276274
* `async` so they cross the wire as eventual sends.
275+
*
276+
* The bootstrap channel carries the registrar exo only: any local
277+
* user daemon that can connect to the bootstrap sock may register
278+
* itself, but **none** of these daemons have administrator
279+
* authority. The `GatewayAdmin` exo (Feature 7) is **not**
280+
* reachable through this bootstrap; it lives on a separate sock
281+
* (`admin.sock`, see `sock-paths.js` and the gateway's
282+
* `getAdmin` in-process accessor) gated by a stricter access
283+
* control. The split keeps registration authority and admin
284+
* authority on independent capability paths.
285+
*
277286
* @property {() => Promise<ChallengePayload>} challenge
278287
* @property {(args: RegistrationArgs) => Promise<Registration>} register
279288
* @property {(args: RelayRegistrationArgs) => Promise<Registration>} registerRelay
280289
* @property {() => Promise<string>} getBindAddress
281290
* @property {() => Promise<AppsNameHub>} getApps
282-
* @property {() => Promise<GatewayAdmin>} getAdmin
283-
* Returns the `GatewayAdmin` exo (Feature 7). The admin facet is
284-
* reachable only over the sock bootstrap (this method) and via
285-
* the in-process `gateway.getAdmin()` accessor; never over the
286-
* network. Throws when the gateway's `adminDaemon` feature
287-
* toggle is off, surfacing a clear error rather than silently
288-
* returning a no-op.
289291
*/
290292

291293
/**
@@ -312,14 +314,6 @@ const publicKeyToHex = bytes => {
312314
* from the configured value after `start()`).
313315
* @property {number} [ttlMs] Nonce TTL; defaults to the registry's
314316
* own default (30s).
315-
* @property {() => GatewayAdmin} [getAdmin]
316-
* Returns the `GatewayAdmin` exo (Feature 7). Injected from the
317-
* gateway proper so the bootstrap and the gateway share a single
318-
* admin facet. When unset, the bootstrap's `getAdmin` method
319-
* throws "admin daemon is disabled"; the gateway proper supplies
320-
* this only when both `sockBootstrap` and `adminDaemon` toggles
321-
* are on. Keeping the admin wiring outside the bootstrap module
322-
* avoids a circular import between `bootstrap.js` and `admin.js`.
323317
*/
324318

325319
/**
@@ -353,7 +347,6 @@ export const makeGatewayBootstrap = ({
353347
apps,
354348
getBindAddress,
355349
ttlMs,
356-
getAdmin,
357350
}) => {
358351
if (crypto === undefined) {
359352
throw makeError(X`makeGatewayBootstrap requires crypto powers`);
@@ -550,14 +543,6 @@ export const makeGatewayBootstrap = ({
550543
async getApps() {
551544
return apps;
552545
},
553-
async getAdmin() {
554-
if (getAdmin === undefined) {
555-
throw makeError(
556-
X`Admin daemon is disabled (set enableFeatures.adminDaemon=true)`,
557-
);
558-
}
559-
return getAdmin();
560-
},
561546
}),
562547
);
563548

packages/gateway/src/config.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,6 @@ const validateFeatureDependencies = features => {
200200
X`captpRelay depends on sockBootstrap for registration; both must be enabled`,
201201
);
202202
}
203-
if (features.adminDaemon && !features.sockBootstrap) {
204-
throw makeError(
205-
X`adminDaemon depends on sockBootstrap for its access channel; both must be enabled`,
206-
);
207-
}
208203
if (features.chatHosting && !features.virtualHosting) {
209204
throw makeError(
210205
X`chatHosting depends on virtualHosting for the Chat weblet's bind; both must be enabled`,

0 commit comments

Comments
 (0)