feat(broker): compose offline broker process
- commit
- 0a1c4c5f93642f9745aa34f8b4481e688b4b550f
- author
- brice · · 2026-09-16 22:24 UTC
- parent
- 36c187d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
39 files · +8127 -87
M docs/architecture.md +187 -5
| @@ -46,6 +46,13 @@ vev uses a hexagonal core with typed session messages at the client and daemon b | |||
| 46 | 46 | the short-lived SSH bootstrap (ephemeral certificate, 32-byte token, | |
| 47 | 47 | nonce, ≤4 KiB readiness, ≤15 s expiry, atomic one-time consumption). | |
| 48 | 48 | QUIC library types never leave the package. | |
| 49 | + | - `internal/adapters/brokerconfig`: owns the strict isolated offline broker | |
| 50 | + | sandbox configuration (`config.json` marker), the textual offline-root | |
| 51 | + | validation that keeps the sandbox out of production runtime/state, and the | |
| 52 | + | immutable `ports.BrokerEndpointResolver` over the provisioned endpoint | |
| 53 | + | registrations, identities, policies, and absolute Unix mux routes. It is | |
| 54 | + | composed only by the hidden offline broker entry points (`_broker-serve`, | |
| 55 | + | `_broker-launcher`, and `_broker-status`). | |
| 49 | 56 | - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment. | |
| 50 | 57 | - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner. | |
| 51 | 58 | - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled. | |
| @@ -223,9 +230,23 @@ Every frame must carry the epoch and connection identity the listener assigned | |||
| 223 | 230 | at accept; a mismatched frame is fenced, and a stale stream open is answered | |
| 224 | 231 | with a `StreamClosed` carrying the stale admission code. A malformed, | |
| 225 | 232 | oversize, or wrong-direction envelope settles exactly the connection that sent | |
| 226 | - | it. Operational bounds are explicit: concurrent clients per listener, per-stream | |
| 227 | - | inbound chunks and bytes, pending mutating operations, and the brokerwire | |
| 228 | - | per-connection stream and dedup bounds. Disconnect cleanup is deterministic: | |
| 233 | + | it. Setup is fully bounded on both halves: the client dialer bounds the Unix | |
| 234 | + | dial, the preamble, the Register send, and the wait for Registered with one | |
| 235 | + | context/deadline, closing the carriage to interrupt a peer that stalls after a | |
| 236 | + | successful preamble and joining every setup worker, while a registered | |
| 237 | + | connection detaches from that setup context; the server requires the client's | |
| 238 | + | Register within the same accept-time handshake budget that bounded the preamble | |
| 239 | + | and admission, so a silent same-user peer can neither pin an admitted core lease | |
| 240 | + | nor hold a listener slot. Admission is the `ports.BrokerAuthority` seam, | |
| 241 | + | implemented by `internal/usecase/broker.Authority`; the pre-Register guard | |
| 242 | + | consults a registered marker set when Register handling begins, so a Register | |
| 243 | + | arriving exactly at the deadline is admitted rather than settled as a timeout, | |
| 244 | + | and a genuine registration timeout is classified as an orderly peer disconnect | |
| 245 | + | so a listener draining its sessions never reports one as its own failure. | |
| 246 | + | Operational bounds are explicit: concurrent clients | |
| 247 | + | per listener, per-stream inbound chunks and bytes, pending mutating operations, | |
| 248 | + | and the brokerwire per-connection stream and dedup bounds. Disconnect cleanup is | |
| 249 | + | deterministic: | |
| 229 | 250 | the session cancels its context, stops publishing, closes the outer carriage | |
| 230 | 251 | (so a stream writer parked on a non-reading peer is interrupted), settles every | |
| 231 | 252 | bridged stream (releasing its core connection and carriage), closes the | |
| @@ -235,6 +256,167 @@ reply is lost after the request was sent, and the client never replays them | |||
| 235 | 256 | blindly. | |
| 236 | 257 | ||
| 237 | 258 | P3.3 introduces no production activation: nothing in `internal/app` or `main` | |
| 238 | - | constructs this adapter yet. P3.4 composes the broker use case, its stores, and | |
| 239 | - | this listener behind a hidden entry point, and P7 performs the coordinated | |
| 259 | + | constructs this adapter for ordinary operation. P3.4 composes the broker use | |
| 260 | + | case, its stores, and this listener behind the hidden `_broker-serve` sandbox | |
| 261 | + | entry point, with the listener consuming | |
| 262 | + | `ports.BrokerAuthority` instead of defining its own seam. The composing caller | |
| 263 | + | owns the `Registry.Run` lifecycle: it starts the single run to own and drain the | |
| 264 | + | durable writer and cancels and joins that run before closing the store, because | |
| 265 | + | `NewAuthority` neither starts nor settles the run. P7 performs the coordinated | |
| 240 | 266 | cutover. | |
| 267 | + | ||
| 268 | + | ## Broker offline sandbox (Plan 001 P3.4 slice C, not activated) | |
| 269 | + | ||
| 270 | + | The hidden `_broker-serve --offline-root ABS [--idle-grace DURATION]` entry | |
| 271 | + | point is a foreground, isolated sandbox over one operator-supplied offline root. | |
| 272 | + | It is excluded from public help and changes no ordinary command, path, or | |
| 273 | + | factory. The root must be absolute, cleaned, symlink-free, and outside the | |
| 274 | + | production runtime and state directories; the runtime, state, and log paths are | |
| 275 | + | derived only beneath it and secured with `pkg/safedir.EnsurePrivate`, and the | |
| 276 | + | durable store is opened with `brokerstore.OpenOffline` over the sandbox state | |
| 277 | + | directory with no legacy inputs. | |
| 278 | + | ||
| 279 | + | `internal/adapters/brokerconfig` owns the strict sandbox configuration: a | |
| 280 | + | bounded, owner-only `config.json` carrying the exact `vev.broker.offline/v1` | |
| 281 | + | marker, with unknown fields, duplicate keys, trailing data, invalid UTF-8, | |
| 282 | + | excessive nesting, and duplicate endpoints refused. Each immutable registration | |
| 283 | + | provisions the exact daemon registration, the stable authenticated daemon | |
| 284 | + | identity, the exact connection policy, and one absolute Unix mux route. The | |
| 285 | + | resulting `ports.BrokerEndpointResolver` performs no I/O and invents no address: | |
| 286 | + | a request must name a configured endpoint, carry its exact registration, and | |
| 287 | + | request a compatible policy, so a request can never supply an address, identity, | |
| 288 | + | secret, command, or policy of its own. Because the pool keys one physical | |
| 289 | + | transport by the exact authenticated identity plus policy pair and deliberately | |
| 290 | + | ignores the opaque route address, two registrations that share that pair must | |
| 291 | + | agree on the one route the pool dials: an alias of an identical route is | |
| 292 | + | accepted under distinct endpoints, while a second route for the same pair is | |
| 293 | + | refused instead of being dialed for neither endpoint. | |
| 294 | + | ||
| 295 | + | The composition samples a cryptographically random non-zero broker epoch and | |
| 296 | + | builds a store-backed, observation-disabled `broker.Registry` (whose single | |
| 297 | + | `Run` the supervisor owns and joins), the immutable resolver, a | |
| 298 | + | `daemonmux.EndpointConnector` over `ipc.DialMuxContext`, a bounded `Pool`, the | |
| 299 | + | idle `Supervisor` (5m default grace), `broker.Authority`, and the | |
| 300 | + | `brokeripc` listener. A temporary startup lease pins the supervisor open until | |
| 301 | + | the listener and its accept drain are ready; the drain continuously takes | |
| 302 | + | admitted sessions without closing them, because each returned service is the | |
| 303 | + | listener's own connection-scoped session. A signal or parent-context | |
| 304 | + | cancellation, or the idle grace, shuts down in the fixed order listener -> | |
| 305 | + | pool -> registry -> store -> log -> owner lock. The foreground process holds a | |
| 306 | + | reusable lifetime owner lock (`internal/adapters/lifecycle.TryAcquire`) on the | |
| 307 | + | sandbox runtime directory for its whole run, so a second owner fails closed. | |
| 308 | + | The optional `idleGrace` field in `config.json` provisions the sandbox's | |
| 309 | + | effective idle grace as a Go duration string. It is the single deterministic | |
| 310 | + | source for that grace: `_broker-serve` and `_broker-launcher` refuse an explicit | |
| 311 | + | `--idle-grace` that conflicts with the provisioned value, because a status probe | |
| 312 | + | cannot read a running broker's effective grace through the broker IPC protocol. | |
| 313 | + | When no grace is provisioned, an explicit flag (or the supervisor default) | |
| 314 | + | applies. | |
| 315 | + | ||
| 316 | + | ## Broker offline connect-or-spawn and status (Plan 001 P3.4 slice D, not activated) | |
| 317 | + | ||
| 318 | + | The hidden `_broker-launcher --offline-root ABS [--idle-grace DURATION]` entry | |
| 319 | + | point is the intermediate half of a double fork: it validates the sandbox | |
| 320 | + | configuration (including the idle-grace conflict rule), starts one detached | |
| 321 | + | `_broker-serve` in a new session, and exits, exactly like the daemon launcher. | |
| 322 | + | The hidden `_broker-status --offline-root ABS [--ensure] [--timeout DURATION]` | |
| 323 | + | entry point reports the offline broker's state. Both are excluded from public | |
| 324 | + | help and change no ordinary command, path, or factory. | |
| 325 | + | ||
| 326 | + | Status without `--ensure` is dial-only: it performs one bounded probe of the | |
| 327 | + | sandbox socket and prints exactly one bounded JSON document, | |
| 328 | + | `{status, endpoint, epoch, revision, host_count}`, where `endpoint` is the | |
| 329 | + | broker IPC socket path and the epoch, revision, and host count are zero when the | |
| 330 | + | broker is offline. A missing socket and a stale socket left by a dead owner are | |
| 331 | + | both recoverable absence; a foreign path, a live endpoint that fails the broker | |
| 332 | + | handshake, and a stalled handshake are incompatible and never respawned over. | |
| 333 | + | `--timeout` is the probe's single absolute bound in dial-only mode, so a caller | |
| 334 | + | can tighten the probe or rely on the default (10s) and observe the same bound. | |
| 335 | + | Dial-only status always exits zero, so the JSON is the sole result. `--ensure` | |
| 336 | + | runs one absolute overall deadline (default 10s): it validates and secures the | |
| 337 | + | sandbox root exactly as the launcher does before creating the spawn directory or | |
| 338 | + | taking any lock. On success it prints the ready report and exits zero; on any | |
| 339 | + | layout, configuration, or startup failure it still prints the offline report but | |
| 340 | + | exits 3, so the caller always observes one offline JSON document and a distinct | |
| 341 | + | exit code. The launcher is spawned under the ensure context | |
| 342 | + | (`exec.CommandContext`), so the overall deadline or a signal bounds a launcher | |
| 343 | + | that never exits; a launcher that already started the detached `_broker-serve` | |
| 344 | + | is still safe to kill, because that serve is in its own session and survives. | |
| 345 | + | ||
| 346 | + | A foreground `_broker-serve` that hits an unexpected terminal accept failure | |
| 347 | + | commits the same ordered shutdown as a cancellation, so a socket that can no | |
| 348 | + | longer admit work is never left bound. | |
| 349 | + | ||
| 350 | + | Race safety rests on two descriptor-backed exclusive locks that are never | |
| 351 | + | stolen by age and whose inodes are never unlinked. Both refuse any lock with | |
| 352 | + | group or other access and trust an owner-only file whatever its exact owner bit | |
| 353 | + | pattern, so a lock created under a restrictive umask (normalized to 0600 on | |
| 354 | + | creation) or by a different tool never wedges startup; a group/other-readable | |
| 355 | + | path stays refused. The broker lifetime lock | |
| 356 | + | (`layout.Runtime/lifecycle.lock`) is held by the foreground `_broker-serve` for | |
| 357 | + | its whole run; only the elected spawner probes it, and only as a liveness hint. | |
| 358 | + | The spawn-election lock (`layout.Spawn/lifecycle.lock`, a separate private | |
| 359 | + | directory beneath runtime) elects exactly one status caller to spawn. The | |
| 360 | + | elected spawner re-dials after winning the lock, then holds the election until | |
| 361 | + | the endpoint answers a valid broker registration and publishes its initial | |
| 362 | + | snapshot, or until the deadline expires. A waiter never touches the lifetime | |
| 363 | + | lock, so it can never briefly steal ownership from the broker it is waiting for, | |
| 364 | + | and every wait is a bounded retry rather than a sleep loop. If the elected | |
| 365 | + | spawner dies before readiness, the kernel releases its lock and a waiter takes | |
| 366 | + | over; a duplicate `_broker-serve` fails its own lifetime acquisition | |
| 367 | + | nonblocking. Every spawn uses the existing double-fork process pattern, and the | |
| 368 | + | status probe and launcher inherit only the ambient environment minus the | |
| 369 | + | performance-trace inputs. | |
| 370 | + | ||
| 371 | + | ## Broker remote mux helpers (Plan 001 P3.4 slice E, not activated) | |
| 372 | + | ||
| 373 | + | The offline broker reaches a remote daemon's private daemonmux carriage over one | |
| 374 | + | of three explicitly provisioned, bounded routes, and P3.4 slice E adds the | |
| 375 | + | transport-selecting connector plus three hidden remote helpers. The routing | |
| 376 | + | schema is strict and immutable: `internal/adapters/brokerconfig` parses a | |
| 377 | + | registration's `route` as either the original Unix mux path string or an object | |
| 378 | + | with an explicit kind, and refuses unknown kinds, unknown fields, trailing data, | |
| 379 | + | and any kind/field mismatch. The bounded `ssh-stdio` and `ssh-quic` variants | |
| 380 | + | carry an ssh target, an explicit remote argv, and optional trust inputs (a | |
| 381 | + | known-hosts file and a connect timeout). A route can never carry an identity, a | |
| 382 | + | policy, a command chosen at request time, or a secret. Identity and policy stay | |
| 383 | + | authoritative on the registration, and the resolver publishes only an opaque | |
| 384 | + | bounded route address: a digest of the route's own contents, so the pooling | |
| 385 | + | layer never carries a target, a path, or an argv word. | |
| 386 | + | ||
| 387 | + | `_broker-serve` composes a transport-selecting `daemonmux.EndpointConnector`. Its | |
| 388 | + | dial function receives only the opaque address, looks the route up in the | |
| 389 | + | immutable configuration it was built with, and refuses an address the | |
| 390 | + | configuration did not produce. A `unix` route dials the provisioned private Unix | |
| 391 | + | mux carriage directly; an `ssh-stdio` route starts one explicit ssh child (built | |
| 392 | + | with `sshstdio.BuildCommandForMux` and carrying only trust inputs that narrow | |
| 393 | + | verification, never disabling host-key checking, forcing an auth bypass, or | |
| 394 | + | requesting a PTY) through `sshstdio.DialMuxContext`; an `ssh-quic` route runs one | |
| 395 | + | SSH-authenticated QUIC bootstrap, reads exactly one readiness line, and pins the | |
| 396 | + | freshly minted ephemeral certificate through `quic.DialMuxContext`. One | |
| 397 | + | caller-supplied setup context bounds bootstrap, pinned dial, and the daemonmux | |
| 398 | + | physical handshake together; `brokerMuxSetupTimeout` is an independent backstop | |
| 399 | + | for the broker-side bootstrap readiness wait and the child's kill/reap cleanup, | |
| 400 | + | so an earlier caller deadline stays authoritative while a helper that never | |
| 401 | + | prints readiness is still interrupted and reaped within the bound. The captured | |
| 402 | + | bootstrap stderr sink is read only after that kill and reap, and the sink is | |
| 403 | + | itself synchronized, so the os/exec copy goroutine never races the diagnostic. A | |
| 404 | + | successful physical connection detaches from that context and stays pool-owned. | |
| 405 | + | QUIC credentials are minted fresh per physical | |
| 406 | + | connection and consumed exactly once, and captured bootstrap stderr is bounded | |
| 407 | + | and sanitized, so a token, a nonce, or raw remote text never reaches an address, | |
| 408 | + | a log, or an error. | |
| 409 | + | ||
| 410 | + | The three hidden helpers are excluded from public help. `_broker-mux-stdio | |
| 411 | + | --offline-root ABS` validates the sandbox and bridges its own stdio to the single | |
| 412 | + | provisioned private Unix daemonmux carriage. `_broker-mux-quic-bootstrap | |
| 413 | + | --offline-root ABS` validates the sandbox, starts one detached | |
| 414 | + | `_broker-mux-quic-proxy` in a new session, forwards its single readiness line, | |
| 415 | + | and exits. `_broker-mux-quic-proxy --offline-root ABS` mints one ephemeral QUIC | |
| 416 | + | server, admits exactly one authenticated carriage, and bridges it to the same | |
| 417 | + | private Unix daemonmux carriage. A helper only ever bridges to the configured | |
| 418 | + | private endpoint: it never starts a broker, an observer, or an ordinary daemon, | |
| 419 | + | never dials the production daemon socket, and never fabricates a daemon | |
| 420 | + | incarnation, which the daemon on the far side of the carriage alone owns. | |
| 421 | + | Ordinary production composition still waits for P7; the helpers change no | |
| 422 | + | ordinary command, path, or factory. | |
M docs/protocol.md +10 -1
| @@ -117,7 +117,16 @@ composition until P3.2. The P3.3 private local endpoint | |||
| 117 | 117 | (`internal/adapters/brokeripc`) carries this conversation over one per-user | |
| 118 | 118 | AF_UNIX socket: `broker.sock` in the per-user runtime directory, with | |
| 119 | 119 | same-user peer-credential admission, bounded per-connection queues, and | |
| 120 | - | production composition deferred to P3.4 and the P7 cutover. | |
| 120 | + | production composition deferred to P3.4 and the P7 cutover. P3.4 adds | |
| 121 | + | the hidden, isolated offline sandbox entries -- `_broker-serve`, | |
| 122 | + | `_broker-launcher`, and `_broker-status` -- over an operator-supplied private | |
| 123 | + | root, plus the hidden remote mux helpers `_broker-mux-stdio`, | |
| 124 | + | `_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` that bridge a | |
| 125 | + | provisioned private daemonmux Unix carriage to stdio or to one freshly | |
| 126 | + | authenticated QUIC stream; ordinary production composition still waits for P7. | |
| 127 | + | Setup is fully bounded: the dialer's one context/deadline covers the Unix | |
| 128 | + | dial, preamble, Register send, and wait for Registered, and the server requires Register within | |
| 129 | + | the same accept-time handshake budget that bounded the preamble and admission. | |
| 121 | 130 | ||
| 122 | 131 | ## Output, ACK, and flow control | |
| 123 | 132 | ||
A internal/adapters/brokerconfig/config.go +607 -0
| @@ -0,0 +1,607 @@ | |||
| 1 | + | // Package brokerconfig owns the strict, isolated offline broker sandbox | |
| 2 | + | // configuration (Plan 001 P3.4 slice C). | |
| 3 | + | // | |
| 4 | + | // It is deliberately not production composition: nothing in internal/app or | |
| 5 | + | // main consults it for ordinary daemon operation. It resolves one caller- | |
| 6 | + | // supplied offline root into sandbox-owned runtime, state, and log paths, | |
| 7 | + | // validates that the root is a private, absolute, cleaned, symlink-free | |
| 8 | + | // directory that does not overlap production runtime or state, and strictly | |
| 9 | + | // parses the bounded config.json marker that provisions immutable endpoint | |
| 10 | + | // registrations, each carrying a stable daemon identity, an exact connection | |
| 11 | + | // policy, and one bounded route (a private Unix mux socket, an SSH stdio mux | |
| 12 | + | // helper, or an SSH-authenticated QUIC bootstrap). | |
| 13 | + | // | |
| 14 | + | // The parsed configuration is immutable and is the only source of endpoint | |
| 15 | + | // addresses, identities, and policies. A client request can name a configured | |
| 16 | + | // endpoint and must carry that endpoint's exact registration and compatible | |
| 17 | + | // policy; it can never supply an address, identity, secret, command, or policy | |
| 18 | + | // of its own, and an unknown or stale request is refused without dialing. The | |
| 19 | + | // route's pool address is an opaque digest, so no route detail is exposed to the | |
| 20 | + | // pooling layer. | |
| 21 | + | // | |
| 22 | + | // Because the pool keys one physical transport by the exact authenticated | |
| 23 | + | // identity plus policy pair and ignores that opaque address, two registrations | |
| 24 | + | // that share the pair must agree on the one route the pool dials: distinct | |
| 25 | + | // endpoints may alias an identical route, but a second route for the same pair | |
| 26 | + | // is refused rather than silently stranded. | |
| 27 | + | package brokerconfig | |
| 28 | + | ||
| 29 | + | import ( | |
| 30 | + | "bytes" | |
| 31 | + | "encoding/hex" | |
| 32 | + | "encoding/json" | |
| 33 | + | "errors" | |
| 34 | + | "fmt" | |
| 35 | + | "io" | |
| 36 | + | "io/fs" | |
| 37 | + | "os" | |
| 38 | + | "path/filepath" | |
| 39 | + | "strings" | |
| 40 | + | "syscall" | |
| 41 | + | "time" | |
| 42 | + | "unicode/utf8" | |
| 43 | + | ||
| 44 | + | "github.com/bnema/vev/internal/domain" | |
| 45 | + | "github.com/bnema/vev/internal/ports" | |
| 46 | + | "github.com/bnema/vev/internal/protocol" | |
| 47 | + | ) | |
| 48 | + | ||
| 49 | + | // Marker is the exact config.json marker that identifies a broker offline | |
| 50 | + | // sandbox configuration. A file without it is never treated as broker config, | |
| 51 | + | // so an accidental --offline-root pointing at unrelated JSON fails closed. | |
| 52 | + | const Marker = "vev.broker.offline/v1" | |
| 53 | + | ||
| 54 | + | // ConfigFileName is the fixed config file name inside one offline root. | |
| 55 | + | const ConfigFileName = "config.json" | |
| 56 | + | ||
| 57 | + | // MaxConfigBytes bounds the config file before any decoding. | |
| 58 | + | const MaxConfigBytes = 1 << 20 | |
| 59 | + | ||
| 60 | + | // MaxIdleGrace bounds a provisioned idle grace so an accidental enormous value | |
| 61 | + | // cannot make an offline broker effectively immortal. | |
| 62 | + | const MaxIdleGrace = 24 * time.Hour | |
| 63 | + | ||
| 64 | + | // Sandbox directory names derived from one offline root. SpawnDirName is the | |
| 65 | + | // private directory under Runtime that holds the descriptor-backed | |
| 66 | + | // spawn-election lock, deliberately separate from the broker lifetime lock in | |
| 67 | + | // Runtime itself so electing a spawner can never collide with lifetime | |
| 68 | + | // ownership. | |
| 69 | + | const ( | |
| 70 | + | RuntimeDirName = "runtime" | |
| 71 | + | StateDirName = "state" | |
| 72 | + | LogDirName = "log" | |
| 73 | + | SpawnDirName = "spawn" | |
| 74 | + | ) | |
| 75 | + | ||
| 76 | + | var ( | |
| 77 | + | // errUnknownEndpoint reports a request for an endpoint the immutable | |
| 78 | + | // configuration never provisioned. | |
| 79 | + | errUnknownEndpoint = errors.New("brokerconfig: unknown broker endpoint") | |
| 80 | + | // errStaleRegistration reports a request whose registration no longer | |
| 81 | + | // matches the provisioned immutable registration. | |
| 82 | + | errStaleRegistration = errors.New("brokerconfig: stale broker registration") | |
| 83 | + | // errNoLocalRoute reports a local request: the offline sandbox provisions | |
| 84 | + | // remote Unix mux routes only, so it owns no local service binding. | |
| 85 | + | errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route") | |
| 86 | + | ) | |
| 87 | + | ||
| 88 | + | // Layout names the sandbox-owned paths derived from one offline root. Every | |
| 89 | + | // path is beneath Root; no production runtime or state path is ever derived. It | |
| 90 | + | // also carries the reserved production directories it was validated against, so | |
| 91 | + | // Load can fence every provisioned route against them too. | |
| 92 | + | type Layout struct { | |
| 93 | + | Root string | |
| 94 | + | Runtime string | |
| 95 | + | State string | |
| 96 | + | Log string | |
| 97 | + | // Spawn is the private spawn-election directory beneath Runtime. | |
| 98 | + | Spawn string | |
| 99 | + | ||
| 100 | + | // reserved holds the cleaned absolute production runtime and state paths | |
| 101 | + | // supplied to ResolveLayout. Load refuses a provisioned route that overlaps | |
| 102 | + | // them; it is unexported so only ResolveLayout can set it. | |
| 103 | + | reserved []string | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // ResolveLayout validates one offline root and derives its sandbox paths. | |
| 107 | + | // | |
| 108 | + | // The root must be absolute and clean, must not be the filesystem root, must | |
| 109 | + | // not contain a symlinked component, and must not equal, contain, or sit inside | |
| 110 | + | // any reserved production directory. Reserved directories are compared cleaned | |
| 111 | + | // and absolute; empty entries are ignored. The result is purely textual: no | |
| 112 | + | // directory is created here, and the caller secures the paths with | |
| 113 | + | // pkg/safedir.EnsurePrivate. | |
| 114 | + | func ResolveLayout(rawRoot string, reserved []string) (Layout, error) { | |
| 115 | + | if rawRoot == "" { | |
| 116 | + | return Layout{}, errors.New("brokerconfig: offline root is empty") | |
| 117 | + | } | |
| 118 | + | if !filepath.IsAbs(rawRoot) { | |
| 119 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not absolute", rawRoot) | |
| 120 | + | } | |
| 121 | + | if cleaned := filepath.Clean(rawRoot); cleaned != rawRoot { | |
| 122 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not clean", rawRoot) | |
| 123 | + | } | |
| 124 | + | if rawRoot == string(filepath.Separator) { | |
| 125 | + | return Layout{}, errors.New("brokerconfig: offline root must not be the filesystem root") | |
| 126 | + | } | |
| 127 | + | if err := rejectSymlinkedComponents(rawRoot); err != nil { | |
| 128 | + | return Layout{}, err | |
| 129 | + | } | |
| 130 | + | for _, other := range reserved { | |
| 131 | + | if other == "" { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | other = filepath.Clean(other) | |
| 135 | + | if !filepath.IsAbs(other) { | |
| 136 | + | return Layout{}, fmt.Errorf("brokerconfig: reserved directory %q is not absolute", other) | |
| 137 | + | } | |
| 138 | + | if overlaps(rawRoot, other) { | |
| 139 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q overlaps production path %q", rawRoot, other) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | runtime := filepath.Join(rawRoot, RuntimeDirName) | |
| 143 | + | return Layout{ | |
| 144 | + | Root: rawRoot, | |
| 145 | + | Runtime: runtime, | |
| 146 | + | State: filepath.Join(rawRoot, StateDirName), | |
| 147 | + | Log: filepath.Join(rawRoot, LogDirName), | |
| 148 | + | Spawn: filepath.Join(runtime, SpawnDirName), | |
| 149 | + | reserved: append([]string(nil), reserved...), | |
| 150 | + | }, nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // VerifyCreated re-walks every sandbox-owned path and refuses any existing | |
| 154 | + | // component that is a symlink. | |
| 155 | + | // | |
| 156 | + | // ResolveLayout validates the root textually and stops at the first component | |
| 157 | + | // that does not exist yet, so the composing caller calls this once | |
| 158 | + | // pkg/safedir.EnsurePrivate has created the runtime, state, and log | |
| 159 | + | // directories. It closes the gap between that validation and the first use of | |
| 160 | + | // the created paths: a component swapped for a symlink in the window fails | |
| 161 | + | // closed instead of being followed. | |
| 162 | + | func (l Layout) VerifyCreated() error { | |
| 163 | + | for _, path := range []string{l.Root, l.Runtime, l.State, l.Log, l.Spawn} { | |
| 164 | + | if err := rejectSymlinkedComponents(path); err != nil { | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | return nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // rejectSymlinkedComponents refuses a path any of whose existing components is | |
| 172 | + | // a symlink. A component that does not exist yet ends the walk: the caller | |
| 173 | + | // creates the remaining private directories itself. | |
| 174 | + | func rejectSymlinkedComponents(abs string) error { | |
| 175 | + | volume := filepath.VolumeName(abs) | |
| 176 | + | rest := strings.TrimPrefix(abs, volume) | |
| 177 | + | rest = strings.TrimPrefix(rest, string(filepath.Separator)) | |
| 178 | + | current := volume + string(filepath.Separator) | |
| 179 | + | if rest == "" { | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | for _, part := range strings.Split(rest, string(filepath.Separator)) { | |
| 183 | + | if part == "" { | |
| 184 | + | continue | |
| 185 | + | } | |
| 186 | + | current = filepath.Join(current, part) | |
| 187 | + | info, err := os.Lstat(current) | |
| 188 | + | if err != nil { | |
| 189 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | return fmt.Errorf("brokerconfig: inspect %q: %w", current, err) | |
| 193 | + | } | |
| 194 | + | if info.Mode()&os.ModeSymlink != 0 { | |
| 195 | + | return fmt.Errorf("brokerconfig: %q is a symlink", current) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // overlaps reports whether two absolute cleaned paths are equal or one contains | |
| 202 | + | // the other. | |
| 203 | + | func overlaps(a, b string) bool { | |
| 204 | + | if a == b { | |
| 205 | + | return true | |
| 206 | + | } | |
| 207 | + | sep := string(filepath.Separator) | |
| 208 | + | return strings.HasPrefix(a, b+sep) || strings.HasPrefix(b, a+sep) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Registration is one immutable, provisioned endpoint: the exact daemon | |
| 212 | + | // registration a request must carry, the stable authenticated daemon identity, | |
| 213 | + | // the exact connection policy, and the bounded route the connector dials. | |
| 214 | + | type Registration struct { | |
| 215 | + | Registration domain.RemoteRegistration | |
| 216 | + | Identity ports.BrokerDaemonIdentity | |
| 217 | + | Policy ports.BrokerPolicy | |
| 218 | + | Route Route | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // pooledIdentity is the exact (authenticated identity, policy) pair the broker | |
| 222 | + | // pool keys one physical transport by. The pool deliberately ignores the opaque | |
| 223 | + | // route address, so two registrations that share a pair share one pooled | |
| 224 | + | // transport and must therefore agree on the single route that transport dials. | |
| 225 | + | type pooledIdentity struct { | |
| 226 | + | identity ports.BrokerDaemonIdentity | |
| 227 | + | policy ports.BrokerPolicy | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // Config is the validated, immutable offline broker configuration. | |
| 231 | + | type Config struct { | |
| 232 | + | byEndpoint map[string]Registration | |
| 233 | + | byAddress map[string]Route | |
| 234 | + | unixRoutes []Route | |
| 235 | + | order []string | |
| 236 | + | // idleGrace is the provisioned effective idle grace; hasIdleGrace records | |
| 237 | + | // whether the file provisioned one, so a caller can distinguish "no | |
| 238 | + | // provisioning, use the built-in default" from "provisioned". | |
| 239 | + | idleGrace time.Duration | |
| 240 | + | hasIdleGrace bool | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Load reads and strictly validates the config marker inside layout.Root. | |
| 244 | + | // | |
| 245 | + | // The file must be a regular owner-only file that is not a symlink and is | |
| 246 | + | // bounded by MaxConfigBytes; the JSON must carry the exact marker, reject | |
| 247 | + | // unknown fields, duplicate keys, trailing data, invalid UTF-8, and excessive | |
| 248 | + | // nesting. Every registration is validated independently and endpoints must be | |
| 249 | + | // unique. Two registrations may alias one identical route (the same opaque | |
| 250 | + | // address) under different endpoints, but two that share the pool's exact | |
| 251 | + | // (authenticated identity, policy) pair must agree on that one route: the pool | |
| 252 | + | // keys a physical transport by the pair and ignores the address, so a second | |
| 253 | + | // route for the same pair is refused rather than silently dialed or ignored. A | |
| 254 | + | // missing or malformed config is an error, never an empty default. | |
| 255 | + | func Load(layout Layout) (*Config, error) { | |
| 256 | + | raw, err := readConfig(filepath.Join(layout.Root, ConfigFileName)) | |
| 257 | + | if err != nil { | |
| 258 | + | return nil, err | |
| 259 | + | } | |
| 260 | + | var document configDocument | |
| 261 | + | if err := decodeStrict(raw, &document); err != nil { | |
| 262 | + | return nil, fmt.Errorf("brokerconfig: %s: %w", ConfigFileName, err) | |
| 263 | + | } | |
| 264 | + | if document.Marker != Marker { | |
| 265 | + | return nil, fmt.Errorf("brokerconfig: %s: missing or invalid marker", ConfigFileName) | |
| 266 | + | } | |
| 267 | + | config := &Config{ | |
| 268 | + | byEndpoint: make(map[string]Registration, len(document.Registrations)), | |
| 269 | + | byAddress: make(map[string]Route, len(document.Registrations)), | |
| 270 | + | order: make([]string, 0, len(document.Registrations)), | |
| 271 | + | } | |
| 272 | + | if document.IdleGrace != "" { | |
| 273 | + | grace, err := time.ParseDuration(document.IdleGrace) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, fmt.Errorf("brokerconfig: %s: idleGrace %q is not a duration", ConfigFileName, document.IdleGrace) | |
| 276 | + | } | |
| 277 | + | if grace <= 0 || grace > MaxIdleGrace { | |
| 278 | + | return nil, fmt.Errorf("brokerconfig: %s: idleGrace must be positive and at most %s", ConfigFileName, MaxIdleGrace) | |
| 279 | + | } | |
| 280 | + | config.idleGrace, config.hasIdleGrace = grace, true | |
| 281 | + | } | |
| 282 | + | pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)) | |
| 283 | + | for i, entry := range document.Registrations { | |
| 284 | + | registration, err := entry.validate() | |
| 285 | + | if err != nil { | |
| 286 | + | return nil, fmt.Errorf("brokerconfig: registration %d: %w", i, err) | |
| 287 | + | } | |
| 288 | + | if registration.Route.IsLocal() { | |
| 289 | + | if err := rejectRouteOverlap(registration.Route.Path(), layout.reserved); err != nil { | |
| 290 | + | return nil, fmt.Errorf("brokerconfig: registration %d: %w", i, err) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | if _, duplicate := config.byEndpoint[registration.Registration.Endpoint]; duplicate { | |
| 294 | + | return nil, fmt.Errorf("brokerconfig: registration %d: duplicate endpoint %q", i, registration.Registration.Endpoint) | |
| 295 | + | } | |
| 296 | + | // The pool keys one physical transport by (identity, policy) and ignores | |
| 297 | + | // the route address, so a second address for the same pair would be | |
| 298 | + | // dialed for neither or both depending on scheduling. Refuse it and allow | |
| 299 | + | // an alias only when the routes are identical. | |
| 300 | + | key := pooledIdentity{identity: registration.Identity, policy: registration.Policy} | |
| 301 | + | address := registration.Route.Address() | |
| 302 | + | if existing, present := pooledRoutes[key]; present && existing != address { | |
| 303 | + | return nil, fmt.Errorf("brokerconfig: registration %d: identity %q and policy already provisioned on a different route", i, registration.Identity) | |
| 304 | + | } | |
| 305 | + | pooledRoutes[key] = address | |
| 306 | + | config.byEndpoint[registration.Registration.Endpoint] = registration | |
| 307 | + | config.byAddress[address] = registration.Route | |
| 308 | + | if registration.Route.IsLocal() { | |
| 309 | + | config.unixRoutes = appendUnixRouteOnce(config.unixRoutes, registration.Route) | |
| 310 | + | } | |
| 311 | + | config.order = append(config.order, registration.Registration.Endpoint) | |
| 312 | + | } | |
| 313 | + | return config, nil | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // appendUnixRouteOnce adds a local Unix route unless an identical route (the | |
| 317 | + | // same opaque address, hence the same bounded contents) is already present, so | |
| 318 | + | // endpoint aliases of one route never make LocalMuxRoute report more than one | |
| 319 | + | // provisioned route. | |
| 320 | + | func appendUnixRouteOnce(routes []Route, route Route) []Route { | |
| 321 | + | for _, existing := range routes { | |
| 322 | + | if existing.Address() == route.Address() { | |
| 323 | + | return routes | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | return append(routes, route) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // Endpoints returns the configured endpoints in file order. The result is a | |
| 330 | + | // copy; the configuration is immutable. | |
| 331 | + | func (c *Config) Endpoints() []string { | |
| 332 | + | if c == nil { | |
| 333 | + | return nil | |
| 334 | + | } | |
| 335 | + | return append([]string(nil), c.order...) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // IdleGrace returns the provisioned effective idle grace and whether the | |
| 339 | + | // configuration provisioned one. A nil configuration provisions nothing. | |
| 340 | + | func (c *Config) IdleGrace() (time.Duration, bool) { | |
| 341 | + | if c == nil { | |
| 342 | + | return 0, false | |
| 343 | + | } | |
| 344 | + | return c.idleGrace, c.hasIdleGrace | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // RouteByAddress resolves one opaque pool address back to the route it was | |
| 348 | + | // derived from. The address is produced only by this configuration's own | |
| 349 | + | // routes, so an unknown address is refused rather than dialed. | |
| 350 | + | func (c *Config) RouteByAddress(address string) (Route, bool) { | |
| 351 | + | if c == nil { | |
| 352 | + | return Route{}, false | |
| 353 | + | } | |
| 354 | + | route, ok := c.byAddress[address] | |
| 355 | + | return route, ok | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | |
| 359 | + | // helper bridges to. A configuration that provisions no Unix route, or more | |
| 360 | + | // than one, is refused: a helper must never guess which daemon-mux endpoint it | |
| 361 | + | // serves. | |
| 362 | + | func (c *Config) LocalMuxRoute() (Route, error) { | |
| 363 | + | if c == nil { | |
| 364 | + | return Route{}, errors.New("brokerconfig: no configuration") | |
| 365 | + | } | |
| 366 | + | switch len(c.unixRoutes) { | |
| 367 | + | case 0: | |
| 368 | + | return Route{}, errors.New("brokerconfig: no Unix daemon-mux route is provisioned") | |
| 369 | + | case 1: | |
| 370 | + | return c.unixRoutes[0], nil | |
| 371 | + | default: | |
| 372 | + | return Route{}, errors.New("brokerconfig: more than one Unix daemon-mux route is provisioned") | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // Resolver returns the immutable endpoint resolver over this configuration. | |
| 377 | + | func (c *Config) Resolver() *Resolver { | |
| 378 | + | if c == nil { | |
| 379 | + | return &Resolver{byEndpoint: map[string]Registration{}} | |
| 380 | + | } | |
| 381 | + | return &Resolver{byEndpoint: c.byEndpoint} | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // configDocument is the strict on-disk shape. Unknown fields are refused by the | |
| 385 | + | // decoder, so the file cannot smuggle additional behavior. | |
| 386 | + | type configDocument struct { | |
| 387 | + | Marker string `json:"marker"` | |
| 388 | + | // IdleGrace optionally provisions the sandbox's effective idle grace as a Go | |
| 389 | + | // duration string (for example "90s"). It is the single deterministic source | |
| 390 | + | // for the idle grace, because a status probe cannot read a running broker's | |
| 391 | + | // effective grace through the broker IPC protocol. | |
| 392 | + | IdleGrace string `json:"idleGrace,omitempty"` | |
| 393 | + | Registrations []registrationRaw `json:"registrations"` | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // registrationRaw is the raw JSON registration before semantic validation. | |
| 397 | + | type registrationRaw struct { | |
| 398 | + | Endpoint string `json:"endpoint"` | |
| 399 | + | Incarnation string `json:"incarnation"` | |
| 400 | + | Generation uint64 `json:"generation"` | |
| 401 | + | Identity string `json:"identity"` | |
| 402 | + | Route json.RawMessage `json:"route"` | |
| 403 | + | Policy policyRaw `json:"policy"` | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // policyRaw is the raw JSON policy before semantic validation. | |
| 407 | + | type policyRaw struct { | |
| 408 | + | ProtocolVersion uint16 `json:"protocolVersion"` | |
| 409 | + | CatalogSchemaVersion uint16 `json:"catalogSchemaVersion"` | |
| 410 | + | EnvironmentPolicy string `json:"environmentPolicy"` | |
| 411 | + | Transport string `json:"transport"` | |
| 412 | + | Trust string `json:"trust"` | |
| 413 | + | Launch string `json:"launch"` | |
| 414 | + | Isolation string `json:"isolation"` | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (r registrationRaw) validate() (Registration, error) { | |
| 418 | + | var registration domain.RemoteRegistration | |
| 419 | + | registration.Endpoint = r.Endpoint | |
| 420 | + | if err := domain.ValidateRemoteHostTarget(r.Endpoint); err != nil { | |
| 421 | + | return Registration{}, err | |
| 422 | + | } | |
| 423 | + | if len(r.Incarnation) != 32 { | |
| 424 | + | return Registration{}, errors.New("incarnation must be 32 hexadecimal characters") | |
| 425 | + | } | |
| 426 | + | decoded, err := hex.DecodeString(r.Incarnation) | |
| 427 | + | if err != nil { | |
| 428 | + | return Registration{}, fmt.Errorf("incarnation: %w", err) | |
| 429 | + | } | |
| 430 | + | copy(registration.Incarnation[:], decoded) | |
| 431 | + | registration.Generation = domain.RemoteGeneration(r.Generation) | |
| 432 | + | if err := registration.Validate(); err != nil { | |
| 433 | + | return Registration{}, err | |
| 434 | + | } | |
| 435 | + | identity := ports.BrokerDaemonIdentity(r.Identity) | |
| 436 | + | if err := identity.Validate(); err != nil { | |
| 437 | + | return Registration{}, err | |
| 438 | + | } | |
| 439 | + | policy, err := r.Policy.validate() | |
| 440 | + | if err != nil { | |
| 441 | + | return Registration{}, err | |
| 442 | + | } | |
| 443 | + | route, err := parseRoute(r.Route) | |
| 444 | + | if err != nil { | |
| 445 | + | return Registration{}, err | |
| 446 | + | } | |
| 447 | + | return Registration{Registration: registration, Identity: identity, Policy: policy, Route: route}, nil | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (p policyRaw) validate() (ports.BrokerPolicy, error) { | |
| 451 | + | var environment protocol.EnvironmentPolicy | |
| 452 | + | switch p.EnvironmentPolicy { | |
| 453 | + | case "client-owned": | |
| 454 | + | environment = protocol.EnvironmentPolicyClientOwned | |
| 455 | + | case "daemon-owned": | |
| 456 | + | environment = protocol.EnvironmentPolicyDaemonOwned | |
| 457 | + | default: | |
| 458 | + | return ports.BrokerPolicy{}, fmt.Errorf("policy environmentPolicy %q is invalid", p.EnvironmentPolicy) | |
| 459 | + | } | |
| 460 | + | policy := ports.BrokerPolicy{ | |
| 461 | + | ProtocolVersion: p.ProtocolVersion, | |
| 462 | + | CatalogSchemaVersion: p.CatalogSchemaVersion, | |
| 463 | + | EnvironmentPolicy: environment, | |
| 464 | + | Transport: p.Transport, | |
| 465 | + | Trust: p.Trust, | |
| 466 | + | Launch: p.Launch, | |
| 467 | + | Isolation: p.Isolation, | |
| 468 | + | } | |
| 469 | + | if err := policy.Validate(); err != nil { | |
| 470 | + | return ports.BrokerPolicy{}, err | |
| 471 | + | } | |
| 472 | + | return policy, nil | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | // rejectRouteOverlap refuses a provisioned Unix mux route that equals, contains, | |
| 476 | + | // or sits inside a reserved production runtime or state directory. A route is an | |
| 477 | + | // address the broker connector dials, so a route overlapping production would | |
| 478 | + | // let the isolated offline sandbox reach a production endpoint even though the | |
| 479 | + | // sandbox root itself is outside production. Reserved entries are cleaned | |
| 480 | + | // absolute paths; empty entries are ignored. | |
| 481 | + | func rejectRouteOverlap(route string, reserved []string) error { | |
| 482 | + | for _, other := range reserved { | |
| 483 | + | if other == "" { | |
| 484 | + | continue | |
| 485 | + | } | |
| 486 | + | other = filepath.Clean(other) | |
| 487 | + | if overlaps(route, other) { | |
| 488 | + | return fmt.Errorf("route %q overlaps production path %q", route, other) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | return nil | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | // validateRoute refuses anything but an absolute, cleaned Unix path without | |
| 495 | + | // symlinked components, so a provisioned route can never be reshaped by the | |
| 496 | + | // request path or escape through a link. | |
| 497 | + | func validateRoute(route string) error { | |
| 498 | + | if route == "" { | |
| 499 | + | return errors.New("route is empty") | |
| 500 | + | } | |
| 501 | + | if !filepath.IsAbs(route) { | |
| 502 | + | return fmt.Errorf("route %q is not absolute", route) | |
| 503 | + | } | |
| 504 | + | if filepath.Clean(route) != route { | |
| 505 | + | return fmt.Errorf("route %q is not clean", route) | |
| 506 | + | } | |
| 507 | + | return rejectSymlinkedComponents(route) | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | // readConfig reads one bounded regular owner-only config file without following | |
| 511 | + | // a symlink. | |
| 512 | + | func readConfig(path string) ([]byte, error) { | |
| 513 | + | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |
| 514 | + | if err != nil { | |
| 515 | + | if errors.Is(err, syscall.ELOOP) { | |
| 516 | + | return nil, fmt.Errorf("brokerconfig: %s is a symlink", path) | |
| 517 | + | } | |
| 518 | + | return nil, fmt.Errorf("brokerconfig: open %s: %w", path, err) | |
| 519 | + | } | |
| 520 | + | file := os.NewFile(uintptr(fd), path) | |
| 521 | + | defer file.Close() | |
| 522 | + | info, err := file.Stat() | |
| 523 | + | if err != nil { | |
| 524 | + | return nil, fmt.Errorf("brokerconfig: stat %s: %w", path, err) | |
| 525 | + | } | |
| 526 | + | if !info.Mode().IsRegular() { | |
| 527 | + | return nil, fmt.Errorf("brokerconfig: %s is not a regular file", path) | |
| 528 | + | } | |
| 529 | + | if info.Mode().Perm()&0o077 != 0 { | |
| 530 | + | return nil, fmt.Errorf("brokerconfig: %s has permissions %04o, want owner-only", path, info.Mode().Perm()) | |
| 531 | + | } | |
| 532 | + | if info.Size() > MaxConfigBytes { | |
| 533 | + | return nil, fmt.Errorf("brokerconfig: %s exceeds %d bytes", path, MaxConfigBytes) | |
| 534 | + | } | |
| 535 | + | raw, err := io.ReadAll(io.LimitReader(file, MaxConfigBytes+1)) | |
| 536 | + | if err != nil { | |
| 537 | + | return nil, fmt.Errorf("brokerconfig: read %s: %w", path, err) | |
| 538 | + | } | |
| 539 | + | if len(raw) > MaxConfigBytes { | |
| 540 | + | return nil, fmt.Errorf("brokerconfig: %s exceeds %d bytes", path, MaxConfigBytes) | |
| 541 | + | } | |
| 542 | + | return raw, nil | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // decodeStrict rejects duplicate keys, unknown fields, trailing JSON, invalid | |
| 546 | + | // UTF-8, and excessive nesting, then decodes once. Byte bounds are applied | |
| 547 | + | // before decoding. | |
| 548 | + | func decodeStrict(raw []byte, dst any) error { | |
| 549 | + | if len(raw) > MaxConfigBytes || !utf8.Valid(raw) { | |
| 550 | + | return errors.New("invalid JSON size or encoding") | |
| 551 | + | } | |
| 552 | + | scan := json.NewDecoder(bytes.NewReader(raw)) | |
| 553 | + | if err := scanJSON(scan, 0); err != nil { | |
| 554 | + | return err | |
| 555 | + | } | |
| 556 | + | if _, err := scan.Token(); err != io.EOF { | |
| 557 | + | return errors.New("trailing JSON") | |
| 558 | + | } | |
| 559 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |
| 560 | + | decoder.DisallowUnknownFields() | |
| 561 | + | return decoder.Decode(dst) | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | // scanJSON walks one JSON value, rejecting duplicate keys and excessive depth. | |
| 565 | + | func scanJSON(decoder *json.Decoder, depth int) error { | |
| 566 | + | if depth > 64 { | |
| 567 | + | return errors.New("JSON nesting limit") | |
| 568 | + | } | |
| 569 | + | token, err := decoder.Token() | |
| 570 | + | if err != nil { | |
| 571 | + | return err | |
| 572 | + | } | |
| 573 | + | delimiter, ok := token.(json.Delim) | |
| 574 | + | if !ok { | |
| 575 | + | return nil | |
| 576 | + | } | |
| 577 | + | switch delimiter { | |
| 578 | + | case '{': | |
| 579 | + | seen := make(map[string]bool) | |
| 580 | + | for decoder.More() { | |
| 581 | + | key, err := decoder.Token() | |
| 582 | + | if err != nil { | |
| 583 | + | return err | |
| 584 | + | } | |
| 585 | + | name, ok := key.(string) | |
| 586 | + | if !ok || seen[name] { | |
| 587 | + | return errors.New("duplicate JSON key") | |
| 588 | + | } | |
| 589 | + | seen[name] = true | |
| 590 | + | if err := scanJSON(decoder, depth+1); err != nil { | |
| 591 | + | return err | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | _, err = decoder.Token() | |
| 595 | + | return err | |
| 596 | + | case '[': | |
| 597 | + | for decoder.More() { | |
| 598 | + | if err := scanJSON(decoder, depth+1); err != nil { | |
| 599 | + | return err | |
| 600 | + | } | |
| 601 | + | } | |
| 602 | + | _, err = decoder.Token() | |
| 603 | + | return err | |
| 604 | + | default: | |
| 605 | + | return errors.New("unexpected JSON delimiter") | |
| 606 | + | } | |
| 607 | + | } | |
A internal/adapters/brokerconfig/config_test.go +653 -0
| @@ -0,0 +1,653 @@ | |||
| 1 | + | package brokerconfig | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/internal/protocol" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | testIncarnation = "0102030405060708090a0b0c0d0e0f10" | |
| 21 | + | testEndpoint = "offline@daemon:2222" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // testPolicy is one fully valid, exact policy for the offline sandbox. | |
| 25 | + | func testPolicy() ports.BrokerPolicy { | |
| 26 | + | return ports.BrokerPolicy{ | |
| 27 | + | ProtocolVersion: protocol.Version, | |
| 28 | + | CatalogSchemaVersion: 3, | |
| 29 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 30 | + | Transport: "unix-mux", | |
| 31 | + | Trust: "offline-trust", | |
| 32 | + | Launch: "offline-launch", | |
| 33 | + | Isolation: "offline-isolation", | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // registrationDocument renders one registration with the supplied overrides. | |
| 38 | + | func registrationDocument(overrides map[string]any) map[string]any { | |
| 39 | + | entry := map[string]any{ | |
| 40 | + | "endpoint": testEndpoint, | |
| 41 | + | "incarnation": testIncarnation, | |
| 42 | + | "generation": 1, | |
| 43 | + | "identity": "offline-daemon", | |
| 44 | + | "route": "/tmp/vev-brokerconfig-test/mux.sock", | |
| 45 | + | "policy": map[string]any{ | |
| 46 | + | "protocolVersion": testPolicy().ProtocolVersion, | |
| 47 | + | "catalogSchemaVersion": testPolicy().CatalogSchemaVersion, | |
| 48 | + | "environmentPolicy": "client-owned", | |
| 49 | + | "transport": testPolicy().Transport, | |
| 50 | + | "trust": testPolicy().Trust, | |
| 51 | + | "launch": testPolicy().Launch, | |
| 52 | + | "isolation": testPolicy().Isolation, | |
| 53 | + | }, | |
| 54 | + | } | |
| 55 | + | for key, value := range overrides { | |
| 56 | + | entry[key] = value | |
| 57 | + | } | |
| 58 | + | return map[string]any{ | |
| 59 | + | "marker": Marker, | |
| 60 | + | "registrations": []any{entry}, | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // writeConfig marshals one document into root/config.json with owner-only | |
| 65 | + | // permissions. | |
| 66 | + | func writeConfig(t *testing.T, root string, document any, perm os.FileMode) { | |
| 67 | + | t.Helper() | |
| 68 | + | raw, err := json.Marshal(document) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | require.NoError(t, os.WriteFile(filepath.Join(root, ConfigFileName), raw, perm)) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestResolveLayout(t *testing.T) { | |
| 74 | + | base := t.TempDir() | |
| 75 | + | linkParent := filepath.Join(t.TempDir(), "link") | |
| 76 | + | require.NoError(t, os.Symlink(base, linkParent)) | |
| 77 | + | ||
| 78 | + | tests := []struct { | |
| 79 | + | name string | |
| 80 | + | root string | |
| 81 | + | reserved []string | |
| 82 | + | wantErr string | |
| 83 | + | }{ | |
| 84 | + | {name: "valid absolute clean root", root: filepath.Join(base, "sandbox")}, | |
| 85 | + | {name: "empty root", root: "", wantErr: "empty"}, | |
| 86 | + | {name: "relative root", root: "sandbox", wantErr: "not absolute"}, | |
| 87 | + | {name: "unclean root", root: base + "/sandbox/../sandbox", wantErr: "not clean"}, | |
| 88 | + | {name: "filesystem root", root: string(filepath.Separator), wantErr: "filesystem root"}, | |
| 89 | + | {name: "symlinked component", root: filepath.Join(linkParent, "sandbox"), wantErr: "symlink"}, | |
| 90 | + | {name: "reserved equals root", root: filepath.Join(base, "sandbox"), reserved: []string{filepath.Join(base, "sandbox")}, wantErr: "overlaps"}, | |
| 91 | + | {name: "reserved is parent", root: filepath.Join(base, "sandbox"), reserved: []string{base}, wantErr: "overlaps"}, | |
| 92 | + | {name: "reserved is child", root: filepath.Join(base, "sandbox"), reserved: []string{filepath.Join(base, "sandbox", "state")}, wantErr: "overlaps"}, | |
| 93 | + | {name: "reserved relative", root: filepath.Join(base, "sandbox"), reserved: []string{"relative"}, wantErr: "not absolute"}, | |
| 94 | + | {name: "unrelated reserved is allowed", root: filepath.Join(base, "sandbox"), reserved: []string{filepath.Join(base, "other")}}, | |
| 95 | + | } | |
| 96 | + | for _, tt := range tests { | |
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |
| 98 | + | layout, err := ResolveLayout(tt.root, tt.reserved) | |
| 99 | + | if tt.wantErr != "" { | |
| 100 | + | require.Error(t, err) | |
| 101 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Equal(t, layout.Root, tt.root) | |
| 106 | + | require.Equal(t, filepath.Join(tt.root, RuntimeDirName), layout.Runtime) | |
| 107 | + | require.Equal(t, filepath.Join(tt.root, StateDirName), layout.State) | |
| 108 | + | require.Equal(t, filepath.Join(tt.root, LogDirName), layout.Log) | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestLoadValidConfig(t *testing.T) { | |
| 114 | + | root := t.TempDir() | |
| 115 | + | writeConfig(t, root, registrationDocument(nil), 0o600) | |
| 116 | + | layout, err := ResolveLayout(root, nil) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | ||
| 119 | + | config, err := Load(layout) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | require.Equal(t, []string{testEndpoint}, config.Endpoints()) | |
| 122 | + | ||
| 123 | + | resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 124 | + | Purpose: ports.BrokerStreamControl, | |
| 125 | + | Endpoint: testEndpoint, | |
| 126 | + | Registration: testRegistration(), | |
| 127 | + | Policy: testPolicy(), | |
| 128 | + | }) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.Equal(t, ports.BrokerDaemonIdentity("offline-daemon"), resolved.Identity) | |
| 131 | + | require.Equal(t, testPolicy(), resolved.Policy) | |
| 132 | + | ||
| 133 | + | local, err := config.LocalMuxRoute() | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | require.Equal(t, RouteUnix, local.Kind()) | |
| 136 | + | require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", local.Path()) | |
| 137 | + | require.Equal(t, local.Address(), resolved.Address) | |
| 138 | + | require.LessOrEqual(t, len(resolved.Address), MaxRouteAddressBytes) | |
| 139 | + | route, ok := config.RouteByAddress(resolved.Address) | |
| 140 | + | require.True(t, ok) | |
| 141 | + | require.Equal(t, local.Path(), route.Path()) | |
| 142 | + | _, ok = config.RouteByAddress("offline-route-unknown") | |
| 143 | + | require.False(t, ok) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestLoadAcceptsEmptyRegistrations(t *testing.T) { | |
| 147 | + | root := t.TempDir() | |
| 148 | + | writeConfig(t, root, map[string]any{"marker": Marker, "registrations": []any{}}, 0o600) | |
| 149 | + | layout, err := ResolveLayout(root, nil) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | config, err := Load(layout) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | require.Empty(t, config.Endpoints()) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestLoadRejects(t *testing.T) { | |
| 157 | + | tests := []struct { | |
| 158 | + | name string | |
| 159 | + | build func(t *testing.T, root string) | |
| 160 | + | wantErr string | |
| 161 | + | }{ | |
| 162 | + | { | |
| 163 | + | name: "missing file", | |
| 164 | + | build: func(*testing.T, string) {}, | |
| 165 | + | wantErr: "open", | |
| 166 | + | }, | |
| 167 | + | { | |
| 168 | + | name: "wrong marker", | |
| 169 | + | build: func(t *testing.T, root string) { | |
| 170 | + | writeConfig(t, root, map[string]any{"marker": "other", "registrations": []any{}}, 0o600) | |
| 171 | + | }, | |
| 172 | + | wantErr: "marker", | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "unknown field", | |
| 176 | + | build: func(t *testing.T, root string) { | |
| 177 | + | document := registrationDocument(nil) | |
| 178 | + | document["extra"] = true | |
| 179 | + | writeConfig(t, root, document, 0o600) | |
| 180 | + | }, | |
| 181 | + | wantErr: "unknown field", | |
| 182 | + | }, | |
| 183 | + | { | |
| 184 | + | name: "duplicate key", | |
| 185 | + | build: func(t *testing.T, root string) { | |
| 186 | + | writeRawConfig(t, root, `{"marker":"`+Marker+`","marker":"`+Marker+`"}`) | |
| 187 | + | }, | |
| 188 | + | wantErr: "duplicate JSON key", | |
| 189 | + | }, | |
| 190 | + | { | |
| 191 | + | name: "trailing json", | |
| 192 | + | build: func(t *testing.T, root string) { | |
| 193 | + | writeRawConfig(t, root, `{"marker":"`+Marker+`","registrations":[]} {}`) | |
| 194 | + | }, | |
| 195 | + | wantErr: "trailing JSON", | |
| 196 | + | }, | |
| 197 | + | { | |
| 198 | + | name: "invalid utf8", | |
| 199 | + | build: func(t *testing.T, root string) { writeRawConfig(t, root, "{\"marker\":\"\xff\"}") }, | |
| 200 | + | wantErr: "encoding", | |
| 201 | + | }, | |
| 202 | + | { | |
| 203 | + | name: "group readable", | |
| 204 | + | build: func(t *testing.T, root string) { writeConfig(t, root, registrationDocument(nil), 0o640) }, | |
| 205 | + | wantErr: "owner-only", | |
| 206 | + | }, | |
| 207 | + | { | |
| 208 | + | name: "symlinked config", | |
| 209 | + | build: func(t *testing.T, root string) { | |
| 210 | + | target := filepath.Join(t.TempDir(), "real.json") | |
| 211 | + | raw, err := json.Marshal(registrationDocument(nil)) | |
| 212 | + | require.NoError(t, err) | |
| 213 | + | require.NoError(t, os.WriteFile(target, raw, 0o600)) | |
| 214 | + | require.NoError(t, os.Symlink(target, filepath.Join(root, ConfigFileName))) | |
| 215 | + | }, | |
| 216 | + | wantErr: "symlink", | |
| 217 | + | }, | |
| 218 | + | { | |
| 219 | + | name: "nonregular config", | |
| 220 | + | build: func(t *testing.T, root string) { | |
| 221 | + | require.NoError(t, os.Mkdir(filepath.Join(root, ConfigFileName), 0o700)) | |
| 222 | + | }, | |
| 223 | + | wantErr: "not a regular file", | |
| 224 | + | }, | |
| 225 | + | { | |
| 226 | + | name: "oversized config", | |
| 227 | + | build: func(t *testing.T, root string) { | |
| 228 | + | require.NoError(t, os.WriteFile(filepath.Join(root, ConfigFileName), make([]byte, MaxConfigBytes+1), 0o600)) | |
| 229 | + | }, | |
| 230 | + | wantErr: "exceeds", | |
| 231 | + | }, | |
| 232 | + | { | |
| 233 | + | name: "symlinked route", | |
| 234 | + | build: func(t *testing.T, root string) { | |
| 235 | + | link := filepath.Join(t.TempDir(), "route-link") | |
| 236 | + | require.NoError(t, os.Symlink(t.TempDir(), link)) | |
| 237 | + | writeConfig(t, root, registrationDocument(map[string]any{"route": filepath.Join(link, "mux.sock")}), 0o600) | |
| 238 | + | }, | |
| 239 | + | wantErr: "symlink", | |
| 240 | + | }, | |
| 241 | + | { | |
| 242 | + | name: "bad endpoint", | |
| 243 | + | build: func(t *testing.T, root string) { | |
| 244 | + | writeConfig(t, root, registrationDocument(map[string]any{"endpoint": "bad host"}), 0o600) | |
| 245 | + | }, | |
| 246 | + | wantErr: "whitespace", | |
| 247 | + | }, | |
| 248 | + | { | |
| 249 | + | name: "incarnation length", | |
| 250 | + | build: func(t *testing.T, root string) { | |
| 251 | + | writeConfig(t, root, registrationDocument(map[string]any{"incarnation": "abcd"}), 0o600) | |
| 252 | + | }, | |
| 253 | + | wantErr: "32 hexadecimal", | |
| 254 | + | }, | |
| 255 | + | { | |
| 256 | + | name: "incarnation zero", | |
| 257 | + | build: func(t *testing.T, root string) { | |
| 258 | + | writeConfig(t, root, registrationDocument(map[string]any{"incarnation": strings.Repeat("0", 32)}), 0o600) | |
| 259 | + | }, | |
| 260 | + | wantErr: "no incarnation", | |
| 261 | + | }, | |
| 262 | + | { | |
| 263 | + | name: "generation zero", | |
| 264 | + | build: func(t *testing.T, root string) { | |
| 265 | + | writeConfig(t, root, registrationDocument(map[string]any{"generation": 0}), 0o600) | |
| 266 | + | }, | |
| 267 | + | wantErr: "no generation", | |
| 268 | + | }, | |
| 269 | + | { | |
| 270 | + | name: "empty identity", | |
| 271 | + | build: func(t *testing.T, root string) { | |
| 272 | + | writeConfig(t, root, registrationDocument(map[string]any{"identity": ""}), 0o600) | |
| 273 | + | }, | |
| 274 | + | wantErr: "identity", | |
| 275 | + | }, | |
| 276 | + | { | |
| 277 | + | name: "bad environment policy", | |
| 278 | + | build: func(t *testing.T, root string) { | |
| 279 | + | writeConfig(t, root, documentWithPolicy(t, "environmentPolicy", "shared"), 0o600) | |
| 280 | + | }, | |
| 281 | + | wantErr: "environmentPolicy", | |
| 282 | + | }, | |
| 283 | + | { | |
| 284 | + | name: "zero protocol version", | |
| 285 | + | build: func(t *testing.T, root string) { | |
| 286 | + | writeConfig(t, root, documentWithPolicy(t, "protocolVersion", 0), 0o600) | |
| 287 | + | }, | |
| 288 | + | wantErr: "no protocol version", | |
| 289 | + | }, | |
| 290 | + | { | |
| 291 | + | name: "relative route", | |
| 292 | + | build: func(t *testing.T, root string) { | |
| 293 | + | writeConfig(t, root, registrationDocument(map[string]any{"route": "mux.sock"}), 0o600) | |
| 294 | + | }, | |
| 295 | + | wantErr: "not absolute", | |
| 296 | + | }, | |
| 297 | + | { | |
| 298 | + | name: "duplicate endpoint", | |
| 299 | + | build: func(t *testing.T, root string) { | |
| 300 | + | document := registrationDocument(nil) | |
| 301 | + | registrations := document["registrations"].([]any) | |
| 302 | + | document["registrations"] = append(registrations, registrations[0]) | |
| 303 | + | writeConfig(t, root, document, 0o600) | |
| 304 | + | }, | |
| 305 | + | wantErr: "duplicate endpoint", | |
| 306 | + | }, | |
| 307 | + | } | |
| 308 | + | for _, tt := range tests { | |
| 309 | + | t.Run(tt.name, func(t *testing.T) { | |
| 310 | + | root := t.TempDir() | |
| 311 | + | tt.build(t, root) | |
| 312 | + | layout, err := ResolveLayout(root, nil) | |
| 313 | + | require.NoError(t, err) | |
| 314 | + | _, err = Load(layout) | |
| 315 | + | require.Error(t, err) | |
| 316 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 317 | + | }) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestLoadRejectsRouteOverlappingProduction(t *testing.T) { | |
| 322 | + | prodRuntime := t.TempDir() | |
| 323 | + | prodState := t.TempDir() | |
| 324 | + | ||
| 325 | + | tests := []struct { | |
| 326 | + | name string | |
| 327 | + | route string | |
| 328 | + | wantErr bool | |
| 329 | + | }{ | |
| 330 | + | {name: "route under production runtime", route: filepath.Join(prodRuntime, "broker.sock"), wantErr: true}, | |
| 331 | + | {name: "route equals production state", route: prodState, wantErr: true}, | |
| 332 | + | {name: "route contains production runtime", route: filepath.Dir(prodRuntime), wantErr: true}, | |
| 333 | + | {name: "unrelated route", route: filepath.Join(t.TempDir(), "mux.sock")}, | |
| 334 | + | } | |
| 335 | + | for _, tt := range tests { | |
| 336 | + | t.Run(tt.name, func(t *testing.T) { | |
| 337 | + | root := t.TempDir() | |
| 338 | + | writeConfig(t, root, registrationDocument(map[string]any{"route": tt.route}), 0o600) | |
| 339 | + | layout, err := ResolveLayout(root, []string{prodRuntime, prodState}) | |
| 340 | + | require.NoError(t, err) | |
| 341 | + | _, err = Load(layout) | |
| 342 | + | if !tt.wantErr { | |
| 343 | + | require.NoError(t, err) | |
| 344 | + | return | |
| 345 | + | } | |
| 346 | + | require.Error(t, err) | |
| 347 | + | require.Contains(t, err.Error(), "overlaps production path") | |
| 348 | + | }) | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func TestLayoutVerifyCreatedDetectsSymlinkedComponent(t *testing.T) { | |
| 353 | + | root := filepath.Join(t.TempDir(), "sandbox") | |
| 354 | + | layout, err := ResolveLayout(root, nil) | |
| 355 | + | require.NoError(t, err) | |
| 356 | + | ||
| 357 | + | // Nothing is created yet, so the walk ends at the first missing component. | |
| 358 | + | require.NoError(t, layout.VerifyCreated()) | |
| 359 | + | ||
| 360 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 361 | + | require.NoError(t, os.Symlink(t.TempDir(), layout.State)) | |
| 362 | + | require.ErrorContains(t, layout.VerifyCreated(), "symlink") | |
| 363 | + | ||
| 364 | + | // Once the swap is removed, every created component is symlink-free again. | |
| 365 | + | require.NoError(t, os.Remove(layout.State)) | |
| 366 | + | require.NoError(t, layout.VerifyCreated()) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // registrationsDocument renders one document carrying the supplied registration | |
| 370 | + | // entries in order. | |
| 371 | + | func registrationsDocument(entries ...map[string]any) map[string]any { | |
| 372 | + | list := make([]any, 0, len(entries)) | |
| 373 | + | for _, entry := range entries { | |
| 374 | + | list = append(list, entry) | |
| 375 | + | } | |
| 376 | + | return map[string]any{"marker": Marker, "registrations": list} | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // registrationEntry renders one registration entry with the supplied overrides. | |
| 380 | + | func registrationEntry(overrides map[string]any) map[string]any { | |
| 381 | + | return registrationDocument(overrides)["registrations"].([]any)[0].(map[string]any) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // TestLoadEnforcesOneRoutePerPooledIdentity pins the pooling invariant: the | |
| 385 | + | // broker pool keys one physical transport by the exact authenticated identity | |
| 386 | + | // plus policy pair and deliberately ignores the opaque route address, so two | |
| 387 | + | // registrations that share the pair must resolve to the same route. An alias on | |
| 388 | + | // an identical route is accepted; a second route for the same pair is refused, | |
| 389 | + | // because the pool would otherwise dial one of the two addresses and silently | |
| 390 | + | // strand the other. | |
| 391 | + | func TestLoadEnforcesOneRoutePerPooledIdentity(t *testing.T) { | |
| 392 | + | const ( | |
| 393 | + | aliasOne = "alias@daemon:2222" | |
| 394 | + | aliasTwo = "alias@daemon:2223" | |
| 395 | + | otherID = "other@daemon:2224" | |
| 396 | + | routeOne = "/tmp/vev-brokerconfig-test/one.sock" | |
| 397 | + | routeTwo = "/tmp/vev-brokerconfig-test/two.sock" | |
| 398 | + | ) | |
| 399 | + | withPolicy := func(entry map[string]any, key string, value any) map[string]any { | |
| 400 | + | entry["policy"].(map[string]any)[key] = value | |
| 401 | + | return entry | |
| 402 | + | } | |
| 403 | + | tests := []struct { | |
| 404 | + | name string | |
| 405 | + | entries []map[string]any | |
| 406 | + | wantErr string | |
| 407 | + | wantRoutes []string | |
| 408 | + | }{ | |
| 409 | + | { | |
| 410 | + | name: "same identity and policy on a different route is refused", | |
| 411 | + | entries: []map[string]any{ | |
| 412 | + | registrationEntry(map[string]any{"endpoint": aliasOne, "route": routeOne}), | |
| 413 | + | registrationEntry(map[string]any{"endpoint": aliasTwo, "route": routeTwo}), | |
| 414 | + | }, | |
| 415 | + | wantErr: "different route", | |
| 416 | + | }, | |
| 417 | + | { | |
| 418 | + | name: "same identity and policy on the identical route is an alias", | |
| 419 | + | entries: []map[string]any{ | |
| 420 | + | registrationEntry(map[string]any{"endpoint": aliasOne, "route": routeOne}), | |
| 421 | + | registrationEntry(map[string]any{"endpoint": aliasTwo, "route": routeOne}), | |
| 422 | + | }, | |
| 423 | + | wantRoutes: []string{aliasOne, aliasTwo}, | |
| 424 | + | }, | |
| 425 | + | { | |
| 426 | + | name: "distinct identities on different routes are independent", | |
| 427 | + | entries: []map[string]any{ | |
| 428 | + | registrationEntry(map[string]any{"endpoint": aliasOne, "route": routeOne}), | |
| 429 | + | registrationEntry(map[string]any{"endpoint": otherID, "route": routeTwo, "identity": "other-daemon"}), | |
| 430 | + | }, | |
| 431 | + | wantRoutes: []string{aliasOne, otherID}, | |
| 432 | + | }, | |
| 433 | + | { | |
| 434 | + | name: "same identity with a distinct policy on a different route is independent", | |
| 435 | + | entries: []map[string]any{ | |
| 436 | + | registrationEntry(map[string]any{"endpoint": aliasOne, "route": routeOne}), | |
| 437 | + | withPolicy(registrationEntry(map[string]any{"endpoint": aliasTwo, "route": routeTwo}), "transport", "other-transport"), | |
| 438 | + | }, | |
| 439 | + | wantRoutes: []string{aliasOne, aliasTwo}, | |
| 440 | + | }, | |
| 441 | + | } | |
| 442 | + | for _, tt := range tests { | |
| 443 | + | t.Run(tt.name, func(t *testing.T) { | |
| 444 | + | root := t.TempDir() | |
| 445 | + | writeConfig(t, root, registrationsDocument(tt.entries...), 0o600) | |
| 446 | + | layout, err := ResolveLayout(root, nil) | |
| 447 | + | require.NoError(t, err) | |
| 448 | + | config, err := Load(layout) | |
| 449 | + | if tt.wantErr != "" { | |
| 450 | + | require.Error(t, err) | |
| 451 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 452 | + | return | |
| 453 | + | } | |
| 454 | + | require.NoError(t, err) | |
| 455 | + | require.Equal(t, tt.wantRoutes, config.Endpoints()) | |
| 456 | + | }) | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // TestLoadAliasesShareOneRoute proves an accepted alias resolves both endpoints | |
| 461 | + | // to the identical opaque address and that the shared local route is still | |
| 462 | + | // reported exactly once, so a remote-side helper is never handed a duplicate of | |
| 463 | + | // the same carriage. | |
| 464 | + | func TestLoadAliasesShareOneRoute(t *testing.T) { | |
| 465 | + | const ( | |
| 466 | + | first = "alias@daemon:2222" | |
| 467 | + | second = "alias@daemon:2223" | |
| 468 | + | route = "/tmp/vev-brokerconfig-test/shared.sock" | |
| 469 | + | ) | |
| 470 | + | root := t.TempDir() | |
| 471 | + | writeConfig(t, root, registrationsDocument( | |
| 472 | + | registrationEntry(map[string]any{"endpoint": first, "route": route}), | |
| 473 | + | registrationEntry(map[string]any{"endpoint": second, "route": route}), | |
| 474 | + | ), 0o600) | |
| 475 | + | layout, err := ResolveLayout(root, nil) | |
| 476 | + | require.NoError(t, err) | |
| 477 | + | config, err := Load(layout) | |
| 478 | + | require.NoError(t, err) | |
| 479 | + | ||
| 480 | + | local, err := config.LocalMuxRoute() | |
| 481 | + | require.NoError(t, err) | |
| 482 | + | require.Equal(t, route, local.Path()) | |
| 483 | + | ||
| 484 | + | for _, endpoint := range []string{first, second} { | |
| 485 | + | registration := testRegistration() | |
| 486 | + | registration.Endpoint = endpoint | |
| 487 | + | resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 488 | + | Purpose: ports.BrokerStreamControl, | |
| 489 | + | Endpoint: endpoint, | |
| 490 | + | Registration: registration, | |
| 491 | + | Policy: testPolicy(), | |
| 492 | + | }) | |
| 493 | + | require.NoError(t, err) | |
| 494 | + | require.Equal(t, local.Address(), resolved.Address) | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // documentWithPolicy renders a valid document with one policy field overridden. | |
| 499 | + | func documentWithPolicy(t *testing.T, key string, value any) map[string]any { | |
| 500 | + | t.Helper() | |
| 501 | + | document := registrationDocument(nil) | |
| 502 | + | entry := document["registrations"].([]any)[0].(map[string]any) | |
| 503 | + | entry["policy"].(map[string]any)[key] = value | |
| 504 | + | return document | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | // writeRawConfig writes one raw config file with owner-only permissions. | |
| 508 | + | func writeRawConfig(t *testing.T, root, content string) { | |
| 509 | + | t.Helper() | |
| 510 | + | require.NoError(t, os.WriteFile(filepath.Join(root, ConfigFileName), []byte(content), 0o600)) | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // testRegistration is the exact registration the fixture document provisions. | |
| 514 | + | func testRegistration() domain.RemoteRegistration { | |
| 515 | + | var registration domain.RemoteRegistration | |
| 516 | + | registration.Endpoint = testEndpoint | |
| 517 | + | for i := range registration.Incarnation { | |
| 518 | + | registration.Incarnation[i] = byte(i + 1) | |
| 519 | + | } | |
| 520 | + | registration.Generation = 1 | |
| 521 | + | return registration | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func TestResolverRefusals(t *testing.T) { | |
| 525 | + | root := t.TempDir() | |
| 526 | + | writeConfig(t, root, registrationDocument(nil), 0o600) | |
| 527 | + | layout, err := ResolveLayout(root, nil) | |
| 528 | + | require.NoError(t, err) | |
| 529 | + | config, err := Load(layout) | |
| 530 | + | require.NoError(t, err) | |
| 531 | + | resolver := config.Resolver() | |
| 532 | + | ||
| 533 | + | base := ports.BrokerOpenStreamRequest{ | |
| 534 | + | Purpose: ports.BrokerStreamControl, | |
| 535 | + | Endpoint: testEndpoint, | |
| 536 | + | Registration: testRegistration(), | |
| 537 | + | Policy: testPolicy(), | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | t.Run("unknown endpoint", func(t *testing.T) { | |
| 541 | + | request := base | |
| 542 | + | request.Endpoint = "other@daemon" | |
| 543 | + | _, err := resolver.Resolve(context.Background(), request) | |
| 544 | + | var typed ports.BrokerError | |
| 545 | + | require.ErrorAs(t, err, &typed) | |
| 546 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 547 | + | require.ErrorIs(t, err, errUnknownEndpoint) | |
| 548 | + | }) | |
| 549 | + | t.Run("local request", func(t *testing.T) { | |
| 550 | + | request := base | |
| 551 | + | request.Local = true | |
| 552 | + | _, err := resolver.Resolve(context.Background(), request) | |
| 553 | + | var typed ports.BrokerError | |
| 554 | + | require.ErrorAs(t, err, &typed) | |
| 555 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 556 | + | require.ErrorIs(t, err, errNoLocalRoute) | |
| 557 | + | }) | |
| 558 | + | t.Run("stale registration", func(t *testing.T) { | |
| 559 | + | request := base | |
| 560 | + | request.Registration.Generation = 2 | |
| 561 | + | _, err := resolver.Resolve(context.Background(), request) | |
| 562 | + | var typed ports.BrokerError | |
| 563 | + | require.ErrorAs(t, err, &typed) | |
| 564 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 565 | + | require.ErrorIs(t, err, errStaleRegistration) | |
| 566 | + | }) | |
| 567 | + | t.Run("conflicting policy", func(t *testing.T) { | |
| 568 | + | request := base | |
| 569 | + | request.Policy.Transport = "other-transport" | |
| 570 | + | _, err := resolver.Resolve(context.Background(), request) | |
| 571 | + | var typed ports.BrokerError | |
| 572 | + | require.ErrorAs(t, err, &typed) | |
| 573 | + | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 574 | + | }) | |
| 575 | + | t.Run("cancelled context", func(t *testing.T) { | |
| 576 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | + | cancel() | |
| 578 | + | _, err := resolver.Resolve(ctx, base) | |
| 579 | + | require.ErrorIs(t, err, context.Canceled) | |
| 580 | + | }) | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | func TestDecodeStrictRejectsNesting(t *testing.T) { | |
| 584 | + | root := t.TempDir() | |
| 585 | + | nested := strings.Repeat("[", 130) + strings.Repeat("]", 130) | |
| 586 | + | writeRawConfig(t, root, `{"marker":"`+Marker+`","registrations":`+nested+`}`) | |
| 587 | + | layout, err := ResolveLayout(root, nil) | |
| 588 | + | require.NoError(t, err) | |
| 589 | + | _, err = Load(layout) | |
| 590 | + | require.Error(t, err) | |
| 591 | + | require.Contains(t, err.Error(), "nesting") | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func TestResolveLayoutDoesNotCreatePaths(t *testing.T) { | |
| 595 | + | root := filepath.Join(t.TempDir(), "does-not-exist") | |
| 596 | + | layout, err := ResolveLayout(root, nil) | |
| 597 | + | require.NoError(t, err) | |
| 598 | + | require.Equal(t, filepath.Join(root, StateDirName), layout.State) | |
| 599 | + | require.Equal(t, filepath.Join(root, RuntimeDirName, SpawnDirName), layout.Spawn) | |
| 600 | + | _, statErr := os.Lstat(root) | |
| 601 | + | require.ErrorIs(t, statErr, os.ErrNotExist) | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | func TestLoadIdleGrace(t *testing.T) { | |
| 605 | + | tests := []struct { | |
| 606 | + | name string | |
| 607 | + | document map[string]any | |
| 608 | + | want time.Duration | |
| 609 | + | wantSet bool | |
| 610 | + | wantErr string | |
| 611 | + | }{ | |
| 612 | + | { | |
| 613 | + | name: "provisioned grace is parsed", | |
| 614 | + | document: map[string]any{"marker": Marker, "registrations": []any{}, "idleGrace": "90s"}, | |
| 615 | + | want: 90 * time.Second, | |
| 616 | + | wantSet: true, | |
| 617 | + | }, | |
| 618 | + | {name: "absent grace provisions nothing", document: map[string]any{"marker": Marker, "registrations": []any{}}}, | |
| 619 | + | { | |
| 620 | + | name: "invalid duration is refused", | |
| 621 | + | document: map[string]any{"marker": Marker, "registrations": []any{}, "idleGrace": "soon"}, | |
| 622 | + | wantErr: "not a duration", | |
| 623 | + | }, | |
| 624 | + | { | |
| 625 | + | name: "non-positive grace is refused", | |
| 626 | + | document: map[string]any{"marker": Marker, "registrations": []any{}, "idleGrace": "0s"}, | |
| 627 | + | wantErr: "must be positive", | |
| 628 | + | }, | |
| 629 | + | { | |
| 630 | + | name: "oversized grace is refused", | |
| 631 | + | document: map[string]any{"marker": Marker, "registrations": []any{}, "idleGrace": (MaxIdleGrace + time.Second).String()}, | |
| 632 | + | wantErr: "must be positive and at most", | |
| 633 | + | }, | |
| 634 | + | } | |
| 635 | + | for _, tt := range tests { | |
| 636 | + | t.Run(tt.name, func(t *testing.T) { | |
| 637 | + | root := t.TempDir() | |
| 638 | + | writeConfig(t, root, tt.document, 0o600) | |
| 639 | + | layout, err := ResolveLayout(root, nil) | |
| 640 | + | require.NoError(t, err) | |
| 641 | + | config, err := Load(layout) | |
| 642 | + | if tt.wantErr != "" { | |
| 643 | + | require.Error(t, err) | |
| 644 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 645 | + | return | |
| 646 | + | } | |
| 647 | + | require.NoError(t, err) | |
| 648 | + | grace, set := config.IdleGrace() | |
| 649 | + | require.Equal(t, tt.wantSet, set) | |
| 650 | + | require.Equal(t, tt.want, grace) | |
| 651 | + | }) | |
| 652 | + | } | |
| 653 | + | } | |
A internal/adapters/brokerconfig/resolver.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package brokerconfig | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Resolver is the immutable ports.BrokerEndpointResolver over one parsed | |
| 11 | + | // offline configuration. It holds no mutable state and performs no I/O: every | |
| 12 | + | // Resolve call fences the request against the provisioned registration and | |
| 13 | + | // returns the configured identity, policy, and opaque route address. A request | |
| 14 | + | // can therefore never select an address, identity, or policy of its own. | |
| 15 | + | type Resolver struct { | |
| 16 | + | byEndpoint map[string]Registration | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | var _ ports.BrokerEndpointResolver = (*Resolver)(nil) | |
| 20 | + | ||
| 21 | + | // Resolve fences one open-stream request against the immutable configuration. | |
| 22 | + | // | |
| 23 | + | // The request must name a configured endpoint, carry that endpoint's exact | |
| 24 | + | // registration (endpoint, incarnation, and generation), and request a policy | |
| 25 | + | // compatible with the provisioned policy. A local request is refused because | |
| 26 | + | // the offline sandbox provisions remote routes only. Unknown, stale, | |
| 27 | + | // or conflicting requests are refused with a typed broker error and no dial is | |
| 28 | + | // attempted; a cancelled context is reported as such so the pool classifies it | |
| 29 | + | // as cancellation rather than unavailability. | |
| 30 | + | func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 31 | + | if err := ctx.Err(); err != nil { | |
| 32 | + | return ports.BrokerResolvedEndpoint{}, err | |
| 33 | + | } | |
| 34 | + | if request.Local { | |
| 35 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute} | |
| 36 | + | } | |
| 37 | + | provisioned, ok := r.byEndpoint[request.Endpoint] | |
| 38 | + | if !ok { | |
| 39 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errUnknownEndpoint} | |
| 40 | + | } | |
| 41 | + | if !request.Registration.Equal(provisioned.Registration) { | |
| 42 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errStaleRegistration} | |
| 43 | + | } | |
| 44 | + | if !provisioned.Policy.Compatible(request.Policy) { | |
| 45 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("brokerconfig: request policy conflicts with the provisioned policy")} | |
| 46 | + | } | |
| 47 | + | resolved := ports.BrokerResolvedEndpoint{Identity: provisioned.Identity, Policy: provisioned.Policy, Address: provisioned.Route.Address()} | |
| 48 | + | if err := resolved.Validate(); err != nil { | |
| 49 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 50 | + | } | |
| 51 | + | return resolved, nil | |
| 52 | + | } | |
A internal/adapters/brokerconfig/route.go +364 -0
| @@ -0,0 +1,364 @@ | |||
| 1 | + | package brokerconfig | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/hex" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | "unicode" | |
| 15 | + | "unicode/utf8" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Bounded route variants (Plan 001 P3.4 slice E). | |
| 21 | + | // | |
| 22 | + | // A registration's route is the transport the offline broker dials for that | |
| 23 | + | // endpoint. It is an explicit, bounded tagged union, never a caller-supplied | |
| 24 | + | // address: the immutable configuration alone decides whether the broker reaches | |
| 25 | + | // the daemon-mux carriage over a local Unix socket, an SSH stdio child, or an | |
| 26 | + | // SSH-authenticated QUIC bootstrap. The route carries no identity, policy, or | |
| 27 | + | // secret; identity and policy stay authoritative on the registration, and the | |
| 28 | + | // one-time QUIC credentials are minted fresh per physical connection and never | |
| 29 | + | // enter an address, a log, or the configuration. | |
| 30 | + | // | |
| 31 | + | // The route's opaque pool address is a digest of the route's own bounded | |
| 32 | + | // contents, so two distinct routes can never collide and no route detail (an | |
| 33 | + | // SSH target, a path, or an argv word) is exposed through the address the pool | |
| 34 | + | // carries. | |
| 35 | + | type RouteKind string | |
| 36 | + | ||
| 37 | + | const ( | |
| 38 | + | // RouteUnix dials one absolute private daemon-mux Unix socket directly. | |
| 39 | + | RouteUnix RouteKind = "unix" | |
| 40 | + | // RouteSSHStdio starts one explicit remote mux helper over a local ssh | |
| 41 | + | // child and carries the daemonmux conversation over its stdio. | |
| 42 | + | RouteSSHStdio RouteKind = "ssh-stdio" | |
| 43 | + | // RouteSSHQUIC runs one SSH-authenticated QUIC bootstrap on the remote | |
| 44 | + | // host and pins the freshly minted ephemeral certificate before dialing. | |
| 45 | + | RouteSSHQUIC RouteKind = "ssh-quic" | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | // Bounded route limits. They are explicit so a hostile or accidental | |
| 49 | + | // configuration can never grow a route, an argv, or a trust input without | |
| 50 | + | // limit, and so two distinct routes are always distinguishable inside the | |
| 51 | + | // address digest. | |
| 52 | + | const ( | |
| 53 | + | // MaxRouteTargetBytes bounds one ssh target. | |
| 54 | + | MaxRouteTargetBytes = 256 | |
| 55 | + | // MaxRouteArgvWords bounds the remote argv word count. | |
| 56 | + | MaxRouteArgvWords = 32 | |
| 57 | + | // MaxRouteArgvBytes bounds the total serialized remote argv. | |
| 58 | + | MaxRouteArgvBytes = 4096 | |
| 59 | + | // MaxRouteWordBytes bounds one remote argv word. | |
| 60 | + | MaxRouteWordBytes = 1024 | |
| 61 | + | // MaxTrustPathBytes bounds one trust-input path. | |
| 62 | + | MaxTrustPathBytes = 1024 | |
| 63 | + | // MaxConnectTimeout bounds a provisioned ssh connect timeout. | |
| 64 | + | MaxConnectTimeout = 5 * time.Minute | |
| 65 | + | ||
| 66 | + | // MaxRouteAddressBytes bounds the opaque pool address a route exposes. | |
| 67 | + | MaxRouteAddressBytes = 64 | |
| 68 | + | ) | |
| 69 | + | ||
| 70 | + | // TrustInputs carries the explicit OpenSSH inputs a provisioned ssh route may | |
| 71 | + | // forward. They can only narrow or redirect verification; host-key checking is | |
| 72 | + | // never disabled, authentication is never bypassed, and no PTY is ever | |
| 73 | + | // requested. Every field is optional. | |
| 74 | + | type TrustInputs struct { | |
| 75 | + | // knownHostsFile, when set, names the absolute known-hosts file ssh verifies | |
| 76 | + | // against. Verification still happens: an unknown or mismatched key fails. | |
| 77 | + | knownHostsFile string | |
| 78 | + | // connectTimeout, when positive, bounds ssh's own connection attempt. | |
| 79 | + | connectTimeout time.Duration | |
| 80 | + | // set records whether the trust object was present, so an empty object and | |
| 81 | + | // an absent object are distinguishable in the route digest. | |
| 82 | + | set bool | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Route is one immutable, validated daemonmux route. | |
| 86 | + | type Route struct { | |
| 87 | + | kind RouteKind | |
| 88 | + | path string | |
| 89 | + | target string | |
| 90 | + | argv []string | |
| 91 | + | trust TrustInputs | |
| 92 | + | // address is the bounded opaque token the pool carries for this route. It | |
| 93 | + | // is a digest of the route contents, so it leaks no route detail and can | |
| 94 | + | // never collide with a distinct route. | |
| 95 | + | address string | |
| 96 | + | // local is true for a Unix route that this process can dial directly, which | |
| 97 | + | // is the route a remote-side mux helper bridges to. | |
| 98 | + | local bool | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Kind returns the route variant. The zero Route reports an empty kind. | |
| 102 | + | func (r Route) Kind() RouteKind { return r.kind } | |
| 103 | + | ||
| 104 | + | // Path returns the absolute private Unix mux socket for a Unix route. | |
| 105 | + | func (r Route) Path() string { return r.path } | |
| 106 | + | ||
| 107 | + | // Target returns the ssh target for an ssh route. | |
| 108 | + | func (r Route) Target() string { return r.target } | |
| 109 | + | ||
| 110 | + | // Argv returns a copy of the explicit remote argv for an ssh route. | |
| 111 | + | func (r Route) Argv() []string { return append([]string(nil), r.argv...) } | |
| 112 | + | ||
| 113 | + | // KnownHostsFile returns the provisioned known-hosts path, if any. | |
| 114 | + | func (r Route) KnownHostsFile() string { return r.trust.knownHostsFile } | |
| 115 | + | ||
| 116 | + | // ConnectTimeout returns the provisioned ssh connect timeout, if any. | |
| 117 | + | func (r Route) ConnectTimeout() time.Duration { return r.trust.connectTimeout } | |
| 118 | + | ||
| 119 | + | // Address returns the bounded opaque pool address for this route. It never | |
| 120 | + | // contains a secret, an argv word, or a trust path. | |
| 121 | + | func (r Route) Address() string { return r.address } | |
| 122 | + | ||
| 123 | + | // IsLocal reports whether the route dials a Unix socket this process can reach | |
| 124 | + | // directly, which is the route a remote-side mux helper bridges to. | |
| 125 | + | func (r Route) IsLocal() bool { return r.local } | |
| 126 | + | ||
| 127 | + | // Validate re-checks one already-parsed route. Parsed routes are validated | |
| 128 | + | // once, so this is the seam a caller uses to fence a zero or hand-built value. | |
| 129 | + | func (r Route) Validate() error { | |
| 130 | + | switch r.kind { | |
| 131 | + | case RouteUnix: | |
| 132 | + | if err := validateRoute(r.path); err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | return nil | |
| 136 | + | case RouteSSHStdio, RouteSSHQUIC: | |
| 137 | + | return r.validateSSH() | |
| 138 | + | default: | |
| 139 | + | return fmt.Errorf("route kind %q is invalid", r.kind) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (r Route) validateSSH() error { | |
| 144 | + | if err := validateSSHTarget(r.target); err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | if err := validateArgv(r.argv); err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | if r.trust.knownHostsFile != "" { | |
| 151 | + | if err := validateTrustPath(r.trust.knownHostsFile); err != nil { | |
| 152 | + | return err | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | if r.trust.connectTimeout < 0 || r.trust.connectTimeout > MaxConnectTimeout { | |
| 156 | + | return fmt.Errorf("trust connectTimeout must be between 0 and %s", MaxConnectTimeout) | |
| 157 | + | } | |
| 158 | + | return nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // validateSSHTarget bounds and sanitizes one ssh target. It reuses the domain | |
| 162 | + | // host-target rule (no whitespace or control characters) and additionally | |
| 163 | + | // bounds the length and refuses a leading dash, so a target can never be | |
| 164 | + | // mistaken for an ssh option even before the option terminator. | |
| 165 | + | func validateSSHTarget(target string) error { | |
| 166 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | if len(target) > MaxRouteTargetBytes { | |
| 170 | + | return fmt.Errorf("ssh target exceeds %d bytes", MaxRouteTargetBytes) | |
| 171 | + | } | |
| 172 | + | if strings.HasPrefix(target, "-") { | |
| 173 | + | return errors.New("ssh target must not start with a dash") | |
| 174 | + | } | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // validateArgv bounds the explicit remote argv. Every word must be non-empty, | |
| 179 | + | // valid UTF-8, and free of NUL and control characters; the executable is the | |
| 180 | + | // first word. The remote argv is passed to ssh as one POSIX-quoted string, so | |
| 181 | + | // spaces inside a word are safe and are not treated as separators. | |
| 182 | + | func validateArgv(argv []string) error { | |
| 183 | + | if len(argv) == 0 { | |
| 184 | + | return errors.New("ssh route requires a non-empty argv") | |
| 185 | + | } | |
| 186 | + | if len(argv) > MaxRouteArgvWords { | |
| 187 | + | return fmt.Errorf("ssh route argv exceeds %d words", MaxRouteArgvWords) | |
| 188 | + | } | |
| 189 | + | total := 0 | |
| 190 | + | for i, word := range argv { | |
| 191 | + | if word == "" { | |
| 192 | + | return fmt.Errorf("ssh route argv[%d] is empty", i) | |
| 193 | + | } | |
| 194 | + | if len(word) > MaxRouteWordBytes { | |
| 195 | + | return fmt.Errorf("ssh route argv[%d] exceeds %d bytes", i, MaxRouteWordBytes) | |
| 196 | + | } | |
| 197 | + | if !utf8.ValidString(word) { | |
| 198 | + | return fmt.Errorf("ssh route argv[%d] is not valid UTF-8", i) | |
| 199 | + | } | |
| 200 | + | for _, r := range word { | |
| 201 | + | if r == 0 || unicode.IsControl(r) { | |
| 202 | + | return fmt.Errorf("ssh route argv[%d] contains a control character", i) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | total += len(word) | |
| 206 | + | } | |
| 207 | + | if total > MaxRouteArgvBytes { | |
| 208 | + | return fmt.Errorf("ssh route argv exceeds %d bytes", MaxRouteArgvBytes) | |
| 209 | + | } | |
| 210 | + | return nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // validateTrustPath bounds one trust-input path. It must be absolute and clean | |
| 214 | + | // and free of control characters, so it can never be reshaped by a later step | |
| 215 | + | // or inject an ssh option. | |
| 216 | + | func validateTrustPath(path string) error { | |
| 217 | + | if len(path) > MaxTrustPathBytes { | |
| 218 | + | return fmt.Errorf("trust path exceeds %d bytes", MaxTrustPathBytes) | |
| 219 | + | } | |
| 220 | + | if !utf8.ValidString(path) { | |
| 221 | + | return errors.New("trust path is not valid UTF-8") | |
| 222 | + | } | |
| 223 | + | if !filepath.IsAbs(path) { | |
| 224 | + | return fmt.Errorf("trust path %q is not absolute", path) | |
| 225 | + | } | |
| 226 | + | if filepath.Clean(path) != path { | |
| 227 | + | return fmt.Errorf("trust path %q is not clean", path) | |
| 228 | + | } | |
| 229 | + | for _, r := range path { | |
| 230 | + | if r == 0 || unicode.IsControl(r) || unicode.IsSpace(r) { | |
| 231 | + | return fmt.Errorf("trust path %q contains whitespace or control characters", path) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // routeDocument is the strict on-disk shape of a route object. Unknown fields | |
| 238 | + | // are refused by the decoder, so a route cannot smuggle additional behavior. | |
| 239 | + | type routeDocument struct { | |
| 240 | + | Kind string `json:"kind"` | |
| 241 | + | Path string `json:"path,omitempty"` | |
| 242 | + | Target string `json:"target,omitempty"` | |
| 243 | + | Argv []string `json:"argv,omitempty"` | |
| 244 | + | Trust *trustDocument `json:"trust,omitempty"` | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | type trustDocument struct { | |
| 248 | + | KnownHostsFile string `json:"knownHostsFile,omitempty"` | |
| 249 | + | ConnectTimeout string `json:"connectTimeout,omitempty"` | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // parseRoute strictly parses one route. A JSON string is the original Unix | |
| 253 | + | // route form (an absolute private mux path); an object carries an explicit | |
| 254 | + | // kind. Unknown kinds, unknown fields, trailing JSON, and a kind/field mismatch | |
| 255 | + | // are refused. | |
| 256 | + | func parseRoute(raw json.RawMessage) (Route, error) { | |
| 257 | + | trimmed := bytes.TrimSpace(raw) | |
| 258 | + | if len(trimmed) == 0 { | |
| 259 | + | return Route{}, errors.New("route is empty") | |
| 260 | + | } | |
| 261 | + | if trimmed[0] == '"' { | |
| 262 | + | var path string | |
| 263 | + | if err := json.Unmarshal(trimmed, &path); err != nil { | |
| 264 | + | return Route{}, fmt.Errorf("route: %w", err) | |
| 265 | + | } | |
| 266 | + | return newUnixRoute(path) | |
| 267 | + | } | |
| 268 | + | if trimmed[0] != '{' { | |
| 269 | + | return Route{}, errors.New("route must be a path string or an object") | |
| 270 | + | } | |
| 271 | + | var document routeDocument | |
| 272 | + | decoder := json.NewDecoder(bytes.NewReader(trimmed)) | |
| 273 | + | decoder.DisallowUnknownFields() | |
| 274 | + | if err := decoder.Decode(&document); err != nil { | |
| 275 | + | return Route{}, fmt.Errorf("route: %w", err) | |
| 276 | + | } | |
| 277 | + | if _, err := decoder.Token(); err != io.EOF { | |
| 278 | + | return Route{}, errors.New("route: trailing JSON") | |
| 279 | + | } | |
| 280 | + | switch RouteKind(document.Kind) { | |
| 281 | + | case RouteUnix: | |
| 282 | + | if document.Target != "" || len(document.Argv) != 0 || document.Trust != nil { | |
| 283 | + | return Route{}, errors.New("unix route must not carry ssh fields") | |
| 284 | + | } | |
| 285 | + | return newUnixRoute(document.Path) | |
| 286 | + | case RouteSSHStdio, RouteSSHQUIC: | |
| 287 | + | trust, err := parseTrust(document.Trust) | |
| 288 | + | if err != nil { | |
| 289 | + | return Route{}, err | |
| 290 | + | } | |
| 291 | + | return newSSHRoute(RouteKind(document.Kind), document.Target, document.Argv, trust) | |
| 292 | + | case "": | |
| 293 | + | return Route{}, errors.New("route object requires a kind") | |
| 294 | + | default: | |
| 295 | + | return Route{}, fmt.Errorf("route kind %q is invalid", document.Kind) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func parseTrust(document *trustDocument) (TrustInputs, error) { | |
| 300 | + | if document == nil { | |
| 301 | + | return TrustInputs{}, nil | |
| 302 | + | } | |
| 303 | + | trust := TrustInputs{knownHostsFile: document.KnownHostsFile, set: true} | |
| 304 | + | if document.ConnectTimeout != "" { | |
| 305 | + | timeout, err := time.ParseDuration(document.ConnectTimeout) | |
| 306 | + | if err != nil { | |
| 307 | + | return TrustInputs{}, fmt.Errorf("trust connectTimeout %q is not a duration", document.ConnectTimeout) | |
| 308 | + | } | |
| 309 | + | if timeout <= 0 { | |
| 310 | + | return TrustInputs{}, errors.New("trust connectTimeout must be positive") | |
| 311 | + | } | |
| 312 | + | trust.connectTimeout = timeout | |
| 313 | + | } | |
| 314 | + | return trust, nil | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func newUnixRoute(path string) (Route, error) { | |
| 318 | + | route := Route{kind: RouteUnix, path: path, local: true} | |
| 319 | + | if err := route.Validate(); err != nil { | |
| 320 | + | return Route{}, err | |
| 321 | + | } | |
| 322 | + | route.address = route.digest() | |
| 323 | + | return route, nil | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func newSSHRoute(kind RouteKind, target string, argv []string, trust TrustInputs) (Route, error) { | |
| 327 | + | route := Route{ | |
| 328 | + | kind: kind, | |
| 329 | + | target: target, | |
| 330 | + | argv: append([]string(nil), argv...), | |
| 331 | + | trust: trust, | |
| 332 | + | } | |
| 333 | + | if err := route.Validate(); err != nil { | |
| 334 | + | return Route{}, err | |
| 335 | + | } | |
| 336 | + | route.address = route.digest() | |
| 337 | + | return route, nil | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // digest computes the bounded opaque pool address. The canonical encoding | |
| 341 | + | // separates every field with a NUL and carries the kind first, so distinct | |
| 342 | + | // routes always produce distinct digests. | |
| 343 | + | func (r Route) digest() string { | |
| 344 | + | var builder strings.Builder | |
| 345 | + | builder.WriteString(string(r.kind)) | |
| 346 | + | builder.WriteByte(0) | |
| 347 | + | builder.WriteString(r.path) | |
| 348 | + | builder.WriteByte(0) | |
| 349 | + | builder.WriteString(r.target) | |
| 350 | + | builder.WriteByte(0) | |
| 351 | + | for _, word := range r.argv { | |
| 352 | + | builder.WriteString(word) | |
| 353 | + | builder.WriteByte(0) | |
| 354 | + | } | |
| 355 | + | builder.WriteByte(0) | |
| 356 | + | builder.WriteString(r.trust.knownHostsFile) | |
| 357 | + | builder.WriteByte(0) | |
| 358 | + | fmt.Fprintf(&builder, "%d", r.trust.connectTimeout) | |
| 359 | + | if r.trust.set { | |
| 360 | + | builder.WriteByte(1) | |
| 361 | + | } | |
| 362 | + | sum := sha256.Sum256([]byte(builder.String())) | |
| 363 | + | return "offline-route-" + hex.EncodeToString(sum[:16]) | |
| 364 | + | } | |
A internal/adapters/brokerconfig/route_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | package brokerconfig | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // TestParseRouteVariants pins the explicit bounded route union: a bare string | |
| 13 | + | // is the original Unix path form, an object carries an explicit kind, and every | |
| 14 | + | // kind/field mismatch or bound violation is refused. | |
| 15 | + | func TestParseRouteVariants(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | ||
| 18 | + | tests := []struct { | |
| 19 | + | name string | |
| 20 | + | raw string | |
| 21 | + | wantErr string | |
| 22 | + | check func(t *testing.T, route Route) | |
| 23 | + | }{ | |
| 24 | + | { | |
| 25 | + | name: "bare string is a Unix route", | |
| 26 | + | raw: `"/tmp/mux.sock"`, | |
| 27 | + | check: func(t *testing.T, route Route) { | |
| 28 | + | require.Equal(t, RouteUnix, route.Kind()) | |
| 29 | + | require.Equal(t, "/tmp/mux.sock", route.Path()) | |
| 30 | + | require.True(t, route.IsLocal()) | |
| 31 | + | }, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "explicit unix route", | |
| 35 | + | raw: `{"kind":"unix","path":"/tmp/mux.sock"}`, | |
| 36 | + | check: func(t *testing.T, route Route) { | |
| 37 | + | require.Equal(t, RouteUnix, route.Kind()) | |
| 38 | + | require.Equal(t, "/tmp/mux.sock", route.Path()) | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "ssh stdio route with trust inputs", | |
| 43 | + | raw: `{"kind":"ssh-stdio","target":"user@host:2222","argv":["vev","_broker-mux-stdio","--offline-root","/srv/vev"],` + | |
| 44 | + | `"trust":{"knownHostsFile":"/etc/ssh/known_hosts","connectTimeout":"30s"}}`, | |
| 45 | + | check: func(t *testing.T, route Route) { | |
| 46 | + | require.Equal(t, RouteSSHStdio, route.Kind()) | |
| 47 | + | require.Equal(t, "user@host:2222", route.Target()) | |
| 48 | + | require.Equal(t, []string{"vev", "_broker-mux-stdio", "--offline-root", "/srv/vev"}, route.Argv()) | |
| 49 | + | require.Equal(t, "/etc/ssh/known_hosts", route.KnownHostsFile()) | |
| 50 | + | require.Equal(t, 30*time.Second, route.ConnectTimeout()) | |
| 51 | + | require.False(t, route.IsLocal()) | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "ssh quic route without trust", | |
| 56 | + | raw: `{"kind":"ssh-quic","target":"host","argv":["vev","_broker-mux-quic-bootstrap","--offline-root","/srv/vev"]}`, | |
| 57 | + | check: func(t *testing.T, route Route) { | |
| 58 | + | require.Equal(t, RouteSSHQUIC, route.Kind()) | |
| 59 | + | require.Equal(t, "host", route.Target()) | |
| 60 | + | require.Empty(t, route.KnownHostsFile()) | |
| 61 | + | require.Zero(t, route.ConnectTimeout()) | |
| 62 | + | }, | |
| 63 | + | }, | |
| 64 | + | {name: "empty route", raw: ``, wantErr: "empty"}, | |
| 65 | + | {name: "null route", raw: `null`, wantErr: "path string or an object"}, | |
| 66 | + | {name: "number route", raw: `42`, wantErr: "path string or an object"}, | |
| 67 | + | {name: "unknown kind", raw: `{"kind":"carrier-pigeon","target":"h","argv":["x"]}`, wantErr: "invalid"}, | |
| 68 | + | {name: "missing kind", raw: `{"path":"/tmp/mux.sock"}`, wantErr: "requires a kind"}, | |
| 69 | + | {name: "unknown field", raw: `{"kind":"unix","path":"/tmp/mux.sock","extra":1}`, wantErr: "unknown field"}, | |
| 70 | + | {name: "trailing json", raw: `{"kind":"unix","path":"/tmp/mux.sock"} {}`, wantErr: "trailing"}, | |
| 71 | + | {name: "unix carries ssh fields", raw: `{"kind":"unix","path":"/tmp/mux.sock","target":"h"}`, wantErr: "must not carry ssh"}, | |
| 72 | + | {name: "ssh without argv", raw: `{"kind":"ssh-stdio","target":"h"}`, wantErr: "argv"}, | |
| 73 | + | {name: "ssh empty argv word", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev",""]}`, wantErr: "empty"}, | |
| 74 | + | {name: "ssh word control char", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev","a\nb"]}`, wantErr: "control"}, | |
| 75 | + | {name: "ssh target whitespace", raw: `{"kind":"ssh-stdio","target":"a b","argv":["vev"]}`, wantErr: "whitespace"}, | |
| 76 | + | {name: "ssh target leading dash", raw: `{"kind":"ssh-stdio","target":"-oProxyCommand=x","argv":["vev"]}`, wantErr: "dash"}, | |
| 77 | + | {name: "ssh target too long", raw: `{"kind":"ssh-stdio","target":"` + strings.Repeat("a", MaxRouteTargetBytes+1) + `","argv":["vev"]}`, wantErr: "exceeds"}, | |
| 78 | + | {name: "too many argv words", raw: `{"kind":"ssh-stdio","target":"h","argv":[` + strings.Repeat(`"w",`, MaxRouteArgvWords) + `"w"]}`, wantErr: "words"}, | |
| 79 | + | {name: "relative trust path", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"knownHostsFile":"known_hosts"}}`, wantErr: "not absolute"}, | |
| 80 | + | {name: "unclean trust path", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"knownHostsFile":"/etc/ssh/../known_hosts"}}`, wantErr: "not clean"}, | |
| 81 | + | {name: "space in trust path", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"knownHostsFile":"/etc/ssh/known hosts"}}`, wantErr: "whitespace"}, | |
| 82 | + | {name: "bad timeout duration", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"connectTimeout":"soon"}}`, wantErr: "not a duration"}, | |
| 83 | + | {name: "negative timeout", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"connectTimeout":"-1s"}}`, wantErr: "positive"}, | |
| 84 | + | {name: "excessive timeout", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"connectTimeout":"1h"}}`, wantErr: "between"}, | |
| 85 | + | } | |
| 86 | + | for _, tt := range tests { | |
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |
| 88 | + | route, err := parseRoute(json.RawMessage(tt.raw)) | |
| 89 | + | if tt.wantErr != "" { | |
| 90 | + | require.Error(t, err) | |
| 91 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | require.NoError(t, route.Validate()) | |
| 96 | + | if tt.check != nil { | |
| 97 | + | tt.check(t, route) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // TestRouteAddressIsOpaqueAndDeterministic proves the pool address leaks no | |
| 104 | + | // route detail, is bounded, is stable across parses, and distinguishes distinct | |
| 105 | + | // routes. | |
| 106 | + | func TestRouteAddressIsOpaqueAndDeterministic(t *testing.T) { | |
| 107 | + | t.Parallel() | |
| 108 | + | ||
| 109 | + | unix, err := parseRoute(json.RawMessage(`{"kind":"unix","path":"/tmp/secret-dir/mux.sock"}`)) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | stdio, err := parseRoute(json.RawMessage(`{"kind":"ssh-stdio","target":"user@secret-host","argv":["vev","_broker-mux-stdio","--offline-root","/srv/secret"]}`)) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | quic, err := parseRoute(json.RawMessage(`{"kind":"ssh-quic","target":"user@secret-host","argv":["vev","_broker-mux-quic-bootstrap","--offline-root","/srv/secret"]}`)) | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | ||
| 116 | + | require.NotEmpty(t, unix.Address()) | |
| 117 | + | require.LessOrEqual(t, len(unix.Address()), MaxRouteAddressBytes) | |
| 118 | + | require.Contains(t, unix.Address(), "offline-route-") | |
| 119 | + | ||
| 120 | + | for _, secret := range []string{"secret-host", "secret-dir", "/srv/secret", "_broker-mux", "mux.sock"} { | |
| 121 | + | for _, route := range []Route{unix, stdio, quic} { | |
| 122 | + | require.NotContains(t, route.Address(), secret, "address must not leak route detail") | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | require.NotEqual(t, unix.Address(), stdio.Address()) | |
| 126 | + | require.NotEqual(t, stdio.Address(), quic.Address()) | |
| 127 | + | ||
| 128 | + | again, err := parseRoute(json.RawMessage(`{"kind":"ssh-stdio","target":"user@secret-host","argv":["vev","_broker-mux-stdio","--offline-root","/srv/secret"]}`)) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.Equal(t, stdio.Address(), again.Address(), "address must be stable across parses") | |
| 131 | + | ||
| 132 | + | // A different trust input is a different route and therefore a different | |
| 133 | + | // address. | |
| 134 | + | trusted, err := parseRoute(json.RawMessage(`{"kind":"ssh-stdio","target":"user@secret-host","argv":["vev","_broker-mux-stdio","--offline-root","/srv/secret"],"trust":{"connectTimeout":"5s"}}`)) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | require.NotEqual(t, stdio.Address(), trusted.Address()) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // TestConfigLocalMuxRouteRules proves a helper only ever bridges to exactly one | |
| 140 | + | // provisioned Unix route. | |
| 141 | + | func TestConfigLocalMuxRouteRules(t *testing.T) { | |
| 142 | + | t.Parallel() | |
| 143 | + | ||
| 144 | + | root := t.TempDir() | |
| 145 | + | writeConfig(t, root, emptyRouteConfig(), 0o600) | |
| 146 | + | layout, err := ResolveLayout(root, nil) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | config, err := Load(layout) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | _, err = config.LocalMuxRoute() | |
| 151 | + | require.Error(t, err) | |
| 152 | + | require.Contains(t, err.Error(), "no Unix daemon-mux route") | |
| 153 | + | ||
| 154 | + | root = t.TempDir() | |
| 155 | + | writeConfig(t, root, routeConfig( | |
| 156 | + | map[string]any{"kind": "unix", "path": "/tmp/one.sock"}, | |
| 157 | + | map[string]any{"kind": "unix", "path": "/tmp/two.sock"}, | |
| 158 | + | ), 0o600) | |
| 159 | + | layout, err = ResolveLayout(root, nil) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | config, err = Load(layout) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | _, err = config.LocalMuxRoute() | |
| 164 | + | require.Error(t, err) | |
| 165 | + | require.Contains(t, err.Error(), "more than one") | |
| 166 | + | ||
| 167 | + | root = t.TempDir() | |
| 168 | + | writeConfig(t, root, routeConfig(map[string]any{"kind": "unix", "path": "/tmp/one.sock"}), 0o600) | |
| 169 | + | layout, err = ResolveLayout(root, nil) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | config, err = Load(layout) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | route, err := config.LocalMuxRoute() | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Equal(t, "/tmp/one.sock", route.Path()) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // emptyRouteConfig renders a marker-valid document with no registrations. | |
| 179 | + | func emptyRouteConfig() map[string]any { | |
| 180 | + | return map[string]any{"marker": Marker, "registrations": []any{}} | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // routeConfig renders one marker-valid document whose registrations carry the | |
| 184 | + | // supplied explicit route objects. Each registration gets a distinct endpoint | |
| 185 | + | // and authenticated identity, so a document with several routes exercises the | |
| 186 | + | // route rules rather than the one-route-per-pooled-identity rule. | |
| 187 | + | func routeConfig(routes ...map[string]any) map[string]any { | |
| 188 | + | registrations := make([]any, 0, len(routes)) | |
| 189 | + | for i, route := range routes { | |
| 190 | + | registrations = append(registrations, map[string]any{ | |
| 191 | + | "endpoint": testEndpoint + "-" + string(rune('a'+i)), | |
| 192 | + | "incarnation": "0102030405060708090a0b0c0d0e0f10", | |
| 193 | + | "generation": 1, | |
| 194 | + | "identity": "offline-daemon-" + string(rune('a'+i)), | |
| 195 | + | "route": route, | |
| 196 | + | "policy": map[string]any{ | |
| 197 | + | "protocolVersion": 1, | |
| 198 | + | "catalogSchemaVersion": 3, | |
| 199 | + | "environmentPolicy": "client-owned", | |
| 200 | + | "transport": "offline", | |
| 201 | + | "trust": "offline-trust", | |
| 202 | + | "launch": "offline-launch", | |
| 203 | + | "isolation": "offline-isolation", | |
| 204 | + | }, | |
| 205 | + | }) | |
| 206 | + | } | |
| 207 | + | return map[string]any{"marker": Marker, "registrations": registrations} | |
| 208 | + | } | |
A internal/adapters/brokeripc/bounded_setup_test.go +328 -0
| @@ -0,0 +1,328 @@ | |||
| 1 | + | package brokeripc | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "runtime" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 14 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Fully bounded broker setup (P3.4 slice A). These tests drive the real AF_UNIX | |
| 18 | + | // carriage, never an in-memory replacement, so the only way to interrupt a peer | |
| 19 | + | // parked in framing I/O is the behavior under test: the client closing the | |
| 20 | + | // carriage when its single setup deadline expires, and the server enforcing the | |
| 21 | + | // accept-time registration budget on the pre-Register phase. | |
| 22 | + | // | |
| 23 | + | // The client half: one Dial context/deadline bounds the Unix dial, the preamble | |
| 24 | + | // exchange, the Register send, and the wait for Registered; a peer that stalls | |
| 25 | + | // after a successful preamble is interrupted by closing the carriage with every | |
| 26 | + | // setup worker joined; and a successful connection detaches from the setup | |
| 27 | + | // context after Registered. The server half: after ports.BrokerAuthority | |
| 28 | + | // admission a client must send Register within the same accept-time handshake | |
| 29 | + | // budget, or the session, its admitted core lease, and its listener slot are | |
| 30 | + | // released, while Listener.Close stays immediate. | |
| 31 | + | ||
| 32 | + | // silentBrokerPeer is one real AF_UNIX peer that completes the broker preamble | |
| 33 | + | // and then never answers Register, so a client's registration wait is observable | |
| 34 | + | // and interruptible without sleeping on a race. | |
| 35 | + | type silentBrokerPeer struct { | |
| 36 | + | mux ipc.MuxListener | |
| 37 | + | preambleSeen chan struct{} | |
| 38 | + | release chan struct{} | |
| 39 | + | done chan struct{} | |
| 40 | + | once sync.Once | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // startSilentBrokerPeer binds one real same-user endpoint whose only peer | |
| 44 | + | // completes the preamble and then stays silent. | |
| 45 | + | func startSilentBrokerPeer(t *testing.T, path string) *silentBrokerPeer { | |
| 46 | + | t.Helper() | |
| 47 | + | mux, err := ipc.ListenMux(path) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | peer := &silentBrokerPeer{ | |
| 50 | + | mux: mux, | |
| 51 | + | preambleSeen: make(chan struct{}), | |
| 52 | + | release: make(chan struct{}), | |
| 53 | + | done: make(chan struct{}), | |
| 54 | + | } | |
| 55 | + | go func() { | |
| 56 | + | defer close(peer.done) | |
| 57 | + | conn, err := mux.Accept() | |
| 58 | + | if err != nil { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | defer func() { _ = conn.Close() }() | |
| 62 | + | if _, err := runServerPreamble(context.Background(), conn, brokerwire.DefaultCeilings()); err != nil { | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | close(peer.preambleSeen) | |
| 66 | + | <-peer.release | |
| 67 | + | }() | |
| 68 | + | t.Cleanup(peer.stop) | |
| 69 | + | return peer | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // stop releases the silent peer and joins it. It is idempotent. | |
| 73 | + | func (p *silentBrokerPeer) stop() { | |
| 74 | + | p.once.Do(func() { close(p.release) }) | |
| 75 | + | _ = p.mux.Close() | |
| 76 | + | <-p.done | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // awaitPreamble fails unless the peer observed the client's preamble. | |
| 80 | + | func (p *silentBrokerPeer) awaitPreamble(t *testing.T) { | |
| 81 | + | t.Helper() | |
| 82 | + | select { | |
| 83 | + | case <-p.preambleSeen: | |
| 84 | + | case <-time.After(5 * time.Second): | |
| 85 | + | t.Fatal("the peer never observed the broker preamble") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // TestClientDialSilentAfterPreambleIsBoundedAndJoined proves a peer that | |
| 90 | + | // completes the preamble and never answers Register cannot park Dial past the | |
| 91 | + | // configured handshake budget: Dial returns a deadline failure, the carriage was | |
| 92 | + | // closed to interrupt the blocked wait, and the setup workers were joined | |
| 93 | + | // instead of being abandoned. | |
| 94 | + | func TestClientDialSilentAfterPreambleIsBoundedAndJoined(t *testing.T) { | |
| 95 | + | path := testSocketPath(t) | |
| 96 | + | peer := startSilentBrokerPeer(t, path) | |
| 97 | + | ||
| 98 | + | baseline := runtime.NumGoroutine() | |
| 99 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 100 | + | defer cancel() | |
| 101 | + | started := time.Now() | |
| 102 | + | client, err := Dial(ctx, path, Config{HandshakeTimeout: 200 * time.Millisecond}) | |
| 103 | + | elapsed := time.Since(started) | |
| 104 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 105 | + | require.Nil(t, client) | |
| 106 | + | require.Less(t, elapsed, 5*time.Second, "the setup deadline must interrupt the silent registration wait") | |
| 107 | + | peer.awaitPreamble(t) | |
| 108 | + | ||
| 109 | + | peer.stop() | |
| 110 | + | require.Eventually(t, func() bool { return runtime.NumGoroutine() <= baseline+2 }, 5*time.Second, 5*time.Millisecond, | |
| 111 | + | "a bounded Dial must not abandon a setup goroutine (baseline=%d now=%d)", baseline, runtime.NumGoroutine()) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // TestClientDialEverySetupStepSharesTheOneDeadline proves the caller's context | |
| 115 | + | // deadline, not only HandshakeTimeout, bounds the whole client setup: a deadline | |
| 116 | + | // far tighter than HandshakeTimeout still interrupts the registration wait. | |
| 117 | + | func TestClientDialEverySetupStepSharesTheOneDeadline(t *testing.T) { | |
| 118 | + | path := testSocketPath(t) | |
| 119 | + | peer := startSilentBrokerPeer(t, path) | |
| 120 | + | ||
| 121 | + | ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) | |
| 122 | + | defer cancel() | |
| 123 | + | started := time.Now() | |
| 124 | + | client, err := Dial(ctx, path, Config{HandshakeTimeout: 30 * time.Second}) | |
| 125 | + | elapsed := time.Since(started) | |
| 126 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 127 | + | require.Nil(t, client) | |
| 128 | + | require.Less(t, elapsed, 5*time.Second, "the caller's deadline must bound the setup") | |
| 129 | + | peer.awaitPreamble(t) | |
| 130 | + | peer.stop() | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // TestClientDialCancellationInterruptsRegistrationWait proves an explicit | |
| 134 | + | // cancellation, not just a deadline, interrupts the wait for Registered and is | |
| 135 | + | // reported as cancellation rather than being masked by a carriage failure. | |
| 136 | + | func TestClientDialCancellationInterruptsRegistrationWait(t *testing.T) { | |
| 137 | + | path := testSocketPath(t) | |
| 138 | + | peer := startSilentBrokerPeer(t, path) | |
| 139 | + | ||
| 140 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 141 | + | defer cancel() | |
| 142 | + | result := make(chan error, 1) | |
| 143 | + | go func() { | |
| 144 | + | _, err := Dial(ctx, path, Config{HandshakeTimeout: 30 * time.Second}) | |
| 145 | + | result <- err | |
| 146 | + | }() | |
| 147 | + | peer.awaitPreamble(t) | |
| 148 | + | cancel() | |
| 149 | + | ||
| 150 | + | select { | |
| 151 | + | case err := <-result: | |
| 152 | + | require.ErrorIs(t, err, context.Canceled, "cancellation must interrupt the registration wait") | |
| 153 | + | case <-time.After(5 * time.Second): | |
| 154 | + | t.Fatal("Dial must return on cancellation instead of waiting out HandshakeTimeout") | |
| 155 | + | } | |
| 156 | + | peer.stop() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // TestClientConnectionOutlivesSetupContext proves a successful connection | |
| 160 | + | // detaches from the setup context after Registered: once Dial returns, the | |
| 161 | + | // caller's setup deadline may elapse without disturbing the established | |
| 162 | + | // connection. | |
| 163 | + | func TestClientConnectionOutlivesSetupContext(t *testing.T) { | |
| 164 | + | e := startEndpoint(t, Config{HandshakeTimeout: 5 * time.Second}) | |
| 165 | + | ||
| 166 | + | setupCtx, cancelSetup := context.WithTimeout(context.Background(), 200*time.Millisecond) | |
| 167 | + | defer cancelSetup() | |
| 168 | + | client, err := Dial(setupCtx, e.path, Config{HandshakeTimeout: 5 * time.Second}) | |
| 169 | + | require.NoError(t, err) | |
| 170 | + | t.Cleanup(func() { _ = client.Close() }) | |
| 171 | + | session := e.accept() | |
| 172 | + | require.Equal(t, session.ConnectionID(), client.ConnectionID()) | |
| 173 | + | ||
| 174 | + | // Let the setup deadline elapse; the connection was registered in time and | |
| 175 | + | // must keep serving on its own connection-lived context. | |
| 176 | + | time.Sleep(400 * time.Millisecond) | |
| 177 | + | require.ErrorIs(t, setupCtx.Err(), context.DeadlineExceeded, "the setup context must have expired") | |
| 178 | + | ||
| 179 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 180 | + | defer cancel() | |
| 181 | + | require.NoError(t, client.AddHost(ctx, "after@setup:22"), "a registered connection must outlive its setup context") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot | |
| 185 | + | // hold an admitted core lease or a listener slot indefinitely: after | |
| 186 | + | // ports.BrokerAuthority admission the client must send Register within the | |
| 187 | + | // accept-time handshake budget, or the session closes the core service and | |
| 188 | + | // releases the slot, and the listener keeps serving. | |
| 189 | + | func TestServerRegistrationBudgetSettlesSilentPeer(t *testing.T) { | |
| 190 | + | e := startEndpoint(t, Config{MaxClients: 1, HandshakeTimeout: 200 * time.Millisecond}) | |
| 191 | + | l := e.listener.(*listener) | |
| 192 | + | ||
| 193 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 194 | + | defer cancel() | |
| 195 | + | transport, err := ipc.DialMuxContext(ctx, e.path) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | t.Cleanup(func() { _ = transport.Close() }) | |
| 198 | + | _, err = runClientPreamble(ctx, transport, brokerwire.DefaultCeilings()) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | ||
| 201 | + | session := e.accept() | |
| 202 | + | core := e.authority.last() | |
| 203 | + | require.NotNil(t, core) | |
| 204 | + | ||
| 205 | + | require.Eventually(t, func() bool { | |
| 206 | + | select { | |
| 207 | + | case <-core.done: | |
| 208 | + | return true | |
| 209 | + | default: | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | }, 5*time.Second, 5*time.Millisecond, "a silent unregistered peer must not hold the admitted core lease") | |
| 213 | + | require.Eventually(t, func() bool { return l.liveSessions() == 0 }, 5*time.Second, 5*time.Millisecond, | |
| 214 | + | "a settled session must leave the listener's live set") | |
| 215 | + | ||
| 216 | + | // The timeout is the peer's departure, not a session failure: the settled | |
| 217 | + | // session reports an orderly end even though its terminal cause still records | |
| 218 | + | // the registration budget. | |
| 219 | + | require.NoError(t, session.Close()) | |
| 220 | + | require.ErrorIs(t, session.(*serverSession).terminalErr(), ErrRegistrationTimeout) | |
| 221 | + | require.ErrorIs(t, session.(*serverSession).terminalErr(), context.DeadlineExceeded) | |
| 222 | + | ||
| 223 | + | // The released slot is reusable even at the bound of one: the listener | |
| 224 | + | // admits a fresh same-user client after the timeout. | |
| 225 | + | fresh := e.dial() | |
| 226 | + | freshSession := e.accept() | |
| 227 | + | require.Equal(t, fresh.ConnectionID(), freshSession.ConnectionID()) | |
| 228 | + | ||
| 229 | + | // A clean listener shutdown must not report a peer timeout as its own | |
| 230 | + | // failure, for the settled session or for the live one it ends. | |
| 231 | + | require.NoError(t, e.listener.Close()) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // TestRegistrationDeadlineBoundaryMarker proves the pre-Register guard treats | |
| 235 | + | // the registered marker, not timer ordering, as the deadline boundary: a session | |
| 236 | + | // whose Register handling already began is never settled with | |
| 237 | + | // ErrRegistrationTimeout, while a session with no Register is. The deadline is | |
| 238 | + | // already in the past so the guard observes the boundary deterministically. | |
| 239 | + | func TestRegistrationDeadlineBoundaryMarker(t *testing.T) { | |
| 240 | + | tests := []struct { | |
| 241 | + | name string | |
| 242 | + | registered bool | |
| 243 | + | wantTimeout bool | |
| 244 | + | }{ | |
| 245 | + | {name: "register handling begun at the deadline", registered: true}, | |
| 246 | + | {name: "no register by the deadline", registered: false, wantTimeout: true}, | |
| 247 | + | } | |
| 248 | + | for _, tc := range tests { | |
| 249 | + | t.Run(tc.name, func(t *testing.T) { | |
| 250 | + | core := newTestCore(0x51) | |
| 251 | + | session, err := newServerSession(0x51, newRecordingTransport(), brokerwire.DefaultCeilings(), core, Config{}, func() {}, time.Now().Add(-time.Second)) | |
| 252 | + | require.NoError(t, err) | |
| 253 | + | session.registered.Store(tc.registered) | |
| 254 | + | ||
| 255 | + | stop := session.armRegistrationDeadline() | |
| 256 | + | if tc.wantTimeout { | |
| 257 | + | require.Eventually(t, func() bool { return errors.Is(session.terminalErr(), ErrRegistrationTimeout) }, time.Second, time.Millisecond) | |
| 258 | + | } | |
| 259 | + | stop() | |
| 260 | + | ||
| 261 | + | if tc.wantTimeout { | |
| 262 | + | require.ErrorIs(t, session.terminalErr(), ErrRegistrationTimeout) | |
| 263 | + | require.ErrorIs(t, session.terminalErr(), context.DeadlineExceeded) | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | require.NoError(t, session.terminalErr(), "a Register already being handled must not be settled as a peer timeout") | |
| 267 | + | }) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // TestServerRegistrationBudgetLeavesRegisteredSessionHealthy proves the | |
| 272 | + | // registration budget bounds only the pre-Register phase: a session that | |
| 273 | + | // registered in time keeps serving long after the accept-time deadline has | |
| 274 | + | // passed. | |
| 275 | + | func TestServerRegistrationBudgetLeavesRegisteredSessionHealthy(t *testing.T) { | |
| 276 | + | e := startEndpoint(t, Config{HandshakeTimeout: 150 * time.Millisecond}) | |
| 277 | + | client, _ := e.pair() | |
| 278 | + | core := e.authority.last() | |
| 279 | + | ||
| 280 | + | // Outlive the accept-time registration budget. | |
| 281 | + | time.Sleep(400 * time.Millisecond) | |
| 282 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 283 | + | defer cancel() | |
| 284 | + | require.NoError(t, client.AddHost(ctx, "after@budget:22")) | |
| 285 | + | select { | |
| 286 | + | case <-core.done: | |
| 287 | + | t.Fatal("a session that registered in time must not be settled by the registration budget") | |
| 288 | + | default: | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // TestListenerCloseDuringRegistrationWindowIsImmediate proves Close remains | |
| 293 | + | // immediate for a session that is inside its registration window with a | |
| 294 | + | // deliberately long budget: it does not wait out HandshakeTimeout, and it | |
| 295 | + | // releases the admitted core service and the slot. | |
| 296 | + | func TestListenerCloseDuringRegistrationWindowIsImmediate(t *testing.T) { | |
| 297 | + | e := startEndpoint(t, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second}) | |
| 298 | + | l := e.listener.(*listener) | |
| 299 | + | ||
| 300 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 301 | + | defer cancel() | |
| 302 | + | transport, err := ipc.DialMuxContext(ctx, e.path) | |
| 303 | + | require.NoError(t, err) | |
| 304 | + | t.Cleanup(func() { _ = transport.Close() }) | |
| 305 | + | _, err = runClientPreamble(ctx, transport, brokerwire.DefaultCeilings()) | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | ||
| 308 | + | _ = e.accept() | |
| 309 | + | core := e.authority.last() | |
| 310 | + | require.NotNil(t, core) | |
| 311 | + | ||
| 312 | + | closed := make(chan error, 1) | |
| 313 | + | go func() { closed <- e.listener.Close() }() | |
| 314 | + | select { | |
| 315 | + | case err := <-closed: | |
| 316 | + | require.NoError(t, err) | |
| 317 | + | case <-time.After(5 * time.Second): | |
| 318 | + | t.Fatal("Close must not wait out the registration budget for a silent session") | |
| 319 | + | } | |
| 320 | + | select { | |
| 321 | + | case <-core.done: | |
| 322 | + | case <-time.After(5 * time.Second): | |
| 323 | + | t.Fatal("Close must end the admitted core service") | |
| 324 | + | } | |
| 325 | + | require.Zero(t, l.liveSessions()) | |
| 326 | + | require.Eventually(t, func() bool { return len(l.slots) == 0 }, 5*time.Second, 5*time.Millisecond, | |
| 327 | + | "Close must release the client slot") | |
| 328 | + | } | |
M internal/adapters/brokeripc/client.go +46 -8
| @@ -45,22 +45,31 @@ func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) | |||
| 45 | 45 | if path == "" { | |
| 46 | 46 | return nil, ErrConfig | |
| 47 | 47 | } | |
| 48 | - | transport, err := ipc.DialMuxContextWithPeerVerifier(ctx, path, verify) | |
| 48 | + | // One deadline bounds the whole setup: the Unix dial, the preamble | |
| 49 | + | // exchange, the Register send, and the wait for Registered. Each setup step | |
| 50 | + | // closes the carriage when that deadline expires, because a context alone | |
| 51 | + | // cannot interrupt blocking framing I/O; that is what stops a peer that | |
| 52 | + | // stalls after a successful preamble from parking Dial forever. | |
| 53 | + | setup, cancelSetup := context.WithTimeout(ctx, cfg.HandshakeTimeout) | |
| 54 | + | defer cancelSetup() | |
| 55 | + | transport, err := ipc.DialMuxContextWithPeerVerifier(setup, path, verify) | |
| 49 | 56 | if err != nil { | |
| 50 | 57 | return nil, err | |
| 51 | 58 | } | |
| 52 | - | handshake, cancel := context.WithTimeout(ctx, cfg.HandshakeTimeout) | |
| 53 | - | defer cancel() | |
| 54 | - | ceilings, err := runClientPreamble(handshake, transport, brokerwire.DefaultCeilings()) | |
| 59 | + | ceilings, err := runClientPreamble(setup, transport, brokerwire.DefaultCeilings()) | |
| 55 | 60 | if err != nil { | |
| 56 | 61 | _ = transport.Close() | |
| 57 | 62 | return nil, err | |
| 58 | 63 | } | |
| 59 | - | scope, err := register(handshake, transport, ceilings) | |
| 64 | + | scope, err := register(setup, transport, ceilings) | |
| 60 | 65 | if err != nil { | |
| 61 | 66 | _ = transport.Close() | |
| 62 | 67 | return nil, err | |
| 63 | 68 | } | |
| 69 | + | // Setup is complete and the carriage is healthy, so the established | |
| 70 | + | // connection detaches from the setup deadline: it gets its own | |
| 71 | + | // connection-lived context below, and cancelSetup (deferred) only ends an | |
| 72 | + | // already-finished setup. | |
| 64 | 73 | conn, err := brokerwire.NewConnection(scope) | |
| 65 | 74 | if err != nil { | |
| 66 | 75 | _ = transport.Close() | |
| @@ -94,7 +103,39 @@ func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) | |||
| 94 | 103 | // register completes the broker registration exchange: one Register follows the | |
| 95 | 104 | // preamble, and the first server frame must be the Registered answer carrying | |
| 96 | 105 | // the scope assigned at accept. Any other first frame is a protocol violation. | |
| 106 | + | // | |
| 107 | + | // The wait is bounded by ctx: a peer that stalls after a successful preamble is | |
| 108 | + | // interrupted by closing the carriage, and the exchange worker is joined before | |
| 109 | + | // returning, so no setup goroutine is abandoned on the carriage. A Registered | |
| 110 | + | // observed after ctx expired is not a success. | |
| 97 | 111 | func register(ctx context.Context, transport wire.BoundedTransport, ceilings brokerwire.Ceilings) (brokerwire.Scope, error) { | |
| 112 | + | type result struct { | |
| 113 | + | scope brokerwire.Scope | |
| 114 | + | err error | |
| 115 | + | } | |
| 116 | + | done := make(chan result, 1) | |
| 117 | + | go func() { | |
| 118 | + | scope, err := exchangeRegister(transport, ceilings) | |
| 119 | + | done <- result{scope: scope, err: err} | |
| 120 | + | }() | |
| 121 | + | select { | |
| 122 | + | case <-ctx.Done(): | |
| 123 | + | _ = transport.Close() | |
| 124 | + | <-done | |
| 125 | + | return brokerwire.Scope{}, ctx.Err() | |
| 126 | + | case outcome := <-done: | |
| 127 | + | if outcome.err == nil { | |
| 128 | + | if err := ctx.Err(); err != nil { | |
| 129 | + | return brokerwire.Scope{}, err | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return outcome.scope, outcome.err | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // exchangeRegister runs one Register/Registered round trip on the carriage. It | |
| 137 | + | // is synchronous: the caller bounds it by closing the carriage on ctx expiry. | |
| 138 | + | func exchangeRegister(transport wire.BoundedTransport, ceilings brokerwire.Ceilings) (brokerwire.Scope, error) { | |
| 98 | 139 | payload, err := brokerwire.EncodeClient(brokerwire.Register{}, ceilings.MaxReceiveEnvelopeBytes, ceilings.StreamChunkLimit) | |
| 99 | 140 | if err != nil { | |
| 100 | 141 | return brokerwire.Scope{}, err | |
| @@ -118,9 +159,6 @@ func register(ctx context.Context, transport wire.BoundedTransport, ceilings bro | |||
| 118 | 159 | if err := scope.Validate(); err != nil { | |
| 119 | 160 | return brokerwire.Scope{}, errors.Join(ErrProtocol, err) | |
| 120 | 161 | } | |
| 121 | - | if err := ctx.Err(); err != nil { | |
| 122 | - | return brokerwire.Scope{}, err | |
| 123 | - | } | |
| 124 | 162 | return scope, nil | |
| 125 | 163 | } | |
| 126 | 164 | ||
M internal/adapters/brokeripc/config.go +9 -4
| @@ -33,10 +33,15 @@ type Config struct { | |||
| 33 | 33 | StreamInboundChunks int | |
| 34 | 34 | // StreamInboundBytes bounds one logical stream's inbound chunk bytes. | |
| 35 | 35 | StreamInboundBytes uint64 | |
| 36 | - | // HandshakeTimeout bounds one accepted connection's broker preamble plus | |
| 37 | - | // admission. The client dialer also bounds its own preamble exchange with | |
| 38 | - | // it. It is deliberately separate from the session handshake budget that | |
| 39 | - | // runs inside a logical stream. | |
| 36 | + | // HandshakeTimeout bounds one accepted connection's broker preamble, broker | |
| 37 | + | // admission, and the wait for its Register. The client dialer also bounds | |
| 38 | + | // its whole setup (Unix dial, preamble, Register send, and wait for | |
| 39 | + | // Registered) with it. On the server the registration phase shares the same | |
| 40 | + | // accept-time budget as the preamble and admission: a peer that completes | |
| 41 | + | // the preamble and admission but never sends Register is settled when that | |
| 42 | + | // deadline elapses, so it cannot hold a core client lease or a listener slot | |
| 43 | + | // indefinitely. It is deliberately separate from the session handshake | |
| 44 | + | // budget that runs inside a logical stream. | |
| 40 | 45 | HandshakeTimeout time.Duration | |
| 41 | 46 | // MaxPendingOperations bounds mutating operations this side is waiting for | |
| 42 | 47 | // on one connection. It never exceeds the brokerwire per-connection bound. | |
M internal/adapters/brokeripc/doc.go +17 -5
| @@ -24,7 +24,17 @@ | |||
| 24 | 24 | // ceilings, and waits for exactly one Register before admitting subscription, | |
| 25 | 25 | // operation, or stream work; every later frame must carry the assigned epoch | |
| 26 | 26 | // and connection identity or it is refused as stale without touching session | |
| 27 | - | // state. Bounds are explicit: accepted clients are capped per listener, stream | |
| 27 | + | // state. Setup is fully bounded on both halves. The client dialer bounds the | |
| 28 | + | // Unix dial, the preamble exchange, the Register send, and the wait for | |
| 29 | + | // Registered with one context/deadline; a context cannot interrupt blocking | |
| 30 | + | // framing I/O, so a setup step closes the carriage on expiry and joins its | |
| 31 | + | // worker, which is what stops a peer that stalls after a successful preamble, | |
| 32 | + | // and a connection that registered detaches from the setup context. Symmetrically | |
| 33 | + | // the server requires the client's Register within the same accept-time | |
| 34 | + | // handshake budget that bounded the preamble and admission, so a same-user peer | |
| 35 | + | // cannot complete the preamble, take an admitted core lease, and then hold that | |
| 36 | + | // lease and its listener slot indefinitely by staying silent. Bounds are | |
| 37 | + | // explicit: accepted clients are capped per listener, stream | |
| 28 | 38 | // inbound queues are bounded per stream, pending operations are capped per | |
| 29 | 39 | // connection, and the brokerwire per-connection trackers bound streams and | |
| 30 | 40 | // completed-operation dedup. Every accepted connection owns its own reader, | |
| @@ -39,8 +49,10 @@ | |||
| 39 | 49 | // Close returns. | |
| 40 | 50 | // | |
| 41 | 51 | // Like the brokerwire codec (P3.1) and the daemonmux carriage (P3.2), this | |
| 42 | - | // adapter is not activated by production composition. Nothing in internal/app | |
| 43 | - | // or main constructs it yet; P3.4 composes the broker use case, its stores, and | |
| 44 | - | // this listener behind a hidden entry point, and P7 performs the coordinated | |
| 45 | - | // production cutover. | |
| 52 | + | // adapter is not activated by production composition: no ordinary command, | |
| 53 | + | // path, or factory constructs it. P3.4 composes the broker use case, its | |
| 54 | + | // stores, and this listener behind the hidden `_broker-serve` sandbox entry | |
| 55 | + | // point, and slice D adds the hidden `_broker-launcher` and `_broker-status` | |
| 56 | + | // detached connect-or-spawn helpers over the same private root; P7 performs the | |
| 57 | + | // coordinated production cutover. | |
| 46 | 58 | package brokeripc | |
M internal/adapters/brokeripc/errors.go +7 -0
| @@ -44,6 +44,13 @@ var ( | |||
| 44 | 44 | // that is not the one assigned at accept. It is refused as stale and never | |
| 45 | 45 | // applied. | |
| 46 | 46 | ErrScopeMismatch = errors.New("brokeripc: frame scope does not match the accepted connection") | |
| 47 | + | // ErrRegistrationTimeout reports a client that completed the broker preamble | |
| 48 | + | // and admission but did not send its Register within the accept-time | |
| 49 | + | // handshake budget. The session is settled, its admitted core service is | |
| 50 | + | // closed, and its listener slot is released; context.DeadlineExceeded also | |
| 51 | + | // matches it. It is an orderly peer disconnect, so a listener draining its | |
| 52 | + | // sessions does not report a clean shutdown as this timeout. | |
| 53 | + | ErrRegistrationTimeout = errors.New("brokeripc: broker registration timed out") | |
| 47 | 54 | ) | |
| 48 | 55 | ||
| 49 | 56 | // transportFailure classifies one carriage error that ended a connection. A | |
M internal/adapters/brokeripc/limits_test.go +12 -4
| @@ -4,7 +4,10 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | + | "io/fs" | |
| 8 | + | "net" | |
| 7 | 9 | "sync" | |
| 10 | + | "syscall" | |
| 8 | 11 | "testing" | |
| 9 | 12 | "time" | |
| 10 | 13 | ||
| @@ -273,21 +276,26 @@ func (unboundedTransport) Close() error { return nil } | |||
| 273 | 276 | // never serve. | |
| 274 | 277 | func TestServerSessionRejectsInvalidConstruction(t *testing.T) { | |
| 275 | 278 | core := &fakeCore{id: ports.BrokerConnectionID{1}, hub: newSnapshotHub(ports.BrokerSnapshot{}), streams: map[ports.BrokerStreamID]*fakeLogicalConn{}, done: make(chan struct{})} | |
| 276 | - | _, err := newServerSession(0, nil, brokerwire.DefaultCeilings(), core, Config{}, nil) | |
| 279 | + | _, err := newServerSession(0, nil, brokerwire.DefaultCeilings(), core, Config{}, nil, time.Time{}) | |
| 277 | 280 | require.ErrorIs(t, err, ErrConfig) | |
| 278 | - | _, err = newServerSession(1, nil, brokerwire.DefaultCeilings(), nil, Config{}, nil) | |
| 281 | + | _, err = newServerSession(1, nil, brokerwire.DefaultCeilings(), nil, Config{}, nil, time.Time{}) | |
| 279 | 282 | require.ErrorIs(t, err, ErrConfig) | |
| 280 | 283 | require.NoError(t, brokerwire.DefaultCeilings().Validate()) | |
| 281 | 284 | require.Error(t, brokerwire.Ceilings{}.Validate()) | |
| 282 | 285 | } | |
| 283 | 286 | ||
| 284 | - | // TestOrderlyDisconnectClassification proves a peer disconnect and a local close | |
| 285 | - | // are orderly ends while a protocol violation is not. | |
| 287 | + | // TestOrderlyDisconnectClassification proves a peer disconnect, a local close, | |
| 288 | + | // and a peer registration timeout are orderly ends while a protocol violation | |
| 289 | + | // is not. | |
| 286 | 290 | func TestOrderlyDisconnectClassification(t *testing.T) { | |
| 287 | 291 | require.True(t, orderlyDisconnect(nil)) | |
| 288 | 292 | require.True(t, orderlyDisconnect(io.EOF)) | |
| 289 | 293 | require.True(t, orderlyDisconnect(ErrSessionClosed)) | |
| 290 | 294 | require.True(t, orderlyDisconnect(ErrConnectionClosed)) | |
| 295 | + | require.True(t, orderlyDisconnect(errors.Join(ErrRegistrationTimeout, context.DeadlineExceeded))) | |
| 296 | + | require.True(t, orderlyDisconnect(&net.OpError{Op: "read", Net: "unix", Err: syscall.ECONNRESET})) | |
| 297 | + | require.True(t, orderlyDisconnect(net.ErrClosed)) | |
| 298 | + | require.True(t, orderlyDisconnect(fs.ErrClosed)) | |
| 291 | 299 | require.False(t, orderlyDisconnect(ErrMalformedFrame)) | |
| 292 | 300 | require.False(t, orderlyDisconnect(ErrProtocol)) | |
| 293 | 301 | } | |
M internal/adapters/brokeripc/listen_test.go +1 -1
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | // gatedAuthority blocks admission until a test releases it, so a listener Close | |
| 22 | 22 | // can race an admission that has already reached the authority. | |
| 23 | 23 | type gatedAuthority struct { | |
| 24 | - | inner Authority | |
| 24 | + | inner ports.BrokerAuthority | |
| 25 | 25 | entered chan struct{} | |
| 26 | 26 | gate chan struct{} | |
| 27 | 27 | once sync.Once | |
M internal/adapters/brokeripc/preamble.go +12 -2
| @@ -21,11 +21,16 @@ import ( | |||
| 21 | 21 | // | |
| 22 | 22 | // The preamble envelope is bounded to brokerwire.BrokerPreambleLimit (4 KiB) | |
| 23 | 23 | // before allocation, strict-scanned with wire.ScanEnvelope before generated | |
| 24 | - | // unmarshal, and is never restarted: the caller's context (the configured | |
| 25 | - | // handshake timeout) bounds the whole exchange. | |
| 24 | + | // unmarshal, and is never restarted. The caller's context (the configured | |
| 25 | + | // setup/handshake budget) bounds the whole exchange: a context alone cannot | |
| 26 | + | // interrupt the blocking carriage I/O, so the client half closes the carriage | |
| 27 | + | // on expiry to release the worker and joins it before returning. | |
| 26 | 28 | ||
| 27 | 29 | // runClientPreamble sends the local broker PreambleRequest offer and accepts | |
| 28 | 30 | // exactly one PreambleResponse. It returns the effective negotiated ceilings. | |
| 31 | + | // On ctx expiry it closes the carriage, which interrupts the blocking send or | |
| 32 | + | // receive, and joins its worker before returning, so a peer that stalls | |
| 33 | + | // mid-preamble never leaves a setup goroutine parked on the carriage. | |
| 29 | 34 | func runClientPreamble(ctx context.Context, transport wire.Transport, offer brokerwire.Ceilings) (brokerwire.Ceilings, error) { | |
| 30 | 35 | if err := offer.Validate(); err != nil { | |
| 31 | 36 | return brokerwire.Ceilings{}, errors.Join(ErrConfig, err) | |
| @@ -69,6 +74,11 @@ func runClientPreamble(ctx context.Context, transport wire.Transport, offer brok | |||
| 69 | 74 | }() | |
| 70 | 75 | select { | |
| 71 | 76 | case <-ctx.Done(): | |
| 77 | + | // A context alone cannot reach a Send or RecvBounded parked on a silent | |
| 78 | + | // peer; closing the carriage releases the worker, and draining done | |
| 79 | + | // joins it before returning. No setup goroutine outlives the exchange. | |
| 80 | + | _ = transport.Close() | |
| 81 | + | <-done | |
| 72 | 82 | return brokerwire.Ceilings{}, ctx.Err() | |
| 73 | 83 | case outcome := <-done: | |
| 74 | 84 | return outcome.ceilings, outcome.err | |
M internal/adapters/brokeripc/server.go +150 -40
| @@ -5,7 +5,12 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | + | "io/fs" | |
| 9 | + | "net" | |
| 8 | 10 | "sync" | |
| 11 | + | "sync/atomic" | |
| 12 | + | "syscall" | |
| 13 | + | "time" | |
| 9 | 14 | ||
| 10 | 15 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 11 | 16 | "github.com/bnema/vev/internal/adapters/ipc" | |
| @@ -30,24 +35,6 @@ import ( | |||
| 30 | 35 | // one goroutine per admitted mutating operation. Every one of those is | |
| 31 | 36 | // per-connection, so a slow or stalled client blocks only its own work. | |
| 32 | 37 | ||
| 33 | - | // Authority admits one accepted broker client to the broker core. The returned | |
| 34 | - | // ports.BrokerService is the core's service for exactly that connection: its | |
| 35 | - | // ConnectionID is the identity the session stamps on every frame, and Close | |
| 36 | - | // releases the connection's core resources. P3.4 supplies the broker use case | |
| 37 | - | // here; this adapter never constructs a broker itself. | |
| 38 | - | // | |
| 39 | - | // ctx bounds AdmitClient only. It carries the listener's handshake deadline and | |
| 40 | - | // is canceled either as soon as admission returns or when the listener closes, | |
| 41 | - | // so an implementation must not retain it or start work that outlives admission | |
| 42 | - | // from it. Honoring ctx cancellation is what lets Listener.Close drop a client | |
| 43 | - | // already parked in admission immediately instead of waiting out the handshake | |
| 44 | - | // budget. The admitted service gets its own connection-lived context from the | |
| 45 | - | // session. An authority that needs a longer-lived context must supply its own | |
| 46 | - | // rather than keep this one. | |
| 47 | - | type Authority interface { | |
| 48 | - | AdmitClient(ctx context.Context) (ports.BrokerService, error) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | 38 | // Listen binds the private per-user broker endpoint at path and returns a | |
| 52 | 39 | // ports.BrokerListener that admits same-user clients only. | |
| 53 | 40 | // | |
| @@ -57,14 +44,22 @@ type Authority interface { | |||
| 57 | 44 | // without being removed, a stale socket left by a dead owner is recovered | |
| 58 | 45 | // race-safely, and a live owner is reported as ipc.ErrDaemonRunning instead of | |
| 59 | 46 | // being evicted. | |
| 60 | - | func Listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Config, opts ...ipc.Option) (ports.BrokerListener, error) { | |
| 47 | + | // | |
| 48 | + | // authority is the ports.BrokerAuthority admission seam: the listener calls | |
| 49 | + | // AdmitClient once per accepted connection under the handshake budget and binds | |
| 50 | + | // the returned service to exactly that connection. That service's ConnectionID | |
| 51 | + | // is the identity the session stamps on every frame; the P3.4 broker use case | |
| 52 | + | // supplies the implementation, and this adapter consumes it without | |
| 53 | + | // constructing a broker itself. See ports.BrokerAuthority for the | |
| 54 | + | // admission-context contract. | |
| 55 | + | func Listen(path string, epoch ports.BrokerEpoch, authority ports.BrokerAuthority, cfg Config, opts ...ipc.Option) (ports.BrokerListener, error) { | |
| 61 | 56 | return listen(path, epoch, authority, cfg, ipc.SameUserPeerVerifier(), opts...) | |
| 62 | 57 | } | |
| 63 | 58 | ||
| 64 | 59 | // listen is Listen with an injected peer verifier, so a test can observe a | |
| 65 | 60 | // deterministic same-user refusal without root. A nil verifier falls back to | |
| 66 | 61 | // the platform default. | |
| 67 | - | func listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Config, verify ipc.PeerVerifier, opts ...ipc.Option) (ports.BrokerListener, error) { | |
| 62 | + | func listen(path string, epoch ports.BrokerEpoch, authority ports.BrokerAuthority, cfg Config, verify ipc.PeerVerifier, opts ...ipc.Option) (ports.BrokerListener, error) { | |
| 68 | 63 | cfg = cfg.withDefaults() | |
| 69 | 64 | if err := cfg.validate(); err != nil { | |
| 70 | 65 | return nil, err | |
| @@ -110,7 +105,7 @@ func listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Confi | |||
| 110 | 105 | // already-accepted client immediately instead of waiting out HandshakeTimeout. | |
| 111 | 106 | type listener struct { | |
| 112 | 107 | epoch ports.BrokerEpoch | |
| 113 | - | authority Authority | |
| 108 | + | authority ports.BrokerAuthority | |
| 114 | 109 | cfg Config | |
| 115 | 110 | mux ipc.MuxListener | |
| 116 | 111 | slots chan struct{} | |
| @@ -338,9 +333,9 @@ func (l *listener) Accept() (ports.BrokerService, error) { | |||
| 338 | 333 | // startSession completes the preamble and admission for one accepted carriage | |
| 339 | 334 | // and starts the session. The admission context is bounded to the handshake | |
| 340 | 335 | // budget and is canceled as soon as admission returns or when the listener | |
| 341 | - | // closes, so the admitted service must not retain it (see Authority) and | |
| 342 | - | // Close can drop a client still in preamble or admission. Slot ownership | |
| 343 | - | // transfers to the session on success. | |
| 336 | + | // closes, so the admitted service must not retain it (see | |
| 337 | + | // ports.BrokerAuthority) and Close can drop a client still in preamble or | |
| 338 | + | // admission. Slot ownership transfers to the session on success. | |
| 344 | 339 | func (l *listener) startSession(transport wire.BoundedTransport, release func()) (*serverSession, error) { | |
| 345 | 340 | // The handshake budget is bounded by the listener-scoped context, so | |
| 346 | 341 | // Listener.Close cancels a preamble or admission that is still waiting | |
| @@ -361,7 +356,7 @@ func (l *listener) startSession(transport wire.BoundedTransport, release func()) | |||
| 361 | 356 | _ = transport.Close() | |
| 362 | 357 | return nil, fmt.Errorf("%w: authority admitted a nil service", ErrConfig) | |
| 363 | 358 | } | |
| 364 | - | session, err := newServerSession(l.epoch, transport, ceilings, core, l.cfg, release) | |
| 359 | + | session, err := newServerSession(l.epoch, transport, ceilings, core, l.cfg, release, registrationDeadline(ctx)) | |
| 365 | 360 | if err != nil { | |
| 366 | 361 | _ = core.Close() | |
| 367 | 362 | _ = transport.Close() | |
| @@ -496,6 +491,23 @@ type serverSession struct { | |||
| 496 | 491 | // session from its live set. It is set before run starts. | |
| 497 | 492 | onShutdown func() | |
| 498 | 493 | ||
| 494 | + | // registerDeadline is the absolute instant by which the client's Register | |
| 495 | + | // must arrive: the accept-time handshake deadline that bounded the preamble | |
| 496 | + | // and admission. Until Register is accepted the session is guarded by it, so | |
| 497 | + | // a peer that stays silent after admission is settled instead of pinning the | |
| 498 | + | // admitted core lease and the listener slot. Once Register is accepted the | |
| 499 | + | // guard is stopped and the session runs on its own connection-lived context. | |
| 500 | + | registerDeadline time.Time | |
| 501 | + | // registered is set atomically the instant Register handling begins, before | |
| 502 | + | // any connection state is touched. The pre-Register guard reads it when the | |
| 503 | + | // registration deadline elapses, so a Register that arrives at the deadline | |
| 504 | + | // boundary is never settled as a peer timeout even if the guard wins the | |
| 505 | + | // race to the deadline. | |
| 506 | + | registered atomic.Bool | |
| 507 | + | // stopRegistration stops and joins the pre-Register guard. It is set by run | |
| 508 | + | // and called once from the Register dispatch; a no-op for a zero deadline. | |
| 509 | + | stopRegistration func() | |
| 510 | + | ||
| 499 | 511 | errMu sync.Mutex | |
| 500 | 512 | closeErr error | |
| 501 | 513 | done chan struct{} | |
| @@ -503,8 +515,27 @@ type serverSession struct { | |||
| 503 | 515 | ||
| 504 | 516 | var _ ports.BrokerService = (*serverSession)(nil) | |
| 505 | 517 | ||
| 518 | + | // registrationDeadline returns the absolute instant by which an accepted client | |
| 519 | + | // must send its Register: the accept-time handshake deadline that already | |
| 520 | + | // bounded the preamble and admission. The session extends that same budget over | |
| 521 | + | // the pre-Register wait, so a peer that completes the preamble and admission and | |
| 522 | + | // then stays silent cannot hold a core client lease or a listener slot past the | |
| 523 | + | // configured handshake budget. A missing deadline yields the zero time, which | |
| 524 | + | // disables the bound. | |
| 525 | + | func registrationDeadline(ctx context.Context) time.Time { | |
| 526 | + | deadline, ok := ctx.Deadline() | |
| 527 | + | if !ok { | |
| 528 | + | return time.Time{} | |
| 529 | + | } | |
| 530 | + | return deadline | |
| 531 | + | } | |
| 532 | + | ||
| 506 | 533 | // newServerSession binds one admitted connection to its exact scope. | |
| 507 | - | func newServerSession(epoch ports.BrokerEpoch, transport wire.BoundedTransport, ceilings brokerwire.Ceilings, core ports.BrokerService, cfg Config, release func()) (*serverSession, error) { | |
| 534 | + | // | |
| 535 | + | // registerDeadline is the absolute instant by which the client's Register must | |
| 536 | + | // arrive (the accept-time handshake deadline); the zero time disables the bound | |
| 537 | + | // and exists only for direct construction outside the listener. | |
| 538 | + | func newServerSession(epoch ports.BrokerEpoch, transport wire.BoundedTransport, ceilings brokerwire.Ceilings, core ports.BrokerService, cfg Config, release func(), registerDeadline time.Time) (*serverSession, error) { | |
| 508 | 539 | if epoch == 0 || transport == nil || core == nil { | |
| 509 | 540 | return nil, ErrConfig | |
| 510 | 541 | } | |
| @@ -530,18 +561,19 @@ func newServerSession(epoch ports.BrokerEpoch, transport wire.BoundedTransport, | |||
| 530 | 561 | release = func() {} | |
| 531 | 562 | } | |
| 532 | 563 | return &serverSession{ | |
| 533 | - | epoch: epoch, | |
| 534 | - | scope: conn.Scope(), | |
| 535 | - | conn: conn, | |
| 536 | - | core: core, | |
| 537 | - | transport: transport, | |
| 538 | - | ceilings: ceilings, | |
| 539 | - | cfg: cfg, | |
| 540 | - | release: release, | |
| 541 | - | ctx: ctx, | |
| 542 | - | cancel: cancel, | |
| 543 | - | streams: make(map[ports.BrokerStreamID]*serverStream), | |
| 544 | - | done: make(chan struct{}), | |
| 564 | + | epoch: epoch, | |
| 565 | + | scope: conn.Scope(), | |
| 566 | + | conn: conn, | |
| 567 | + | core: core, | |
| 568 | + | transport: transport, | |
| 569 | + | ceilings: ceilings, | |
| 570 | + | cfg: cfg, | |
| 571 | + | release: release, | |
| 572 | + | ctx: ctx, | |
| 573 | + | cancel: cancel, | |
| 574 | + | streams: make(map[ports.BrokerStreamID]*serverStream), | |
| 575 | + | registerDeadline: registerDeadline, | |
| 576 | + | done: make(chan struct{}), | |
| 545 | 577 | }, nil | |
| 546 | 578 | } | |
| 547 | 579 | ||
| @@ -551,6 +583,13 @@ func newServerSession(epoch ports.BrokerEpoch, transport wire.BoundedTransport, | |||
| 551 | 583 | // carriage error settles exactly this connection. | |
| 552 | 584 | func (s *serverSession) run() { | |
| 553 | 585 | defer s.shutdown() | |
| 586 | + | // The pre-Register phase shares the accept-time handshake deadline that | |
| 587 | + | // bounded the preamble and admission. Arm it before the read loop and stop | |
| 588 | + | // (and join) it as soon as Register is accepted, so a silent peer cannot pin | |
| 589 | + | // the admitted core lease or the listener slot, and a healthy session is | |
| 590 | + | // never disturbed by it. | |
| 591 | + | s.stopRegistration = s.armRegistrationDeadline() | |
| 592 | + | defer s.stopRegistration() | |
| 554 | 593 | for { | |
| 555 | 594 | envelope, err := s.transport.RecvBounded(s.ceilings.MaxReceiveEnvelopeBytes) | |
| 556 | 595 | if err != nil { | |
| @@ -569,15 +608,72 @@ func (s *serverSession) run() { | |||
| 569 | 608 | } | |
| 570 | 609 | } | |
| 571 | 610 | ||
| 611 | + | // armRegistrationDeadline enforces the accept-time registration budget on the | |
| 612 | + | // pre-Register phase. The peer must send its Register before the absolute | |
| 613 | + | // deadline that already bounded the preamble and admission; if it does not, the | |
| 614 | + | // guard settles the session, which closes the admitted core service and releases | |
| 615 | + | // the listener slot. The bound is derived from the session's own connection-lived | |
| 616 | + | // context, so shutdown cancels it too, and it is stopped as soon as Register is | |
| 617 | + | // accepted. | |
| 618 | + | // | |
| 619 | + | // The guard consults the registered marker before settling: a Register whose | |
| 620 | + | // handling already began is admitted even when the deadline elapses concurrently | |
| 621 | + | // with it, so the boundary between an on-time Register and a silent peer is the | |
| 622 | + | // marker, never a timer race. | |
| 623 | + | // | |
| 624 | + | // The returned stop function is idempotent and joins the guard goroutine, so an | |
| 625 | + | // accepted (or settled) session never leaves a timer goroutine behind. A zero | |
| 626 | + | // deadline disables the bound; only direct session construction uses that. | |
| 627 | + | func (s *serverSession) armRegistrationDeadline() func() { | |
| 628 | + | if s.registerDeadline.IsZero() { | |
| 629 | + | return func() {} | |
| 630 | + | } | |
| 631 | + | ctx, cancel := context.WithDeadline(s.ctx, s.registerDeadline) | |
| 632 | + | stop := make(chan struct{}) | |
| 633 | + | done := make(chan struct{}) | |
| 634 | + | go func() { | |
| 635 | + | defer close(done) | |
| 636 | + | select { | |
| 637 | + | case <-ctx.Done(): | |
| 638 | + | // Only the registration budget itself settles the session; a session | |
| 639 | + | // cancel (shutdown) leaves the terminal cause to the reader. A | |
| 640 | + | // Register already being handled makes the budget moot: the marker is | |
| 641 | + | // set before any connection state is touched, so this check is the | |
| 642 | + | // deadline boundary. | |
| 643 | + | if errors.Is(ctx.Err(), context.DeadlineExceeded) && !s.registered.Load() { | |
| 644 | + | s.abort(errors.Join(ErrRegistrationTimeout, context.DeadlineExceeded)) | |
| 645 | + | } | |
| 646 | + | case <-stop: | |
| 647 | + | } | |
| 648 | + | }() | |
| 649 | + | var once sync.Once | |
| 650 | + | return func() { | |
| 651 | + | once.Do(func() { | |
| 652 | + | cancel() | |
| 653 | + | close(stop) | |
| 654 | + | <-done | |
| 655 | + | }) | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 572 | 659 | // dispatch applies one client frame. A returned error is a protocol or carriage | |
| 573 | 660 | // violation that settles the connection; a refusal the peer can observe is sent | |
| 574 | 661 | // on the wire and returns nil. | |
| 575 | 662 | func (s *serverSession) dispatch(message brokerwire.ClientMessage) error { | |
| 576 | 663 | switch m := message.(type) { | |
| 577 | 664 | case brokerwire.Register: | |
| 665 | + | // Mark registration as begun before touching connection state, so the | |
| 666 | + | // pre-Register guard sharing the accept-time deadline can distinguish a | |
| 667 | + | // Register arriving at the boundary from a silent peer and never settles | |
| 668 | + | // this healthy connection. | |
| 669 | + | s.registered.Store(true) | |
| 578 | 670 | if err := s.conn.Register(); err != nil { | |
| 579 | 671 | return errors.Join(ErrProtocol, err) | |
| 580 | 672 | } | |
| 673 | + | // The client met the accept-time registration budget: stop (and join) | |
| 674 | + | // the pre-Register guard before answering, so this healthy connection is | |
| 675 | + | // no longer bounded by the setup deadline. | |
| 676 | + | s.stopRegistration() | |
| 581 | 677 | return s.send(brokerwire.Registered{Epoch: s.epoch, Connection: s.scope.Connection}) | |
| 582 | 678 | case brokerwire.Subscribe: | |
| 583 | 679 | if !s.scopeMatches(m.Epoch, m.Connection) { | |
| @@ -771,9 +867,23 @@ func (s *serverSession) Close() error { | |||
| 771 | 867 | } | |
| 772 | 868 | ||
| 773 | 869 | // orderlyDisconnect reports whether one terminal cause is a peer disconnect or a | |
| 774 | - | // local close rather than a failure. | |
| 870 | + | // local close rather than a failure. A registration timeout is a peer that | |
| 871 | + | // completed admission and then stayed silent: the session settles, but the | |
| 872 | + | // outcome is the peer's departure, so a listener draining its sessions never | |
| 873 | + | // reports it as its own failure. | |
| 874 | + | // | |
| 875 | + | // A peer that closes abruptly can surface a reset (or a local-close sentinel | |
| 876 | + | // from a concurrently interrupted read) instead of a clean EOF; both are still | |
| 877 | + | // the peer's or this side's ordinary departure, never a sandbox failure. | |
| 775 | 878 | func orderlyDisconnect(err error) bool { | |
| 776 | - | return err == nil || errors.Is(err, io.EOF) || errors.Is(err, ErrConnectionClosed) || errors.Is(err, ErrSessionClosed) | |
| 879 | + | return err == nil || | |
| 880 | + | errors.Is(err, io.EOF) || | |
| 881 | + | errors.Is(err, syscall.ECONNRESET) || | |
| 882 | + | errors.Is(err, net.ErrClosed) || | |
| 883 | + | errors.Is(err, fs.ErrClosed) || | |
| 884 | + | errors.Is(err, ErrConnectionClosed) || | |
| 885 | + | errors.Is(err, ErrSessionClosed) || | |
| 886 | + | errors.Is(err, ErrRegistrationTimeout) | |
| 777 | 887 | } | |
| 778 | 888 | ||
| 779 | 889 | // ConnectionID returns the identity assigned to this connection at accept. | |
M internal/adapters/brokeripc/session_shutdown_test.go +1 -1
| @@ -84,7 +84,7 @@ func TestProtocolViolationWithParkedStreamWriterReleasesSession(t *testing.T) { | |||
| 84 | 84 | require.True(t, ok, "the IPC carriage must be a bounded transport") | |
| 85 | 85 | core := newTestCore(epoch) | |
| 86 | 86 | released := make(chan struct{}, 1) | |
| 87 | - | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() { released <- struct{}{} }) | |
| 87 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() { released <- struct{}{} }, time.Time{}) | |
| 88 | 88 | require.NoError(t, err) | |
| 89 | 89 | ||
| 90 | 90 | // The peer's Register already happened; the duplicate Register below is the | |
M internal/adapters/brokeripc/session_test.go +3 -3
| @@ -30,7 +30,7 @@ func TestResubscribePublishesCurrentSnapshotPerGeneration(t *testing.T) { | |||
| 30 | 30 | for burst := 0; burst < bursts; burst++ { | |
| 31 | 31 | transport := newRecordingTransport() | |
| 32 | 32 | core := newTestCore(epoch) | |
| 33 | - | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}) | |
| 33 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}, time.Time{}) | |
| 34 | 34 | require.NoError(t, err) | |
| 35 | 35 | require.NoError(t, session.conn.Register()) | |
| 36 | 36 | ||
| @@ -71,7 +71,7 @@ func TestSupersededPublisherOwnsItsWake(t *testing.T) { | |||
| 71 | 71 | const epoch = ports.BrokerEpoch(0x52) | |
| 72 | 72 | transport := newRecordingTransport() | |
| 73 | 73 | core := newTestCore(epoch) | |
| 74 | - | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}) | |
| 74 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}, time.Time{}) | |
| 75 | 75 | require.NoError(t, err) | |
| 76 | 76 | require.NoError(t, session.conn.Register()) | |
| 77 | 77 | t.Cleanup(func() { session.shutdown() }) | |
| @@ -103,7 +103,7 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) { | |||
| 103 | 103 | const epoch = ports.BrokerEpoch(0x53) | |
| 104 | 104 | transport := newRecordingTransport() | |
| 105 | 105 | core := newTestCore(epoch) | |
| 106 | - | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}) | |
| 106 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}, time.Time{}) | |
| 107 | 107 | require.NoError(t, err) | |
| 108 | 108 | t.Cleanup(func() { session.shutdown() }) | |
| 109 | 109 | ||
M internal/adapters/daemonmux/pump_test.go +48 -9
| @@ -197,6 +197,43 @@ func requireEngineEventually(t *testing.T, pump *Pump, cond func(*StreamEngine) | |||
| 197 | 197 | require.Eventually(t, func() bool { return cond(pump.Engine()) }, 5*time.Second, time.Millisecond, msgAndArgs...) | |
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | + | // requireStreamTerminalEventually waits until one stream has reached its | |
| 201 | + | // terminal state with Done closed, then returns that settled read. | |
| 202 | + | func requireStreamTerminalEventually(t *testing.T, pump *Pump, id PhysicalStreamID) StreamStatus { | |
| 203 | + | t.Helper() | |
| 204 | + | var status StreamStatus | |
| 205 | + | require.Eventually(t, func() bool { | |
| 206 | + | current, ok := pump.Engine().Status(id) | |
| 207 | + | if !ok { | |
| 208 | + | return false | |
| 209 | + | } | |
| 210 | + | status = current | |
| 211 | + | return current.State == StreamTerminal && channelClosed(current.Done) | |
| 212 | + | }, 5*time.Second, time.Millisecond, "stream %d did not reach terminal", id) | |
| 213 | + | return status | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // requireStreamClosingEventually waits until one undrained stream has settled | |
| 217 | + | // into closing with its preserved chunk still queued and charged and Done still | |
| 218 | + | // open, then returns that settled read. The transition is stable until the | |
| 219 | + | // chunk is drained, so the returned read cannot be a stale intermediate state. | |
| 220 | + | func requireStreamClosingEventually(t *testing.T, pump *Pump, id PhysicalStreamID) StreamStatus { | |
| 221 | + | t.Helper() | |
| 222 | + | var status StreamStatus | |
| 223 | + | require.Eventually(t, func() bool { | |
| 224 | + | current, ok := pump.Engine().Status(id) | |
| 225 | + | if !ok { | |
| 226 | + | return false | |
| 227 | + | } | |
| 228 | + | status = current | |
| 229 | + | return current.State == StreamClosing && | |
| 230 | + | current.QueuedChunks == 1 && | |
| 231 | + | current.QueuedBytes == 1 && | |
| 232 | + | !channelClosed(current.Done) | |
| 233 | + | }, 5*time.Second, time.Millisecond, "stream %d did not settle into closing with a preserved chunk", id) | |
| 234 | + | return status | |
| 235 | + | } | |
| 236 | + | ||
| 200 | 237 | // takeEventually waits until one stream's inbound queue yields a chunk. | |
| 201 | 238 | func takeEventually(t *testing.T, pump *Pump, id PhysicalStreamID) ([]byte, bool) { | |
| 202 | 239 | t.Helper() | |
| @@ -310,19 +347,21 @@ func TestPumpTwoAndHundredStreams(t *testing.T) { | |||
| 310 | 347 | } | |
| 311 | 348 | // The drained stream settles at once; every other stream is closing | |
| 312 | 349 | // and still holds its one unread, charged chunk until it is drained. | |
| 313 | - | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == count-1 }) | |
| 314 | - | require.Equal(t, count-1, pump.Engine().AggregateBytes()) | |
| 315 | - | require.False(t, pump.Engine().Closed()) | |
| 316 | - | require.False(t, channelClosed(pump.Done())) | |
| 317 | - | terminal := mustStatus(t, pump.Engine(), ids[0]) | |
| 350 | + | // The reader applies the closes asynchronously and in order, so each | |
| 351 | + | // stream is observed on its own eventual transition: a global Live | |
| 352 | + | // precondition would pass as soon as the drained stream settles, | |
| 353 | + | // before a later stream has applied its own close. | |
| 354 | + | terminal := requireStreamTerminalEventually(t, pump, ids[0]) | |
| 318 | 355 | require.Equal(t, StreamTerminal, terminal.State) | |
| 319 | - | require.True(t, channelClosed(terminal.Done)) | |
| 320 | 356 | for _, id := range ids[1:] { | |
| 321 | - | status := mustStatus(t, pump.Engine(), id) | |
| 322 | - | require.Equal(t, StreamClosing, status.State) | |
| 323 | - | require.False(t, channelClosed(status.Done)) | |
| 357 | + | status := requireStreamClosingEventually(t, pump, id) | |
| 324 | 358 | require.Equal(t, 1, status.QueuedChunks) | |
| 359 | + | require.Equal(t, 1, status.QueuedBytes) | |
| 325 | 360 | } | |
| 361 | + | require.Equal(t, count-1, pump.Engine().Live()) | |
| 362 | + | require.Equal(t, count-1, pump.Engine().AggregateBytes()) | |
| 363 | + | require.False(t, pump.Engine().Closed()) | |
| 364 | + | require.False(t, channelClosed(pump.Done())) | |
| 326 | 365 | ||
| 327 | 366 | // Draining every preserved chunk releases its bytes and terminalizes | |
| 328 | 367 | // the stream; the physical connection stays open. | |
M internal/adapters/lifecycle/flock.go +52 -2
| @@ -40,9 +40,9 @@ func TryAcquire(runtimeDir string) (*Owner, error) { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | path := Path(runtimeDir) | |
| 43 | - | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) | |
| 43 | + | file, err := openLockFile(path) | |
| 44 | 44 | if err != nil { | |
| 45 | - | return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err) | |
| 45 | + | return nil, err | |
| 46 | 46 | } | |
| 47 | 47 | if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |
| 48 | 48 | closeErr := file.Close() | |
| @@ -54,6 +54,56 @@ func TryAcquire(runtimeDir string) (*Owner, error) { | |||
| 54 | 54 | return &Owner{file: file, path: path}, nil | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | + | // openLockFile opens the lock inode without following a symlink and refuses to | |
| 58 | + | // trust anything that is not a regular, owner-only file. | |
| 59 | + | // | |
| 60 | + | // O_NOFOLLOW makes a symlinked lock path fail closed with ELOOP instead of | |
| 61 | + | // flocking the link target, and the fstat check fails closed on a directory, | |
| 62 | + | // FIFO, device, or a file with group/other access, so a path another user could | |
| 63 | + | // plant can never become the lifecycle lock. Only group and other access is | |
| 64 | + | // refused: an owner-only file is trusted regardless of the exact owner bit | |
| 65 | + | // pattern, so a lock created under a restrictive umask (or normalized by a | |
| 66 | + | // different tool) is never a permanent, unrecoverable startup failure. A newly | |
| 67 | + | // created lock is additionally chmodded to 0600 so a permissive umask can never | |
| 68 | + | // leave group/other bits behind. The failure is reported as an ordinary error, | |
| 69 | + | // never ErrBusy. | |
| 70 | + | func openLockFile(path string) (*os.File, error) { | |
| 71 | + | fd, err := syscall.Open(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0o600) | |
| 72 | + | created := err == nil | |
| 73 | + | if err != nil { | |
| 74 | + | if !errors.Is(err, syscall.EEXIST) { | |
| 75 | + | if errors.Is(err, syscall.ELOOP) { | |
| 76 | + | return nil, fmt.Errorf("lifecycle: %s is a symlink", path) | |
| 77 | + | } | |
| 78 | + | return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err) | |
| 79 | + | } | |
| 80 | + | fd, err = syscall.Open(path, syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0) | |
| 81 | + | if err != nil { | |
| 82 | + | if errors.Is(err, syscall.ELOOP) { | |
| 83 | + | return nil, fmt.Errorf("lifecycle: %s is a symlink", path) | |
| 84 | + | } | |
| 85 | + | return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | file := os.NewFile(uintptr(fd), path) | |
| 89 | + | if created { | |
| 90 | + | if err := syscall.Fchmod(fd, 0o600); err != nil { | |
| 91 | + | return nil, errors.Join(fmt.Errorf("lifecycle: chmod %s: %w", path, err), file.Close()) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | info, err := file.Stat() | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, errors.Join(fmt.Errorf("lifecycle: stat %s: %w", path, err), file.Close()) | |
| 97 | + | } | |
| 98 | + | if !info.Mode().IsRegular() { | |
| 99 | + | return nil, errors.Join(fmt.Errorf("lifecycle: %s is not a regular file", path), file.Close()) | |
| 100 | + | } | |
| 101 | + | if info.Mode().Perm()&0o077 != 0 { | |
| 102 | + | return nil, errors.Join(fmt.Errorf("lifecycle: %s has permissions %04o, want owner-only", path, info.Mode().Perm()), file.Close()) | |
| 103 | + | } | |
| 104 | + | return file, nil | |
| 105 | + | } | |
| 106 | + | ||
| 57 | 107 | // Acquire waits until lifecycle ownership is available or ctx is cancelled. | |
| 58 | 108 | // Only lock contention is retried; filesystem and lock errors fail closed. | |
| 59 | 109 | func Acquire(ctx context.Context, runtimeDir string, retry time.Duration) (*Owner, error) { | |
M internal/adapters/lifecycle/flock_test.go +87 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "os" | |
| 12 | 12 | "os/exec" | |
| 13 | 13 | "path/filepath" | |
| 14 | + | "syscall" | |
| 14 | 15 | "testing" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| @@ -36,6 +37,92 @@ func TestLifecycleLock(t *testing.T) { | |||
| 36 | 37 | t.Run("process contention and reacquire", testLifecycleLockProcessContentionAndReacquire) | |
| 37 | 38 | t.Run("acquire cancellation", testLifecycleLockAcquireCancellation) | |
| 38 | 39 | t.Run("unavailable runtime directory", testLifecycleLockUnavailableRuntimeDirectory) | |
| 40 | + | t.Run("lock file is owner-only regular", testLifecycleLockIsOwnerOnlyRegular) | |
| 41 | + | t.Run("owner-only variant is accepted", testLifecycleLockOwnerOnlyVariantAccepted) | |
| 42 | + | t.Run("restrictive umask normalizes to owner-only", testLifecycleLockRestrictiveUmaskNormalizes) | |
| 43 | + | t.Run("symlinked lock path fails closed", testLifecycleLockSymlinkFailsClosed) | |
| 44 | + | t.Run("nonregular lock path fails closed", testLifecycleLockNonRegularFailsClosed) | |
| 45 | + | t.Run("loose permissions fail closed", testLifecycleLockLoosePermissionsFailClosed) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func testLifecycleLockIsOwnerOnlyRegular(t *testing.T) { | |
| 49 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 50 | + | owner, err := TryAcquire(runtimeDir) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | defer func() { require.NoError(t, owner.Release()) }() | |
| 53 | + | ||
| 54 | + | info, err := os.Lstat(Path(runtimeDir)) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.True(t, info.Mode().IsRegular(), "the lock must be a regular file") | |
| 57 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "the lock must be owner-only") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // testLifecycleLockOwnerOnlyVariantAccepted proves a pre-existing owner-only | |
| 61 | + | // lock with a different owner bit pattern is trusted, so an installation whose | |
| 62 | + | // lock was created under a variant umask can still start the daemon. | |
| 63 | + | func testLifecycleLockOwnerOnlyVariantAccepted(t *testing.T) { | |
| 64 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 65 | + | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 66 | + | require.NoError(t, os.WriteFile(Path(runtimeDir), nil, 0o600)) | |
| 67 | + | require.NoError(t, os.Chmod(Path(runtimeDir), 0o700)) | |
| 68 | + | ||
| 69 | + | owner, err := TryAcquire(runtimeDir) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | require.NoError(t, owner.Release()) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // testLifecycleLockRestrictiveUmaskNormalizes proves a newly created lock is | |
| 75 | + | // chmodded to 0600 even when the process umask would have stripped owner bits, | |
| 76 | + | // so the lock is always exactly owner-only after creation. | |
| 77 | + | func testLifecycleLockRestrictiveUmaskNormalizes(t *testing.T) { | |
| 78 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 79 | + | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 80 | + | previous := syscall.Umask(0o400) | |
| 81 | + | defer syscall.Umask(previous) | |
| 82 | + | ||
| 83 | + | owner, err := TryAcquire(runtimeDir) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | defer func() { require.NoError(t, owner.Release()) }() | |
| 86 | + | ||
| 87 | + | info, err := os.Lstat(Path(runtimeDir)) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "a newly created lock must be normalized to owner-only") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func testLifecycleLockSymlinkFailsClosed(t *testing.T) { | |
| 93 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 94 | + | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 95 | + | target := filepath.Join(t.TempDir(), "target.lock") | |
| 96 | + | require.NoError(t, os.WriteFile(target, nil, 0o600)) | |
| 97 | + | require.NoError(t, os.Symlink(target, Path(runtimeDir))) | |
| 98 | + | ||
| 99 | + | _, err := TryAcquire(runtimeDir) | |
| 100 | + | require.Error(t, err) | |
| 101 | + | require.False(t, errors.Is(err, ErrBusy), "a symlinked lock path is an error, not contention") | |
| 102 | + | require.Contains(t, err.Error(), "symlink") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func testLifecycleLockNonRegularFailsClosed(t *testing.T) { | |
| 106 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 107 | + | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 108 | + | require.NoError(t, syscall.Mkfifo(Path(runtimeDir), 0o600)) | |
| 109 | + | ||
| 110 | + | _, err := TryAcquire(runtimeDir) | |
| 111 | + | require.Error(t, err) | |
| 112 | + | require.False(t, errors.Is(err, ErrBusy), "a non-regular lock path is an error, not contention") | |
| 113 | + | require.Contains(t, err.Error(), "not a regular file") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func testLifecycleLockLoosePermissionsFailClosed(t *testing.T) { | |
| 117 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 118 | + | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 119 | + | require.NoError(t, os.WriteFile(Path(runtimeDir), nil, 0o600)) | |
| 120 | + | require.NoError(t, os.Chmod(Path(runtimeDir), 0o644)) | |
| 121 | + | ||
| 122 | + | _, err := TryAcquire(runtimeDir) | |
| 123 | + | require.Error(t, err) | |
| 124 | + | require.False(t, errors.Is(err, ErrBusy), "a loose lock file is an error, not contention") | |
| 125 | + | require.Contains(t, err.Error(), "permissions") | |
| 39 | 126 | } | |
| 40 | 127 | ||
| 41 | 128 | func testLifecycleLockProcessContentionAndReacquire(t *testing.T) { | |
M internal/adapters/sshstdio/mux.go +37 -0
| @@ -45,6 +45,7 @@ import ( | |||
| 45 | 45 | "os" | |
| 46 | 46 | "os/exec" | |
| 47 | 47 | "strings" | |
| 48 | + | "sync" | |
| 48 | 49 | "unicode" | |
| 49 | 50 | "unicode/utf8" | |
| 50 | 51 | ||
| @@ -99,6 +100,34 @@ func DialMuxContext(ctx context.Context, spec CommandSpec, logger *slog.Logger, | |||
| 99 | 100 | return dialMuxContext(ctx, spec, logger, opts...) | |
| 100 | 101 | } | |
| 101 | 102 | ||
| 103 | + | // NewDiagnosticSink returns a bounded stderr sink that captures at most limit | |
| 104 | + | // bytes and reports a sanitized, truncated diagnostic: terminal escapes and | |
| 105 | + | // control bytes are stripped, so a hostile peer can neither grow the capture | |
| 106 | + | // without bound nor inject control bytes into a log. It is the same bounded, | |
| 107 | + | // sanitized capture the mux carriage uses for its own child diagnostics, shared | |
| 108 | + | // with callers (such as the broker's authenticated QUIC bootstrap) that start | |
| 109 | + | // an ssh child of their own and must never surface raw remote stderr. | |
| 110 | + | func NewDiagnosticSink(limit int) *DiagnosticSink { | |
| 111 | + | if limit <= 0 { | |
| 112 | + | limit = muxStderrLimit | |
| 113 | + | } | |
| 114 | + | return &DiagnosticSink{capped: newCappedDiagnostic(limit)} | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // DiagnosticSink is a bounded, sanitizing, concurrent-safe io.Writer for one | |
| 118 | + | // child's stderr. It is safe for the os/exec stderr copy goroutine to write | |
| 119 | + | // while another goroutine reads String: the read is internally synchronized and | |
| 120 | + | // observes a bounded, sanitized snapshot rather than a partial capture. | |
| 121 | + | type DiagnosticSink struct{ capped *cappedDiagnostic } | |
| 122 | + | ||
| 123 | + | // Write captures at most the sink's ceiling and records that the rest was | |
| 124 | + | // dropped, so the child is never blocked writing diagnostics. | |
| 125 | + | func (s *DiagnosticSink) Write(p []byte) (int, error) { return s.capped.Write(p) } | |
| 126 | + | ||
| 127 | + | // String returns the captured text sanitized and truncated to the sink's | |
| 128 | + | // ceiling, so every reader observes a bounded, control-byte-free value. | |
| 129 | + | func (s *DiagnosticSink) String() string { return s.capped.String() } | |
| 130 | + | ||
| 102 | 131 | // NewStdioTransport returns the helper half of one SSH stdio carriage: the | |
| 103 | 132 | // current process' own stdin and stdout framed as the raw bounded transport the | |
| 104 | 133 | // daemonmux daemon path consumes. It is the counterpart of DialMuxContext, so a | |
| @@ -157,6 +186,10 @@ func dialMuxContext(ctx context.Context, spec CommandSpec, logger *slog.Logger, | |||
| 157 | 186 | // non-printable bytes are stripped, so a hostile peer can neither grow the | |
| 158 | 187 | // capture without bound nor inject control bytes into a log. | |
| 159 | 188 | type cappedDiagnostic struct { | |
| 189 | + | // mu makes Write and String safe to call concurrently: an os/exec stderr | |
| 190 | + | // copy goroutine may still be writing while a reader renders the diagnostic, | |
| 191 | + | // and the capture must never be observed partially written. | |
| 192 | + | mu sync.Mutex | |
| 160 | 193 | buf bytes.Buffer | |
| 161 | 194 | limit int | |
| 162 | 195 | overflow bool | |
| @@ -169,6 +202,8 @@ func newCappedDiagnostic(limit int) *cappedDiagnostic { | |||
| 169 | 202 | // Write captures at most the configured ceiling and records that the rest was | |
| 170 | 203 | // dropped, so the child is never blocked writing diagnostics. | |
| 171 | 204 | func (d *cappedDiagnostic) Write(p []byte) (int, error) { | |
| 205 | + | d.mu.Lock() | |
| 206 | + | defer d.mu.Unlock() | |
| 172 | 207 | remaining := d.limit - d.buf.Len() | |
| 173 | 208 | if remaining <= 0 { | |
| 174 | 209 | d.overflow = true | |
| @@ -185,6 +220,8 @@ func (d *cappedDiagnostic) Write(p []byte) (int, error) { | |||
| 185 | 220 | // String returns the captured text sanitized and truncated to the diagnostic | |
| 186 | 221 | // ceiling, so every reader observes a bounded, control-byte-free value. | |
| 187 | 222 | func (d *cappedDiagnostic) String() string { | |
| 223 | + | d.mu.Lock() | |
| 224 | + | defer d.mu.Unlock() | |
| 188 | 225 | return sanitizeDiagnostic(d.buf.String()) | |
| 189 | 226 | } | |
| 190 | 227 | ||
A internal/app/broker_offline.go +376 -0
| @@ -0,0 +1,376 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "log/slog" | |
| 11 | + | "os/signal" | |
| 12 | + | "strings" | |
| 13 | + | "syscall" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/brokerstore" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 21 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 22 | + | "github.com/bnema/vev/internal/logging" | |
| 23 | + | "github.com/bnema/vev/internal/platform" | |
| 24 | + | "github.com/bnema/vev/internal/ports" | |
| 25 | + | "github.com/bnema/vev/internal/usecase/broker" | |
| 26 | + | "github.com/bnema/vev/pkg/safedir" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // Offline broker foreground composition (Plan 001 P3.4 slice C). | |
| 30 | + | // | |
| 31 | + | // `_broker-serve` is a hidden, isolated sandbox command. It takes one strict | |
| 32 | + | // offline root, derives only sandbox runtime, state, and log paths from it, and | |
| 33 | + | // composes the P3.4 broker core and P3.3 local IPC listener over the immutable | |
| 34 | + | // config marker. It never reads or writes production runtime, state, or config | |
| 35 | + | // and never changes an ordinary command, path, or factory. | |
| 36 | + | // | |
| 37 | + | // Nothing here spawns a detached process, connects or spawns a daemon, reports | |
| 38 | + | // status, or reaches SSH or QUIC: those remain later slices. The composed | |
| 39 | + | // process is foreground: it owns one lifetime lock for its whole run, serves | |
| 40 | + | // until a signal or its parent context ends it, or until the broker idles out, | |
| 41 | + | // and then shuts down in a fixed order. | |
| 42 | + | ||
| 43 | + | const brokerServeCommand = "_broker-serve" | |
| 44 | + | ||
| 45 | + | // brokerServePoolLimits bound the offline broker pool. They are explicit, not | |
| 46 | + | // derived from the environment, and small enough that a stalled peer is | |
| 47 | + | // refused rather than absorbed. | |
| 48 | + | var brokerServePoolLimits = broker.PoolLimits{ | |
| 49 | + | Physical: 64, | |
| 50 | + | Clients: brokeripc.DefaultMaxClients, | |
| 51 | + | Streams: 256, | |
| 52 | + | StreamsPerClient: 64, | |
| 53 | + | Idle: 5 * time.Minute, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // brokerServeOptions is the parsed hidden invocation. | |
| 57 | + | type brokerServeOptions struct { | |
| 58 | + | offlineRoot string | |
| 59 | + | // idleGrace is zero when the operator did not override it; the supervisor | |
| 60 | + | // then applies its own 5m default. | |
| 61 | + | idleGrace time.Duration | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // parseBrokerServeArgs strictly parses `_broker-serve --offline-root ABS | |
| 65 | + | // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a | |
| 66 | + | // missing value, and a non-positive or unparsable duration are refused; the | |
| 67 | + | // path itself is validated later against the filesystem. | |
| 68 | + | func parseBrokerServeArgs(args []string) (command, error) { | |
| 69 | + | if len(args) == 0 { | |
| 70 | + | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |
| 71 | + | } | |
| 72 | + | options := brokerServeOptions{} | |
| 73 | + | var rootSet, graceSet bool | |
| 74 | + | for index := 0; index < len(args); { | |
| 75 | + | switch args[index] { | |
| 76 | + | case "--offline-root": | |
| 77 | + | if rootSet { | |
| 78 | + | return command{}, usagef("`%s` received duplicate --offline-root", brokerServeCommand) | |
| 79 | + | } | |
| 80 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 81 | + | return command{}, usagef("`--offline-root` requires a path") | |
| 82 | + | } | |
| 83 | + | options.offlineRoot = args[index+1] | |
| 84 | + | rootSet = true | |
| 85 | + | index += 2 | |
| 86 | + | case "--idle-grace": | |
| 87 | + | if graceSet { | |
| 88 | + | return command{}, usagef("`%s` received duplicate --idle-grace", brokerServeCommand) | |
| 89 | + | } | |
| 90 | + | if index+1 >= len(args) { | |
| 91 | + | return command{}, usagef("`--idle-grace` requires a duration") | |
| 92 | + | } | |
| 93 | + | grace, err := time.ParseDuration(args[index+1]) | |
| 94 | + | if err != nil { | |
| 95 | + | return command{}, usagef("`--idle-grace` %q is not a duration", args[index+1]) | |
| 96 | + | } | |
| 97 | + | if grace <= 0 { | |
| 98 | + | return command{}, usagef("`--idle-grace` must be positive") | |
| 99 | + | } | |
| 100 | + | options.idleGrace = grace | |
| 101 | + | graceSet = true | |
| 102 | + | index += 2 | |
| 103 | + | default: | |
| 104 | + | if strings.HasPrefix(args[index], "-") { | |
| 105 | + | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerServeCommand) | |
| 106 | + | } | |
| 107 | + | return command{}, usagef("`%s` does not accept positional arguments", brokerServeCommand) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | if !rootSet { | |
| 111 | + | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |
| 112 | + | } | |
| 113 | + | return command{kind: kindBrokerServe, brokerServe: options}, nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // brokerServeDeps are the composition seams. Production supplies the real | |
| 117 | + | // adapters; tests override the clock, the owner lock, the logger, and the | |
| 118 | + | // readiness hook so idle shutdown and cleanup are deterministic. | |
| 119 | + | type brokerServeDeps struct { | |
| 120 | + | clock func() ports.Clock | |
| 121 | + | acquire func(string) (lifecycleOwnership, error) | |
| 122 | + | newLogger func(string) (*slog.Logger, io.Closer, error) | |
| 123 | + | newEpoch func() (ports.BrokerEpoch, error) | |
| 124 | + | listen func(string, ports.BrokerEpoch, ports.BrokerAuthority, brokeripc.Config) (ports.BrokerListener, error) | |
| 125 | + | onReady func(socketPath string) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // defaultBrokerServeDeps builds the production seams. | |
| 129 | + | func defaultBrokerServeDeps() brokerServeDeps { | |
| 130 | + | return brokerServeDeps{ | |
| 131 | + | clock: func() ports.Clock { return clock.New() }, | |
| 132 | + | acquire: func(runtimeDir string) (lifecycleOwnership, error) { | |
| 133 | + | return lifecycle.TryAcquire(runtimeDir) | |
| 134 | + | }, | |
| 135 | + | newLogger: sandboxLogging, | |
| 136 | + | newEpoch: newBrokerEpoch, | |
| 137 | + | listen: func(path string, epoch ports.BrokerEpoch, authority ports.BrokerAuthority, cfg brokeripc.Config) (ports.BrokerListener, error) { | |
| 138 | + | return brokeripc.Listen(path, epoch, authority, cfg) | |
| 139 | + | }, | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // runBrokerServeCommand enters the hidden foreground process: it installs | |
| 144 | + | // SIGINT/SIGTERM cancellation over the parent context and runs the sandbox. | |
| 145 | + | func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) error { | |
| 146 | + | ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) | |
| 147 | + | defer stop() | |
| 148 | + | return runBrokerServe(ctx, options, defaultBrokerServeDeps()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // sandboxLogging configures broker logging inside the sandbox log directory. | |
| 152 | + | // It never consults the production state directory. | |
| 153 | + | func sandboxLogging(dir string) (*slog.Logger, io.Closer, error) { | |
| 154 | + | return logging.Setup(logging.Config{ | |
| 155 | + | Dir: dir, | |
| 156 | + | Component: logging.Daemon, | |
| 157 | + | Level: logging.EnvLevel(), | |
| 158 | + | MaxBytes: logging.DefaultMaxBytes, | |
| 159 | + | }) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // newBrokerEpoch samples one cryptographically random non-zero broker epoch. | |
| 163 | + | func newBrokerEpoch() (ports.BrokerEpoch, error) { | |
| 164 | + | var raw [8]byte | |
| 165 | + | if _, err := rand.Read(raw[:]); err != nil { | |
| 166 | + | return 0, fmt.Errorf("vev: sample broker epoch: %w", err) | |
| 167 | + | } | |
| 168 | + | epoch := ports.BrokerEpoch(binary.BigEndian.Uint64(raw[:])) | |
| 169 | + | if epoch == 0 { | |
| 170 | + | epoch = 1 | |
| 171 | + | } | |
| 172 | + | return epoch, nil | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // offlineLayout resolves the hidden offline sandbox layout for one operator | |
| 176 | + | // root, refusing any root that overlaps the production runtime or state paths. | |
| 177 | + | func offlineLayout(offlineRoot string) (brokerconfig.Layout, error) { | |
| 178 | + | return brokerconfig.ResolveLayout(offlineRoot, []string{ipc.SocketDir(), platform.StateDir()}) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // effectiveIdleGrace resolves the sandbox idle grace from an explicit flag and | |
| 182 | + | // the provisioned configuration. The provisioned value is the single | |
| 183 | + | // deterministic source: a status probe cannot read a running broker's effective | |
| 184 | + | // grace through the broker IPC protocol, so an explicit grace that conflicts | |
| 185 | + | // with a provisioned one is diagnosed here rather than silently applied. | |
| 186 | + | func effectiveIdleGrace(config *brokerconfig.Config, explicit time.Duration) (time.Duration, error) { | |
| 187 | + | provisioned, ok := config.IdleGrace() | |
| 188 | + | switch { | |
| 189 | + | case explicit <= 0: | |
| 190 | + | if ok { | |
| 191 | + | return provisioned, nil | |
| 192 | + | } | |
| 193 | + | // Zero lets the supervisor apply its own built-in default. | |
| 194 | + | return 0, nil | |
| 195 | + | case ok && explicit != provisioned: | |
| 196 | + | return 0, usagef("`--idle-grace` %s conflicts with the provisioned idle grace %s", explicit, provisioned) | |
| 197 | + | default: | |
| 198 | + | return explicit, nil | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // runBrokerServe runs one isolated offline broker in the foreground until ctx | |
| 203 | + | // is cancelled or the broker idles out, then shuts down in the fixed order | |
| 204 | + | // listener -> pool -> registry -> store -> log -> owner lock. | |
| 205 | + | // | |
| 206 | + | // Every resource is acquired in dependency order and released in reverse on | |
| 207 | + | // any failure, so a partial setup never leaks a store lock, an owner lock, a | |
| 208 | + | // bound socket, or a supervisor goroutine. The temporary startup lease pins the | |
| 209 | + | // supervisor open from construction until the listener and its accept drain are | |
| 210 | + | // ready, so the idle timer can never fire during setup. | |
| 211 | + | func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) { | |
| 212 | + | layout, err := offlineLayout(options.offlineRoot) | |
| 213 | + | if err != nil { | |
| 214 | + | return err | |
| 215 | + | } | |
| 216 | + | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | |
| 217 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 218 | + | return fmt.Errorf("vev: secure broker sandbox directory %s: %w", dir, err) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | // ResolveLayout validates the root textually and stops at the first | |
| 222 | + | // component that does not exist yet; now that EnsurePrivate has created the | |
| 223 | + | // sandbox paths, re-walk every component so a component swapped for a | |
| 224 | + | // symlink in that window fails closed instead of being followed. | |
| 225 | + | if err := layout.VerifyCreated(); err != nil { | |
| 226 | + | return fmt.Errorf("vev: verify broker sandbox paths: %w", err) | |
| 227 | + | } | |
| 228 | + | config, err := brokerconfig.Load(layout) | |
| 229 | + | if err != nil { | |
| 230 | + | return err | |
| 231 | + | } | |
| 232 | + | idleGrace, err := effectiveIdleGrace(config, options.idleGrace) | |
| 233 | + | if err != nil { | |
| 234 | + | return err | |
| 235 | + | } | |
| 236 | + | owner, err := deps.acquire(layout.Runtime) | |
| 237 | + | if err != nil { | |
| 238 | + | return fmt.Errorf("vev: broker sandbox ownership: %w", err) | |
| 239 | + | } | |
| 240 | + | defer func() { retErr = errors.Join(retErr, owner.Release()) }() | |
| 241 | + | ||
| 242 | + | log, logCloser, err := deps.newLogger(layout.Log) | |
| 243 | + | if err != nil { | |
| 244 | + | return err | |
| 245 | + | } | |
| 246 | + | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |
| 247 | + | ||
| 248 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: layout.State}) | |
| 249 | + | if err != nil { | |
| 250 | + | return fmt.Errorf("vev: open broker sandbox store: %w", err) | |
| 251 | + | } | |
| 252 | + | defer func() { retErr = errors.Join(retErr, store.Close()) }() | |
| 253 | + | ||
| 254 | + | epoch, err := deps.newEpoch() | |
| 255 | + | if err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | // One clock is shared by the supervisor, registry, and pool so an injected | |
| 259 | + | // clock (for example a manual test clock) governs every timer in the sandbox. | |
| 260 | + | clk := deps.clock() | |
| 261 | + | supervisor, err := broker.NewSupervisor(clk, broker.SupervisorConfig{IdleGrace: idleGrace}) | |
| 262 | + | if err != nil { | |
| 263 | + | return err | |
| 264 | + | } | |
| 265 | + | // The supervisor owns the ordered drain; its deferred Close is a safety net | |
| 266 | + | // for every error path below. A successful run closes it explicitly, and the | |
| 267 | + | // flag suppresses the deferred duplicate join so one shutdown error is never | |
| 268 | + | // reported twice. | |
| 269 | + | supervisorClosed := false | |
| 270 | + | defer func() { | |
| 271 | + | if !supervisorClosed { | |
| 272 | + | retErr = errors.Join(retErr, supervisor.Close()) | |
| 273 | + | } | |
| 274 | + | }() | |
| 275 | + | ||
| 276 | + | // The startup lease pins the broker open until the listener and its accept | |
| 277 | + | // drain are ready. It is released exactly once, and again on any error path. | |
| 278 | + | startupLease, err := supervisor.AdmitOperation() | |
| 279 | + | if err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | defer startupLease.Release() | |
| 283 | + | ||
| 284 | + | registry, err := broker.NewRegistryWithConfig(epoch, store, nil, clk, log, broker.RegistryConfig{ObservationDisabled: true}) | |
| 285 | + | if err != nil { | |
| 286 | + | return err | |
| 287 | + | } | |
| 288 | + | // Registration order is shutdown order reversed: registering the registry | |
| 289 | + | // first, then the pool, then the listener drains them listener -> pool -> | |
| 290 | + | // registry. | |
| 291 | + | if err := supervisor.RegisterRunner("registry", registry); err != nil { | |
| 292 | + | return err | |
| 293 | + | } | |
| 294 | + | connector, err := brokerMuxConnector(config, log) | |
| 295 | + | if err != nil { | |
| 296 | + | return err | |
| 297 | + | } | |
| 298 | + | pool, err := broker.NewPool(epoch, config.Resolver(), connector, clk, brokerServePoolLimits) | |
| 299 | + | if err != nil { | |
| 300 | + | return err | |
| 301 | + | } | |
| 302 | + | if err := supervisor.RegisterCloseable("pool", pool); err != nil { | |
| 303 | + | return err | |
| 304 | + | } | |
| 305 | + | authority, err := broker.NewAuthority(epoch, registry, pool, supervisor) | |
| 306 | + | if err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 310 | + | listener, err := deps.listen(socketPath, epoch, authority, brokeripc.Config{}) | |
| 311 | + | if err != nil { | |
| 312 | + | return fmt.Errorf("vev: listen on broker sandbox socket: %w", err) | |
| 313 | + | } | |
| 314 | + | if err := supervisor.RegisterCloseable("listener", listener); err != nil { | |
| 315 | + | _ = listener.Close() | |
| 316 | + | return err | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | started := make(chan struct{}) | |
| 320 | + | drained := make(chan struct{}) | |
| 321 | + | failed := make(chan struct{}) | |
| 322 | + | go drainBrokerAccept(listener, log, started, drained, failed) | |
| 323 | + | <-started | |
| 324 | + | startupLease.Release() | |
| 325 | + | log.Info("broker_offline_ready", "socket", socketPath, "endpoints", len(config.Endpoints())) | |
| 326 | + | if deps.onReady != nil { | |
| 327 | + | deps.onReady(socketPath) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | select { | |
| 331 | + | case <-ctx.Done(): | |
| 332 | + | case <-supervisor.Done(): | |
| 333 | + | case <-failed: | |
| 334 | + | // An unexpected terminal accept failure leaves the socket bound but | |
| 335 | + | // unable to admit work. Commit shutdown so the sandbox tears down in the | |
| 336 | + | // fixed order instead of wedging as a bound, unserving broker. | |
| 337 | + | } | |
| 338 | + | // Closing the supervisor commits shutdown, cancels the registry run, and | |
| 339 | + | // drains the listener, pool, and registry in order. The store, log, and | |
| 340 | + | // owner lock are released by the deferred cleanup afterwards. | |
| 341 | + | closeErr := supervisor.Close() | |
| 342 | + | supervisorClosed = true | |
| 343 | + | <-drained | |
| 344 | + | log.Info("broker_offline_stopped") | |
| 345 | + | return closeErr | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // drainBrokerAccept continuously drains admitted broker sessions so the | |
| 349 | + | // listener's bounded admission FIFO never stalls. | |
| 350 | + | // | |
| 351 | + | // A drained session is the listener's own connection-scoped service: closing it | |
| 352 | + | // here would end a live client, so it is deliberately left open and owned by | |
| 353 | + | // the listener. The drain stops when the listener closes or reports a terminal | |
| 354 | + | // accept failure. A listener close is the expected, orderly end of the drain and | |
| 355 | + | // is never logged; any other terminal accept failure is unexpected: it is logged | |
| 356 | + | // as an error and reported on failed so the caller commits shutdown instead of | |
| 357 | + | // leaving a bound broker that can no longer admit work. | |
| 358 | + | func drainBrokerAccept(listener ports.BrokerListener, log *slog.Logger, started chan<- struct{}, drained chan<- struct{}, failed chan<- struct{}) { | |
| 359 | + | defer close(drained) | |
| 360 | + | close(started) | |
| 361 | + | for { | |
| 362 | + | session, err := listener.Accept() | |
| 363 | + | if err != nil { | |
| 364 | + | if errors.Is(err, brokeripc.ErrListenerClosed) { | |
| 365 | + | return | |
| 366 | + | } | |
| 367 | + | log.Error("broker_offline_accept_failed", "error", err) | |
| 368 | + | close(failed) | |
| 369 | + | return | |
| 370 | + | } | |
| 371 | + | if session == nil { | |
| 372 | + | continue | |
| 373 | + | } | |
| 374 | + | log.Debug("broker_offline_connection", "connection", fmt.Sprintf("%x", session.ConnectionID())) | |
| 375 | + | } | |
| 376 | + | } | |
A internal/app/broker_offline_mux.go +541 -0
| @@ -0,0 +1,541 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "log/slog" | |
| 9 | + | "net" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "syscall" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/quic" | |
| 21 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 22 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Broker mux remote helper composition (Plan 001 P3.4 slice E). | |
| 26 | + | // | |
| 27 | + | // The offline broker reaches a remote daemon's private daemonmux carriage | |
| 28 | + | // through one of three explicitly provisioned routes: a local Unix mux socket, | |
| 29 | + | // an SSH stdio child that runs a hidden mux helper, or an SSH-authenticated | |
| 30 | + | // QUIC bootstrap that pins a freshly minted ephemeral certificate. This file | |
| 31 | + | // owns both halves. | |
| 32 | + | // | |
| 33 | + | // The broker side is a transport-selecting daemonmux.EndpointConnector. It | |
| 34 | + | // receives only the opaque address the immutable resolver published, looks the | |
| 35 | + | // route up in the configuration it was composed with, and dials that route. It | |
| 36 | + | // never authenticates the carriage itself: the daemonmux physical preamble | |
| 37 | + | // verifies the daemon identity, incarnation, and policy the resolver already | |
| 38 | + | // pinned. One caller-supplied setup context bounds bootstrap, pinned dial, and | |
| 39 | + | // the daemonmux handshake together; a successful physical connection detaches | |
| 40 | + | // from that context and is owned by the pool. | |
| 41 | + | // | |
| 42 | + | // The remote side is three hidden helpers, excluded from public help: | |
| 43 | + | // | |
| 44 | + | // - `_broker-mux-stdio --offline-root ABS` bridges its own stdio to the | |
| 45 | + | // single provisioned private Unix daemonmux carriage. | |
| 46 | + | // - `_broker-mux-quic-bootstrap --offline-root ABS` starts one detached | |
| 47 | + | // `_broker-mux-quic-proxy`, forwards its single readiness line, and exits. | |
| 48 | + | // - `_broker-mux-quic-proxy --offline-root ABS` mints one ephemeral QUIC | |
| 49 | + | // server, admits exactly one authenticated carriage, and bridges it to the | |
| 50 | + | // same private Unix daemonmux carriage. | |
| 51 | + | // | |
| 52 | + | // A helper only ever bridges to the configured private Unix daemonmux | |
| 53 | + | // endpoint. It never starts a broker, an observer, or an ordinary daemon, never | |
| 54 | + | // dials the production daemon socket, and never fabricates a daemon | |
| 55 | + | // incarnation: the daemon on the other side of the carriage owns that value. | |
| 56 | + | // The one-time QUIC token and nonce are minted per physical connection, travel | |
| 57 | + | // only inside the SSH channel and the encrypted stream, and never reach an | |
| 58 | + | // address, a log, or an error. SSH host-key verification, noninteractive | |
| 59 | + | // authentication, and the absence of a remote PTY are preserved: the helpers | |
| 60 | + | // only add explicit trust inputs that narrow verification, never disable it. | |
| 61 | + | ||
| 62 | + | const ( | |
| 63 | + | // brokerMuxStdioCommand is the hidden remote SSH stdio mux bridge. | |
| 64 | + | brokerMuxStdioCommand = "_broker-mux-stdio" | |
| 65 | + | // brokerMuxQUICBootstrapCommand is the hidden remote QUIC mux bootstrap. | |
| 66 | + | brokerMuxQUICBootstrapCommand = "_broker-mux-quic-bootstrap" | |
| 67 | + | // brokerMuxQUICProxyCommand is the hidden detached remote QUIC mux proxy. | |
| 68 | + | brokerMuxQUICProxyCommand = "_broker-mux-quic-proxy" | |
| 69 | + | ) | |
| 70 | + | ||
| 71 | + | // brokerMuxSetupTimeout bounds each side's bootstrap readiness wait. On the | |
| 72 | + | // broker side it caps the wait for the local ssh bootstrap child's readiness | |
| 73 | + | // line together with that child's kill/reap cleanup; on the remote side it caps | |
| 74 | + | // the bootstrap command's wait for the detached proxy's readiness line. It is an | |
| 75 | + | // additional backstop only: an earlier caller context deadline always stays | |
| 76 | + | // authoritative, because context.WithTimeout keeps the sooner deadline, so the | |
| 77 | + | // independent bound can never extend a shorter caller budget. | |
| 78 | + | const brokerMuxSetupTimeout = 30 * time.Second | |
| 79 | + | ||
| 80 | + | // brokerMuxBootstrapReadyTimeout is the effective broker-side bootstrap | |
| 81 | + | // readiness budget. Production always uses brokerMuxSetupTimeout; a test | |
| 82 | + | // narrows it so a stalled helper that never prints readiness is exercised | |
| 83 | + | // deterministically without waiting out the production bound. | |
| 84 | + | var brokerMuxBootstrapReadyTimeout = brokerMuxSetupTimeout | |
| 85 | + | ||
| 86 | + | // brokerMuxQUICDialTimeout bounds the pinned QUIC dial. The setup context is | |
| 87 | + | // still authoritative; this narrows the dial itself. | |
| 88 | + | const brokerMuxQUICDialTimeout = 15 * time.Second | |
| 89 | + | ||
| 90 | + | // brokerMuxWaitDelay bounds how long a bootstrap Wait blocks on a captured- | |
| 91 | + | // stderr copy goroutine after the direct child exits. A remote helper that | |
| 92 | + | // leaves a grandchild holding the inherited stderr pipe can therefore never pin | |
| 93 | + | // setup shutdown past this delay; the direct child is still killed and reaped. | |
| 94 | + | const brokerMuxWaitDelay = 2 * time.Second | |
| 95 | + | ||
| 96 | + | // brokerMuxOptions is the parsed hidden mux helper invocation. | |
| 97 | + | type brokerMuxOptions struct { | |
| 98 | + | offlineRoot string | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // parseBrokerMuxArgs strictly parses `<hidden> --offline-root ABS`. Unknown or | |
| 102 | + | // duplicate flags, a missing value, and positionals are refused, exactly like | |
| 103 | + | // the foreground sandbox entry point. | |
| 104 | + | func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) { | |
| 105 | + | options := brokerMuxOptions{} | |
| 106 | + | rootSet := false | |
| 107 | + | for index := 0; index < len(args); { | |
| 108 | + | switch args[index] { | |
| 109 | + | case "--offline-root": | |
| 110 | + | if rootSet { | |
| 111 | + | return command{}, usagef("`%s` received duplicate --offline-root", name) | |
| 112 | + | } | |
| 113 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 114 | + | return command{}, usagef("`--offline-root` requires a path") | |
| 115 | + | } | |
| 116 | + | options.offlineRoot = args[index+1] | |
| 117 | + | rootSet = true | |
| 118 | + | index += 2 | |
| 119 | + | default: | |
| 120 | + | if strings.HasPrefix(args[index], "-") { | |
| 121 | + | return command{}, usagef("unknown flag %q for `%s`", args[index], name) | |
| 122 | + | } | |
| 123 | + | return command{}, usagef("`%s` does not accept positional arguments", name) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if !rootSet { | |
| 127 | + | return command{}, usagef("`%s` requires --offline-root", name) | |
| 128 | + | } | |
| 129 | + | return command{kind: kind, brokerMux: options}, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // loadMuxHelperConfig validates one offline root and loads its strict sandbox | |
| 133 | + | // configuration. A helper never creates sandbox directories: the root and the | |
| 134 | + | // config marker must already exist, so a helper can never provision state. | |
| 135 | + | func loadMuxHelperConfig(offlineRoot string) (*brokerconfig.Config, error) { | |
| 136 | + | layout, err := offlineLayout(offlineRoot) | |
| 137 | + | if err != nil { | |
| 138 | + | return nil, err | |
| 139 | + | } | |
| 140 | + | return brokerconfig.Load(layout) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // brokerMuxConnector builds the transport-selecting daemonmux endpoint | |
| 144 | + | // connector over one immutable configuration. The dial function receives only | |
| 145 | + | // the opaque address the resolver published and refuses any address the | |
| 146 | + | // configuration did not produce, so a request can never select a transport or | |
| 147 | + | // an address of its own. | |
| 148 | + | func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | |
| 149 | + | if config == nil { | |
| 150 | + | return nil, errors.New("vev: broker mux connector requires a configuration") | |
| 151 | + | } | |
| 152 | + | dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 153 | + | route, ok := config.RouteByAddress(address) | |
| 154 | + | if !ok { | |
| 155 | + | return nil, fmt.Errorf("vev: broker mux: unknown route address") | |
| 156 | + | } | |
| 157 | + | return dialBrokerRoute(ctx, route, log) | |
| 158 | + | } | |
| 159 | + | return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings()) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // dialBrokerRoute dials one provisioned route as an already-authenticated raw | |
| 163 | + | // framed carriage. Authentication, identity, and policy remain the daemonmux | |
| 164 | + | // connector's concern; this function only selects the transport. | |
| 165 | + | func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (daemonmux.RawFramedTransport, error) { | |
| 166 | + | switch route.Kind() { | |
| 167 | + | case brokerconfig.RouteUnix: | |
| 168 | + | return ipc.DialMuxContext(ctx, route.Path()) | |
| 169 | + | case brokerconfig.RouteSSHStdio: | |
| 170 | + | return sshstdio.DialMuxContext(ctx, sshMuxCommandSpec(route), log) | |
| 171 | + | case brokerconfig.RouteSSHQUIC: | |
| 172 | + | return dialBrokerQUICRoute(ctx, route, log) | |
| 173 | + | default: | |
| 174 | + | return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind()) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // sshMuxCommandSpec builds the local ssh argv for one provisioned route. It | |
| 179 | + | // reuses the mux carriage's own command construction (the option terminator, | |
| 180 | + | // the POSIX-quoted remote argv, and `-T` for no remote PTY) and splices in only | |
| 181 | + | // the explicit trust inputs the operator provisioned. It never disables | |
| 182 | + | // host-key verification, never forces a non-interactive auth bypass, and never | |
| 183 | + | // requests a PTY. | |
| 184 | + | func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec { | |
| 185 | + | spec := sshstdio.BuildCommandForMux(route.Target(), route.Argv()...) | |
| 186 | + | options := sshTrustOptions(route) | |
| 187 | + | if len(options) == 0 || len(spec.Args) < 2 { | |
| 188 | + | return spec | |
| 189 | + | } | |
| 190 | + | // BuildCommandForMux returns ["-T", "--", target, remote]; insert options | |
| 191 | + | // after `-T` so they stay ssh options and the option terminator still | |
| 192 | + | // protects the target. | |
| 193 | + | args := make([]string, 0, len(spec.Args)+len(options)) | |
| 194 | + | args = append(args, spec.Args[0]) | |
| 195 | + | args = append(args, options...) | |
| 196 | + | args = append(args, spec.Args[1:]...) | |
| 197 | + | return sshstdio.CommandSpec{Path: spec.Path, Args: args} | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // sshTrustOptions renders the provisioned trust inputs as ssh options. A | |
| 201 | + | // connect timeout only narrows the attempt; a known-hosts file names the | |
| 202 | + | // verification source. So the option carries a single bounded, whitespace-free | |
| 203 | + | // value and can never inject a second option. | |
| 204 | + | func sshTrustOptions(route brokerconfig.Route) []string { | |
| 205 | + | var options []string | |
| 206 | + | if timeout := route.ConnectTimeout(); timeout > 0 { | |
| 207 | + | seconds := int(timeout / time.Second) | |
| 208 | + | if seconds < 1 { | |
| 209 | + | seconds = 1 | |
| 210 | + | } | |
| 211 | + | options = append(options, "-o", fmt.Sprintf("ConnectTimeout=%d", seconds)) | |
| 212 | + | } | |
| 213 | + | if knownHosts := route.KnownHostsFile(); knownHosts != "" { | |
| 214 | + | options = append(options, "-o", "UserKnownHostsFile="+knownHosts) | |
| 215 | + | } | |
| 216 | + | return options | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // dialBrokerQUICRoute runs one SSH-authenticated QUIC bootstrap and returns the | |
| 220 | + | // pinned, authenticated raw bounded carriage. The local ssh child starts the | |
| 221 | + | // remote `_broker-mux-quic-bootstrap`, prints exactly one readiness line on | |
| 222 | + | // stdout, and exits; the detached remote proxy serves one carriage. A refused | |
| 223 | + | // pin, token, or nonce closes the carriage and hands nothing out. Captured | |
| 224 | + | // stderr is sanitized and never part of the returned error. | |
| 225 | + | // | |
| 226 | + | // The caller's setup context bounds the exchange, and brokerMuxSetupTimeout is | |
| 227 | + | // an independent backstop: an earlier caller deadline stays authoritative, | |
| 228 | + | // while a bootstrap that never prints readiness is still killed and reaped | |
| 229 | + | // within the bound and can never pin pool setup. The captured stderr sink is | |
| 230 | + | // read only after that kill and reap, and the sink is itself synchronized, so | |
| 231 | + | // the os/exec copy goroutine can never race the diagnostic. | |
| 232 | + | func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (wire.BoundedTransport, error) { | |
| 233 | + | spec := sshMuxCommandSpec(route) | |
| 234 | + | stderr := sshstdio.NewDiagnosticSink(0) | |
| 235 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 236 | + | cmd.Stderr = stderr | |
| 237 | + | // A captured stderr is a pipe with a copy goroutine; WaitDelay ensures a | |
| 238 | + | // grandchild that inherits the write end cannot block Wait past the delay | |
| 239 | + | // once the direct child is gone. | |
| 240 | + | cmd.WaitDelay = brokerMuxWaitDelay | |
| 241 | + | stdout, err := cmd.StdoutPipe() | |
| 242 | + | if err != nil { | |
| 243 | + | return nil, brokerQUICUnavailable("bootstrap stdout", err) | |
| 244 | + | } | |
| 245 | + | if err := cmd.Start(); err != nil { | |
| 246 | + | _ = stdout.Close() | |
| 247 | + | return nil, brokerQUICUnavailable("start bootstrap", err) | |
| 248 | + | } | |
| 249 | + | // reapBootstrap kills the local bootstrap and starts a bounded join, exactly | |
| 250 | + | // once. Every error path reaps before it reads the captured stderr sink; the | |
| 251 | + | // sink is safe to read whether or not that bounded join has finished, because | |
| 252 | + | // it synchronizes its own reads and writes. The remote proxy is detached | |
| 253 | + | // in its own session, so killing the local ssh child never ends it. | |
| 254 | + | reaped := false | |
| 255 | + | reapBootstrap := func() { | |
| 256 | + | if reaped { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | reaped = true | |
| 260 | + | reapBootstrapProcess(cmd) | |
| 261 | + | } | |
| 262 | + | defer func() { | |
| 263 | + | reapBootstrap() | |
| 264 | + | _ = stdout.Close() | |
| 265 | + | }() | |
| 266 | + | ||
| 267 | + | // The readiness wait is bounded independently of the caller context, so a | |
| 268 | + | // bootstrap that never prints readiness cannot pin pool setup. An earlier | |
| 269 | + | // caller deadline still wins: WithTimeout keeps the sooner deadline. | |
| 270 | + | readyCtx, cancelReadiness := context.WithTimeout(ctx, brokerMuxBootstrapReadyTimeout) | |
| 271 | + | readiness, err := quic.ReadReadiness(readyCtx, stdout) | |
| 272 | + | cancelReadiness() | |
| 273 | + | if err != nil { | |
| 274 | + | reapBootstrap() | |
| 275 | + | logBrokerBootstrapStderr(log, stderr) | |
| 276 | + | return nil, brokerQUICUnavailable("read bootstrap readiness", err) | |
| 277 | + | } | |
| 278 | + | // The bootstrap channel served its purpose: the detached remote proxy owns | |
| 279 | + | // the carriage lifetime. Reap the short-lived local child without killing | |
| 280 | + | // it, so SIGKILL never races the proxy detach. A bounded I/O drain that the | |
| 281 | + | // child's exit outlived (ErrWaitDelay) is not a bootstrap failure. | |
| 282 | + | if waitErr := cmd.Wait(); waitErr != nil && !errors.Is(waitErr, exec.ErrWaitDelay) { | |
| 283 | + | reaped = true | |
| 284 | + | logBrokerBootstrapStderr(log, stderr) | |
| 285 | + | return nil, brokerQUICUnavailable("reap bootstrap", waitErr) | |
| 286 | + | } | |
| 287 | + | reaped = true | |
| 288 | + | ||
| 289 | + | // Compose the dial address from the configured ssh target host plus the | |
| 290 | + | // host-independent readiness port: a remote loopback address is never | |
| 291 | + | // dialed. The one-time token and nonce travel only inside the encrypted | |
| 292 | + | // stream after the pinned handshake. | |
| 293 | + | addr, err := brokerQUICPeerAddr(route.Target(), readiness.Port) | |
| 294 | + | if err != nil { | |
| 295 | + | return nil, brokerQUICUnavailable("resolve QUIC peer", err) | |
| 296 | + | } | |
| 297 | + | transport, err := quic.DialMuxContext(ctx, addr, readiness, quic.Config{}, brokerMuxQUICDialTimeout) | |
| 298 | + | if err != nil { | |
| 299 | + | return nil, brokerQUICUnavailable("dial bootstrap QUIC", err) | |
| 300 | + | } | |
| 301 | + | return transport, nil | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // brokerQUICPeerAddr composes the dial address from the ssh target host and the | |
| 305 | + | // bootstrap-published port, stripping a user prefix, an explicit port, and | |
| 306 | + | // bracketed IPv6 literals to the bare host. | |
| 307 | + | func brokerQUICPeerAddr(target string, port int) (string, error) { | |
| 308 | + | if port <= 0 || port > 65535 { | |
| 309 | + | return "", fmt.Errorf("invalid bootstrap port %d", port) | |
| 310 | + | } | |
| 311 | + | host := target | |
| 312 | + | if at := strings.LastIndexByte(host, '@'); at >= 0 { | |
| 313 | + | host = host[at+1:] | |
| 314 | + | } | |
| 315 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 316 | + | host = h | |
| 317 | + | } | |
| 318 | + | host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") | |
| 319 | + | if host == "" { | |
| 320 | + | return "", errors.New("empty bootstrap host") | |
| 321 | + | } | |
| 322 | + | return net.JoinHostPort(host, strconv.Itoa(port)), nil | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // logBrokerBootstrapStderr records a bounded, sanitized bootstrap diagnostic. | |
| 326 | + | // The readiness secret is never on stderr, and no raw remote text is surfaced. | |
| 327 | + | // The join performed by the caller is bounded, so the os/exec stderr copy | |
| 328 | + | // goroutine may still be writing when this reads the sink; the diagnostic is | |
| 329 | + | // nonetheless consistent because DiagnosticSink synchronizes its own reads and | |
| 330 | + | // writes and returns a bounded, sanitized snapshot rather than relying on the | |
| 331 | + | // join having quiesced the sink. | |
| 332 | + | func logBrokerBootstrapStderr(log *slog.Logger, sink *sshstdio.DiagnosticSink) { | |
| 333 | + | if log == nil || sink == nil { | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | diagnostic := strings.TrimSpace(sink.String()) | |
| 337 | + | if diagnostic == "" { | |
| 338 | + | return | |
| 339 | + | } | |
| 340 | + | log.Warn("broker_mux_bootstrap_stderr", "diagnostic", diagnostic) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // reapBootstrapProcess kills one bootstrap child and starts a bounded join of | |
| 344 | + | // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that | |
| 345 | + | // leaves a grandchild holding the captured stderr pipe cannot pin cleanup, and | |
| 346 | + | // exec.Cmd.WaitDelay narrows the wait much further once the direct child is | |
| 347 | + | // gone. When the bound expires the wait goroutine may still be draining the | |
| 348 | + | // captured-stderr copy, so a caller that reads the sink afterwards relies on | |
| 349 | + | // the sink's own synchronization, not on the join having finished. The direct | |
| 350 | + | // child is always killed, so no bootstrap process is ever leaked. | |
| 351 | + | func reapBootstrapProcess(cmd *exec.Cmd) { | |
| 352 | + | if cmd == nil || cmd.Process == nil { | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | _ = cmd.Process.Kill() | |
| 356 | + | done := make(chan struct{}) | |
| 357 | + | go func() { | |
| 358 | + | defer close(done) | |
| 359 | + | _ = cmd.Wait() | |
| 360 | + | }() | |
| 361 | + | timer := time.NewTimer(brokerMuxSetupTimeout) | |
| 362 | + | defer timer.Stop() | |
| 363 | + | select { | |
| 364 | + | case <-done: | |
| 365 | + | case <-timer.C: | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // brokerQUICUnavailable wraps one bootstrap or pinned-dial failure. The | |
| 370 | + | // readiness record is deliberately absent: a returned error never carries the | |
| 371 | + | // one-time token, the nonce, or remote stderr. | |
| 372 | + | func brokerQUICUnavailable(action string, err error) error { | |
| 373 | + | if err == nil { | |
| 374 | + | err = errors.New("unavailable") | |
| 375 | + | } | |
| 376 | + | return fmt.Errorf("vev: broker mux QUIC unavailable: %s: %w", action, err) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // runBrokerMuxStdioCommand bridges the process' own stdio to the single | |
| 380 | + | // provisioned private Unix daemonmux carriage. | |
| 381 | + | func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 382 | + | config, err := loadMuxHelperConfig(options.offlineRoot) | |
| 383 | + | if err != nil { | |
| 384 | + | return err | |
| 385 | + | } | |
| 386 | + | route, err := config.LocalMuxRoute() | |
| 387 | + | if err != nil { | |
| 388 | + | return err | |
| 389 | + | } | |
| 390 | + | raw, err := ipc.DialMuxContext(ctx, route.Path()) | |
| 391 | + | if err != nil { | |
| 392 | + | return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err) | |
| 393 | + | } | |
| 394 | + | return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), raw) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // runBrokerMuxQUICBootstrapCommand starts one detached `_broker-mux-quic-proxy`, | |
| 398 | + | // forwards its single readiness line on this process' stdout, and exits. The | |
| 399 | + | // proxy owns the carriage lifetime in its own session and survives this short- | |
| 400 | + | // lived bootstrap, exactly like the ordinary QUIC bootstrap. | |
| 401 | + | func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 402 | + | // Validate the sandbox before spawning anything, so a bad root or config | |
| 403 | + | // fails without a readiness line and without a child. | |
| 404 | + | if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil { | |
| 405 | + | return err | |
| 406 | + | } | |
| 407 | + | exe, err := os.Executable() | |
| 408 | + | if err != nil { | |
| 409 | + | return err | |
| 410 | + | } | |
| 411 | + | reader, writer, err := os.Pipe() | |
| 412 | + | if err != nil { | |
| 413 | + | return err | |
| 414 | + | } | |
| 415 | + | defer func() { _ = reader.Close() }() | |
| 416 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 417 | + | if err != nil { | |
| 418 | + | _ = writer.Close() | |
| 419 | + | return err | |
| 420 | + | } | |
| 421 | + | defer func() { _ = devNull.Close() }() | |
| 422 | + | ||
| 423 | + | cmd := exec.CommandContext(ctx, exe, brokerMuxQUICProxyCommand, "--offline-root", options.offlineRoot) | |
| 424 | + | // The proxy writes its readiness through the pipe and its diagnostics | |
| 425 | + | // through stderr; stdio is otherwise detached so the SSH bootstrap channel | |
| 426 | + | // can close while the detached proxy owns the carriage. | |
| 427 | + | cmd.Stdin = devNull | |
| 428 | + | cmd.Stdout = writer | |
| 429 | + | cmd.Stderr = devNull | |
| 430 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 431 | + | if err := cmd.Start(); err != nil { | |
| 432 | + | _ = writer.Close() | |
| 433 | + | return err | |
| 434 | + | } | |
| 435 | + | _ = writer.Close() | |
| 436 | + | ||
| 437 | + | readyCtx, cancel := context.WithTimeout(ctx, brokerMuxSetupTimeout) | |
| 438 | + | defer cancel() | |
| 439 | + | readiness, err := quic.ReadReadiness(readyCtx, reader) | |
| 440 | + | if err != nil { | |
| 441 | + | _ = cmd.Process.Kill() | |
| 442 | + | _ = cmd.Wait() | |
| 443 | + | return fmt.Errorf("vev: broker mux bootstrap readiness: %w", err) | |
| 444 | + | } | |
| 445 | + | line, err := quic.EncodeReadiness(readiness) | |
| 446 | + | if err != nil { | |
| 447 | + | _ = cmd.Process.Kill() | |
| 448 | + | _ = cmd.Wait() | |
| 449 | + | return err | |
| 450 | + | } | |
| 451 | + | if _, err := fmt.Fprintf(os.Stdout, "%s\n", line); err != nil { | |
| 452 | + | _ = cmd.Process.Kill() | |
| 453 | + | _ = cmd.Wait() | |
| 454 | + | return err | |
| 455 | + | } | |
| 456 | + | // Release the detached proxy: it is in its own session and outlives this | |
| 457 | + | // bootstrap. Reaping it here would block until the carriage ends. | |
| 458 | + | return cmd.Process.Release() | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | // runBrokerMuxQUICProxyCommand mints one ephemeral QUIC server, admits exactly | |
| 462 | + | // one authenticated carriage, and bridges it to the single provisioned private | |
| 463 | + | // Unix daemonmux carriage. It exits after its carriage ends, on credential | |
| 464 | + | // expiry, or on setup failure, so it can never outlive its advertised | |
| 465 | + | // credential or serve a second physical connection. | |
| 466 | + | func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 467 | + | config, err := loadMuxHelperConfig(options.offlineRoot) | |
| 468 | + | if err != nil { | |
| 469 | + | return err | |
| 470 | + | } | |
| 471 | + | route, err := config.LocalMuxRoute() | |
| 472 | + | if err != nil { | |
| 473 | + | return err | |
| 474 | + | } | |
| 475 | + | server, readiness, err := quic.NewServer() | |
| 476 | + | if err != nil { | |
| 477 | + | return err | |
| 478 | + | } | |
| 479 | + | defer func() { _ = server.Close() }() | |
| 480 | + | line, err := quic.EncodeReadiness(readiness) | |
| 481 | + | if err != nil { | |
| 482 | + | return err | |
| 483 | + | } | |
| 484 | + | if _, err := fmt.Fprintf(os.Stdout, "%s\n", line); err != nil { | |
| 485 | + | return err | |
| 486 | + | } | |
| 487 | + | transport, err := server.AcceptMux(ctx) | |
| 488 | + | if err != nil { | |
| 489 | + | return err | |
| 490 | + | } | |
| 491 | + | defer func() { _ = transport.Close() }() | |
| 492 | + | raw, err := ipc.DialMuxContext(ctx, route.Path()) | |
| 493 | + | if err != nil { | |
| 494 | + | return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err) | |
| 495 | + | } | |
| 496 | + | return bridgeMuxTransports(ctx, transport, raw) | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | // bridgeMuxTransports copies raw frames both ways until either carriage ends or | |
| 500 | + | // ctx is done, then closes both. An orderly end of either direction (EOF) is a | |
| 501 | + | // normal exit for a bridge owner that merely forwards bytes. | |
| 502 | + | func bridgeMuxTransports(ctx context.Context, a, b wire.BoundedTransport) error { | |
| 503 | + | ctx, cancel := context.WithCancel(ctx) | |
| 504 | + | defer cancel() | |
| 505 | + | defer func() { _ = a.Close() }() | |
| 506 | + | defer func() { _ = b.Close() }() | |
| 507 | + | errCh := make(chan error, 2) | |
| 508 | + | go func() { errCh <- relayMux(ctx, a, b) }() | |
| 509 | + | go func() { errCh <- relayMux(ctx, b, a) }() | |
| 510 | + | select { | |
| 511 | + | case err := <-errCh: | |
| 512 | + | return orderlyRelay(err) | |
| 513 | + | case <-ctx.Done(): | |
| 514 | + | return ctx.Err() | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // relayMux copies one direction of raw bounded frames. | |
| 519 | + | func relayMux(ctx context.Context, src, dst wire.BoundedTransport) error { | |
| 520 | + | for { | |
| 521 | + | envelope, err := src.RecvBounded(wire.AbsoluteEnvelopeLimit) | |
| 522 | + | if err != nil { | |
| 523 | + | return err | |
| 524 | + | } | |
| 525 | + | if err := dst.Send(envelope); err != nil { | |
| 526 | + | return err | |
| 527 | + | } | |
| 528 | + | if err := ctx.Err(); err != nil { | |
| 529 | + | return err | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | // orderlyRelay treats EOF as a clean bridge end and passes every other outcome | |
| 535 | + | // through unchanged. | |
| 536 | + | func orderlyRelay(err error) error { | |
| 537 | + | if err == nil || errors.Is(err, io.EOF) { | |
| 538 | + | return nil | |
| 539 | + | } | |
| 540 | + | return err | |
| 541 | + | } | |
A internal/app/broker_offline_mux_test.go +709 -0
| @@ -0,0 +1,709 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 10 | + | "log/slog" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "syscall" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | "unicode" | |
| 19 | + | ||
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 23 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 24 | + | "github.com/bnema/vev/internal/adapters/quic" | |
| 25 | + | "github.com/bnema/vev/internal/ports" | |
| 26 | + | "github.com/bnema/vev/internal/protocol" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // Real-composition coverage for the P3.4 slice E remote mux helpers. | |
| 30 | + | // | |
| 31 | + | // The SSH carriage is exercised with an owned test subprocess: a fake `ssh` | |
| 32 | + | // on PATH re-executes this test binary through TestMain's broker-helper | |
| 33 | + | // interception, so the real helper code runs with real pipes and a real unix | |
| 34 | + | // daemonmux fixture, and the exact ssh argv is captured for inspection. The | |
| 35 | + | // QUIC carriage runs a real loopback authenticated QUIC server, so a successful | |
| 36 | + | // round trip proves pin, token, nonce, and daemonmux handshake all compose. | |
| 37 | + | ||
| 38 | + | const ( | |
| 39 | + | fakeSSHArgsEnv = "VEV_TEST_SSH_ARGS" | |
| 40 | + | fakeSSHReadyEnv = "VEV_TEST_SSH_READY" | |
| 41 | + | fakeSSHPIDEnv = "VEV_TEST_SSH_PID" | |
| 42 | + | fakeSSHBinEnv = "VEV_TEST_SSH_BIN" | |
| 43 | + | fakeSSHModeEnv = "VEV_TEST_SSH_MODE" | |
| 44 | + | fakeSSHRootEnv = "VEV_TEST_SSH_REMOTE_ROOT" | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | // fakeSSHExecHelper is an owned test ssh: it records its exact argv and then | |
| 48 | + | // re-executes this test binary as the named hidden mux helper against an | |
| 49 | + | // isolated remote sandbox. Its stdin/stdout remain the carriage pipes. | |
| 50 | + | const fakeSSHExecHelper = `#!/bin/sh | |
| 51 | + | printf '%s\n' "$@" > "$` + fakeSSHArgsEnv + `" | |
| 52 | + | exec "$` + fakeSSHBinEnv + `" "$` + fakeSSHModeEnv + `" --offline-root "$` + fakeSSHRootEnv + `" | |
| 53 | + | ` | |
| 54 | + | ||
| 55 | + | // fakeSSHEmitReadiness prints one controlled readiness line and exits, so a | |
| 56 | + | // test can drive the broker-side pinned QUIC dial without a real remote host. | |
| 57 | + | const fakeSSHEmitReadiness = `#!/bin/sh | |
| 58 | + | printf '%s\n' "$@" > "$` + fakeSSHArgsEnv + `" | |
| 59 | + | printf '%s\n' "$` + fakeSSHReadyEnv + `" | |
| 60 | + | exit 0 | |
| 61 | + | ` | |
| 62 | + | ||
| 63 | + | // fakeSSHSleep blocks without producing readiness, so a test can prove setup | |
| 64 | + | // cancellation reaps the bootstrap child. | |
| 65 | + | const fakeSSHSleep = `#!/bin/sh | |
| 66 | + | printf '%s\n' "$@" > "$` + fakeSSHArgsEnv + `" | |
| 67 | + | printf '%s\n' "$$" > "$` + fakeSSHPIDEnv + `" | |
| 68 | + | sleep 30 | |
| 69 | + | ` | |
| 70 | + | ||
| 71 | + | // fakeSSHFloodStderr floods stderr with text carrying a terminal escape and a | |
| 72 | + | // control byte and never prints readiness, so a test can prove the broker-side | |
| 73 | + | // diagnostic sink is read only after the os/exec copy goroutine has been killed | |
| 74 | + | // and joined. | |
| 75 | + | const fakeSSHFloodStderr = `#!/bin/sh | |
| 76 | + | printf '%s\n' "$@" > "$` + fakeSSHArgsEnv + `" | |
| 77 | + | printf '%s\n' "$$" > "$` + fakeSSHPIDEnv + `" | |
| 78 | + | i=0 | |
| 79 | + | while true; do | |
| 80 | + | printf 'flood-%s\033[31mred\033[0m\001\002-noise\n' "$i" >&2 | |
| 81 | + | i=$((i+1)) | |
| 82 | + | sleep 0.01 | |
| 83 | + | done | |
| 84 | + | ` | |
| 85 | + | ||
| 86 | + | // fakeSSHStallNeverReady stalls without printing readiness and without exiting, | |
| 87 | + | // so a test can prove the independent broker-side setup timeout interrupts a | |
| 88 | + | // bootstrap that the caller never cancels. It execs the blocking sleep so the | |
| 89 | + | // reaped process owns the captured stderr pipe and no grandchild lingers. | |
| 90 | + | const fakeSSHStallNeverReady = `#!/bin/sh | |
| 91 | + | printf '%s\n' "$@" > "$` + fakeSSHArgsEnv + `" | |
| 92 | + | printf '%s\n' "$$" > "$` + fakeSSHPIDEnv + `" | |
| 93 | + | exec sleep 30 | |
| 94 | + | ` | |
| 95 | + | ||
| 96 | + | // installFakeSSH puts an owned `ssh` executable first on PATH and returns the | |
| 97 | + | // file its argv is recorded to. | |
| 98 | + | func installFakeSSH(t *testing.T, script string) string { | |
| 99 | + | t.Helper() | |
| 100 | + | binDir := t.TempDir() | |
| 101 | + | require.NoError(t, os.Chmod(binDir, 0o755)) | |
| 102 | + | argsFile := filepath.Join(t.TempDir(), "ssh-args") | |
| 103 | + | require.NoError(t, os.WriteFile(filepath.Join(binDir, "ssh"), []byte(script), 0o755)) | |
| 104 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |
| 105 | + | t.Setenv(fakeSSHArgsEnv, argsFile) | |
| 106 | + | t.Setenv(fakeSSHBinEnv, os.Args[0]) | |
| 107 | + | return argsFile | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // muxTestEnv isolates production XDG roots and records every detached mux | |
| 111 | + | // helper so no test leaks a process. | |
| 112 | + | type muxTestEnv struct { | |
| 113 | + | t *testing.T | |
| 114 | + | prodRuntime, prodState string | |
| 115 | + | recordDir string | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func newMuxTestEnv(t *testing.T) *muxTestEnv { | |
| 119 | + | t.Helper() | |
| 120 | + | _, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 121 | + | recordDir := filepath.Join(shortTempDir(t, "vevm"), "r") | |
| 122 | + | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | |
| 123 | + | t.Setenv(brokerHelperEnv, "1") | |
| 124 | + | t.Setenv(brokerHelperRecordDirEnv, recordDir) | |
| 125 | + | env := &muxTestEnv{t: t, prodRuntime: prodRuntime, prodState: prodState, recordDir: recordDir} | |
| 126 | + | t.Cleanup(env.cleanup) | |
| 127 | + | return env | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // cleanup kills every recorded mux helper process. | |
| 131 | + | func (e *muxTestEnv) cleanup() { | |
| 132 | + | for _, role := range []string{brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand} { | |
| 133 | + | raw, err := os.ReadFile(filepath.Join(e.recordDir, role+".pids")) | |
| 134 | + | if err != nil { | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | for _, field := range strings.Fields(string(raw)) { | |
| 138 | + | pid, convErr := strconv.Atoi(field) | |
| 139 | + | if convErr != nil { | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | _ = waitForProcessExit(pid, 5*time.Second) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // writeMuxRoot writes one marker-valid sandbox root carrying a single | |
| 151 | + | // registration with the supplied route object. | |
| 152 | + | func writeMuxRoot(t *testing.T, route any) string { | |
| 153 | + | t.Helper() | |
| 154 | + | root := filepath.Join(shortTempDir(t, "vevx"), "s") | |
| 155 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 156 | + | writeSandboxConfig(t, root, sandboxRegistrationDocument(route)) | |
| 157 | + | return root | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // loadTestRoute loads one sandbox configuration and returns the route its | |
| 161 | + | // resolver publishes, so a test can inspect a parsed route without the | |
| 162 | + | // unexported brokerconfig fields. | |
| 163 | + | func loadTestRoute(t *testing.T, route any) brokerconfig.Route { | |
| 164 | + | t.Helper() | |
| 165 | + | root := writeMuxRoot(t, route) | |
| 166 | + | layout, err := offlineLayout(root) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | config, err := brokerconfig.Load(layout) | |
| 169 | + | require.NoError(t, err) | |
| 170 | + | resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 171 | + | Purpose: ports.BrokerStreamControl, | |
| 172 | + | Endpoint: brokerTestEndpoint, | |
| 173 | + | Registration: brokerTestRegistration(), | |
| 174 | + | Policy: brokerTestPolicy(), | |
| 175 | + | }) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | parsed, ok := config.RouteByAddress(resolved.Address) | |
| 178 | + | require.True(t, ok) | |
| 179 | + | return parsed | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // TestParseBrokerMuxArgs pins the strict hidden-helper argument contract. | |
| 183 | + | func TestParseBrokerMuxArgs(t *testing.T) { | |
| 184 | + | tests := []struct { | |
| 185 | + | name string | |
| 186 | + | args []string | |
| 187 | + | wantErr string | |
| 188 | + | want brokerMuxOptions | |
| 189 | + | }{ | |
| 190 | + | {name: "valid", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox"}}, | |
| 191 | + | {name: "missing root", args: nil, wantErr: "requires --offline-root"}, | |
| 192 | + | {name: "empty root", args: []string{"--offline-root", ""}, wantErr: "requires a path"}, | |
| 193 | + | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate"}, | |
| 194 | + | {name: "unknown flag", args: []string{"--idle-grace", "1m"}, wantErr: "unknown flag"}, | |
| 195 | + | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 196 | + | } | |
| 197 | + | for _, tt := range tests { | |
| 198 | + | t.Run(tt.name, func(t *testing.T) { | |
| 199 | + | command, err := parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, tt.args) | |
| 200 | + | if tt.wantErr != "" { | |
| 201 | + | require.Error(t, err) | |
| 202 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | require.Equal(t, brokerMuxOptions{offlineRoot: tt.want.offlineRoot}, command.brokerMux) | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // TestBrokerMuxHelpersHiddenFromPublicHelp proves the three helpers parse but | |
| 212 | + | // stay out of public help and never alter ordinary commands. | |
| 213 | + | func TestBrokerMuxHelpersHiddenFromPublicHelp(t *testing.T) { | |
| 214 | + | for _, tt := range []struct { | |
| 215 | + | name string | |
| 216 | + | kind cmdKind | |
| 217 | + | }{ | |
| 218 | + | {brokerMuxStdioCommand, kindBrokerMuxStdio}, | |
| 219 | + | {brokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap}, | |
| 220 | + | {brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy}, | |
| 221 | + | } { | |
| 222 | + | require.NotContains(t, usageText, tt.name) | |
| 223 | + | command, err := parseArgs([]string{tt.name, "--offline-root", "/srv/sandbox"}) | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | require.Equal(t, tt.kind, command.kind) | |
| 226 | + | require.Equal(t, "/srv/sandbox", command.brokerMux.offlineRoot) | |
| 227 | + | } | |
| 228 | + | // Ordinary commands are untouched by the new hidden entries. | |
| 229 | + | attach, err := parseArgs(nil) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | require.Equal(t, kindAttach, attach.kind) | |
| 232 | + | list, err := parseArgs([]string{"ls"}) | |
| 233 | + | require.NoError(t, err) | |
| 234 | + | require.Equal(t, kindList, list.kind) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // TestSSHMuxCommandSpecPreservesTrustAndNoPTY proves the ssh argv keeps `-T`, | |
| 238 | + | // the option terminator, and the quoted remote argv, splices only the explicit | |
| 239 | + | // trust inputs, and never disables host-key verification. | |
| 240 | + | func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) { | |
| 241 | + | isolateSandboxEnv(t) | |
| 242 | + | route := loadTestRoute(t, map[string]any{ | |
| 243 | + | "kind": "ssh-stdio", | |
| 244 | + | "target": "user@host:2222", | |
| 245 | + | "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"}, | |
| 246 | + | "trust": map[string]any{"knownHostsFile": "/etc/ssh/known_hosts", "connectTimeout": "30s"}, | |
| 247 | + | }) | |
| 248 | + | spec := sshMuxCommandSpec(route) | |
| 249 | + | require.Equal(t, "ssh", spec.Path) | |
| 250 | + | require.Equal(t, "-T", spec.Args[0]) | |
| 251 | + | require.Contains(t, spec.Args, "--") | |
| 252 | + | require.Contains(t, spec.Args, "UserKnownHostsFile=/etc/ssh/known_hosts") | |
| 253 | + | require.Contains(t, spec.Args, "ConnectTimeout=30") | |
| 254 | + | joined := strings.Join(spec.Args, " ") | |
| 255 | + | require.Contains(t, joined, "-- user@host:2222 ") | |
| 256 | + | require.Contains(t, joined, brokerMuxStdioCommand) | |
| 257 | + | require.NotContains(t, joined, "StrictHostKeyChecking") | |
| 258 | + | require.NotContains(t, joined, "BatchMode") | |
| 259 | + | require.NotContains(t, joined, "ProxyCommand") | |
| 260 | + | // The option terminator protects the target, and the -T option comes first. | |
| 261 | + | require.Less(t, indexOf(spec.Args, "-T"), indexOf(spec.Args, "--")) | |
| 262 | + | for _, arg := range spec.Args { | |
| 263 | + | require.NotEqual(t, "no", strings.ToLower(arg)) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // A route without trust inputs reuses the mux command construction | |
| 267 | + | // unchanged (`-T -- target remote`). | |
| 268 | + | plain := loadTestRoute(t, map[string]any{ | |
| 269 | + | "kind": "ssh-quic", | |
| 270 | + | "target": "host", | |
| 271 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 272 | + | }) | |
| 273 | + | plainSpec := sshMuxCommandSpec(plain) | |
| 274 | + | require.Equal(t, []string{"-T", "--"}, append([]string(nil), plainSpec.Args[:2]...)) | |
| 275 | + | require.NotContains(t, plainSpec.Args, "-o") | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv proves a remote argv word | |
| 279 | + | // with a space stays one quoted word and never becomes a second option. | |
| 280 | + | func TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv(t *testing.T) { | |
| 281 | + | isolateSandboxEnv(t) | |
| 282 | + | route := loadTestRoute(t, map[string]any{ | |
| 283 | + | "kind": "ssh-stdio", | |
| 284 | + | "target": "host", | |
| 285 | + | "argv": []any{"vev", "_broker-mux-stdio", "--offline-root", "/srv/with space"}, | |
| 286 | + | }) | |
| 287 | + | spec := sshMuxCommandSpec(route) | |
| 288 | + | require.Contains(t, spec.Args[len(spec.Args)-1], `'/srv/with space'`) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // TestBrokerQUICPeerAddr pins the host-independent address composition. | |
| 292 | + | func TestBrokerQUICPeerAddr(t *testing.T) { | |
| 293 | + | tests := []struct { | |
| 294 | + | target string | |
| 295 | + | port int | |
| 296 | + | want string | |
| 297 | + | wantErr bool | |
| 298 | + | }{ | |
| 299 | + | {target: "user@host", port: 2222, want: "host:2222"}, | |
| 300 | + | {target: "user@host:22", port: 2222, want: "host:2222"}, | |
| 301 | + | {target: "host", port: 1, want: "host:1"}, | |
| 302 | + | {target: "user@[::1]", port: 7, want: "[::1]:7"}, | |
| 303 | + | {target: "host", port: 0, wantErr: true}, | |
| 304 | + | {target: "host", port: 65536, wantErr: true}, | |
| 305 | + | {target: "user@", port: 7, wantErr: true}, | |
| 306 | + | } | |
| 307 | + | for _, tt := range tests { | |
| 308 | + | got, err := brokerQUICPeerAddr(tt.target, tt.port) | |
| 309 | + | if tt.wantErr { | |
| 310 | + | require.Error(t, err) | |
| 311 | + | continue | |
| 312 | + | } | |
| 313 | + | require.NoError(t, err) | |
| 314 | + | require.Equal(t, tt.want, got) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector | |
| 319 | + | // refuses any address the immutable configuration did not produce. | |
| 320 | + | func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | |
| 321 | + | isolateSandboxEnv(t) | |
| 322 | + | root := writeMuxRoot(t, map[string]any{"kind": "unix", "path": "/tmp/unused-mux.sock"}) | |
| 323 | + | layout, err := offlineLayout(root) | |
| 324 | + | require.NoError(t, err) | |
| 325 | + | config, err := brokerconfig.Load(layout) | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | ||
| 330 | + | _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{ | |
| 331 | + | Identity: brokerTestIdentity, | |
| 332 | + | Policy: brokerTestPolicy(), | |
| 333 | + | Address: "offline-route-00000000000000000000000000000000", | |
| 334 | + | }) | |
| 335 | + | require.Error(t, err) | |
| 336 | + | var typed ports.BrokerError | |
| 337 | + | require.ErrorAs(t, err, &typed) | |
| 338 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // TestBrokerServeSSHStdioRouteRoundTrip drives the full broker path over an SSH | |
| 342 | + | // stdio route: the broker dials the fake ssh child, the hidden helper bridges | |
| 343 | + | // its stdio to a real unix daemonmux fixture, and a typed Ping returns a Pong. | |
| 344 | + | func TestBrokerServeSSHStdioRouteRoundTrip(t *testing.T) { | |
| 345 | + | env := newMuxTestEnv(t) | |
| 346 | + | policy := brokerTestPolicy() | |
| 347 | + | fixture := newBrokerMuxFixture(t, policy) | |
| 348 | + | remoteRoot := writeMuxRoot(t, map[string]any{"kind": "unix", "path": fixture.route}) | |
| 349 | + | argsFile := installFakeSSH(t, fakeSSHExecHelper) | |
| 350 | + | t.Setenv(fakeSSHModeEnv, brokerMuxStdioCommand) | |
| 351 | + | t.Setenv(fakeSSHRootEnv, remoteRoot) | |
| 352 | + | localRoot := writeMuxRoot(t, map[string]any{ | |
| 353 | + | "kind": "ssh-stdio", | |
| 354 | + | "target": "user@muxhost:2222", | |
| 355 | + | "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", remoteRoot}, | |
| 356 | + | "trust": map[string]any{"knownHostsFile": "/etc/ssh/known_hosts", "connectTimeout": "5s"}, | |
| 357 | + | }) | |
| 358 | + | ||
| 359 | + | socketPath, stop := startMuxBrokerServe(t, localRoot) | |
| 360 | + | defer stop() | |
| 361 | + | requireBrokerPing(t, socketPath) | |
| 362 | + | require.Positive(t, fixture.streams.Load()) | |
| 363 | + | ||
| 364 | + | // The exact ssh argv was built by BuildCommandForMux plus the trust inputs. | |
| 365 | + | argv := readFileString(t, argsFile) | |
| 366 | + | require.Contains(t, argv, "-T\n") | |
| 367 | + | require.Contains(t, argv, "--\nuser@muxhost:2222\n") | |
| 368 | + | require.Contains(t, argv, brokerMuxStdioCommand) | |
| 369 | + | require.Contains(t, argv, "UserKnownHostsFile=/etc/ssh/known_hosts") | |
| 370 | + | require.Contains(t, argv, "ConnectTimeout=5") | |
| 371 | + | require.NotContains(t, argv, "StrictHostKeyChecking") | |
| 372 | + | require.NotContains(t, argv, "BatchMode") | |
| 373 | + | ||
| 374 | + | stop() | |
| 375 | + | env.requireProductionUntouched(t) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // TestBrokerServeSSHQUICRouteRoundTrip drives the full broker path over an | |
| 379 | + | // SSH-authenticated QUIC route with a real loopback QUIC server and a real | |
| 380 | + | // one-time credential. | |
| 381 | + | func TestBrokerServeSSHQUICRouteRoundTrip(t *testing.T) { | |
| 382 | + | env := newMuxTestEnv(t) | |
| 383 | + | policy := brokerTestPolicy() | |
| 384 | + | fixture := newBrokerMuxFixture(t, policy) | |
| 385 | + | remoteRoot := writeMuxRoot(t, map[string]any{"kind": "unix", "path": fixture.route}) | |
| 386 | + | installFakeSSH(t, fakeSSHExecHelper) | |
| 387 | + | t.Setenv(fakeSSHModeEnv, brokerMuxQUICBootstrapCommand) | |
| 388 | + | t.Setenv(fakeSSHRootEnv, remoteRoot) | |
| 389 | + | localRoot := writeMuxRoot(t, map[string]any{ | |
| 390 | + | "kind": "ssh-quic", | |
| 391 | + | "target": "127.0.0.1", | |
| 392 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", remoteRoot}, | |
| 393 | + | }) | |
| 394 | + | ||
| 395 | + | socketPath, stop := startMuxBrokerServe(t, localRoot) | |
| 396 | + | defer stop() | |
| 397 | + | requireBrokerPing(t, socketPath) | |
| 398 | + | require.Positive(t, fixture.streams.Load()) | |
| 399 | + | ||
| 400 | + | stop() | |
| 401 | + | env.requireProductionUntouched(t) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // TestBrokerMuxQUICRejectsBadPinWithoutLeakingSecret proves the pinned dial | |
| 405 | + | // fails closed on a wrong certificate pin and that the one-time credential | |
| 406 | + | // never appears in the returned error. | |
| 407 | + | func TestBrokerMuxQUICRejectsBadPinWithoutLeakingSecret(t *testing.T) { | |
| 408 | + | isolateSandboxEnv(t) | |
| 409 | + | installFakeSSH(t, fakeSSHEmitReadiness) | |
| 410 | + | ||
| 411 | + | server, readiness, err := quic.NewServer() | |
| 412 | + | require.NoError(t, err) | |
| 413 | + | defer func() { _ = server.Close() }() | |
| 414 | + | // Flip the pin: the TLS handshake must fail before any token is accepted. | |
| 415 | + | tampered := readiness | |
| 416 | + | tampered.Fingerprint = strings.Repeat("00", 32) | |
| 417 | + | t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, tampered))) | |
| 418 | + | ||
| 419 | + | route := loadTestRoute(t, map[string]any{ | |
| 420 | + | "kind": "ssh-quic", | |
| 421 | + | "target": "127.0.0.1", | |
| 422 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 423 | + | }) | |
| 424 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 425 | + | defer cancel() | |
| 426 | + | _, err = dialBrokerRoute(ctx, route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 427 | + | require.Error(t, err) | |
| 428 | + | requireBrokerErrorHasNoReadiness(t, err, readiness) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // TestBrokerMuxQUICRejectsExpiredReadiness proves client-side parsing refuses | |
| 432 | + | // an implausibly old credential before dialing and never echoes its secret. | |
| 433 | + | func TestBrokerMuxQUICRejectsExpiredReadiness(t *testing.T) { | |
| 434 | + | isolateSandboxEnv(t) | |
| 435 | + | installFakeSSH(t, fakeSSHEmitReadiness) | |
| 436 | + | ||
| 437 | + | token := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("A", 32))) | |
| 438 | + | nonce := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("B", 16))) | |
| 439 | + | readiness := quic.Readiness{ | |
| 440 | + | Version: 1, | |
| 441 | + | Port: 2222, | |
| 442 | + | Token: token, | |
| 443 | + | Nonce: nonce, | |
| 444 | + | Fingerprint: strings.Repeat("00", 32), | |
| 445 | + | ExpiresAt: time.Now().Add(-time.Hour).Unix(), | |
| 446 | + | } | |
| 447 | + | t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, readiness))) | |
| 448 | + | ||
| 449 | + | route := loadTestRoute(t, map[string]any{ | |
| 450 | + | "kind": "ssh-quic", | |
| 451 | + | "target": "127.0.0.1", | |
| 452 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 453 | + | }) | |
| 454 | + | _, err := dialBrokerRoute(context.Background(), route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 455 | + | require.Error(t, err) | |
| 456 | + | require.NotContains(t, err.Error(), token) | |
| 457 | + | require.NotContains(t, err.Error(), nonce) | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // TestBrokerMuxDialCancellationReapsBootstrap proves one setup context bounds | |
| 461 | + | // the bootstrap wait, a cancellation returns promptly, and the bootstrap child | |
| 462 | + | // is killed and reaped rather than leaked. | |
| 463 | + | func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) { | |
| 464 | + | isolateSandboxEnv(t) | |
| 465 | + | installFakeSSH(t, fakeSSHSleep) | |
| 466 | + | pidFile := filepath.Join(t.TempDir(), "ssh.pid") | |
| 467 | + | t.Setenv(fakeSSHPIDEnv, pidFile) | |
| 468 | + | ||
| 469 | + | route := loadTestRoute(t, map[string]any{ | |
| 470 | + | "kind": "ssh-quic", | |
| 471 | + | "target": "127.0.0.1", | |
| 472 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 473 | + | }) | |
| 474 | + | ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) | |
| 475 | + | defer cancel() | |
| 476 | + | start := time.Now() | |
| 477 | + | _, err := dialBrokerRoute(ctx, route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 478 | + | require.Error(t, err) | |
| 479 | + | require.Less(t, time.Since(start), 3*time.Second, "cancellation must return promptly") | |
| 480 | + | ||
| 481 | + | pid := readPID(t, pidFile) | |
| 482 | + | require.NoError(t, waitForProcessExit(pid, 3*time.Second), "the bootstrap child must be reaped") | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // TestBrokerMuxDialFloodedStderrIsReapedAndSanitized is the regression for the | |
| 486 | + | // stderr DiagnosticSink race: a bootstrap that floods stderr and never prints | |
| 487 | + | // readiness must be killed and reaped before the captured sink is read, and the | |
| 488 | + | // diagnostic that reaches the log must stay bounded and control-byte-free. Run | |
| 489 | + | // under -race with -count>1, this fails if the os/exec copy goroutine is ever | |
| 490 | + | // observed while it is still writing the sink. | |
| 491 | + | func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) { | |
| 492 | + | isolateSandboxEnv(t) | |
| 493 | + | installFakeSSH(t, fakeSSHFloodStderr) | |
| 494 | + | pidFile := filepath.Join(t.TempDir(), "ssh.pid") | |
| 495 | + | t.Setenv(fakeSSHPIDEnv, pidFile) | |
| 496 | + | ||
| 497 | + | route := loadTestRoute(t, map[string]any{ | |
| 498 | + | "kind": "ssh-quic", | |
| 499 | + | "target": "127.0.0.1", | |
| 500 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 501 | + | }) | |
| 502 | + | ||
| 503 | + | var logs bytes.Buffer | |
| 504 | + | logger := slog.New(slog.NewJSONHandler(&logs, nil)) | |
| 505 | + | ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) | |
| 506 | + | defer cancel() | |
| 507 | + | _, err := dialBrokerRoute(ctx, route, logger) | |
| 508 | + | require.Error(t, err) | |
| 509 | + | require.NotContains(t, err.Error(), "flood", "bootstrap stderr must never reach the error") | |
| 510 | + | ||
| 511 | + | // Reaping is observable: the flooding bootstrap is gone, not leaked. | |
| 512 | + | pid := readPID(t, pidFile) | |
| 513 | + | require.NoError(t, waitForProcessExit(pid, 3*time.Second), "the flooding bootstrap must be reaped") | |
| 514 | + | ||
| 515 | + | record := decodeBootstrapDiagnostic(t, logs.Bytes()) | |
| 516 | + | require.NotEmpty(t, record.Diagnostic) | |
| 517 | + | require.LessOrEqual(t, len(record.Diagnostic), 512, "the captured diagnostic must stay bounded") | |
| 518 | + | for _, r := range record.Diagnostic { | |
| 519 | + | require.False(t, unicode.IsControl(r) && r != '\t' && r != '\n', "control byte %q leaked into the diagnostic", r) | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // TestBrokerMuxDialStalledBootstrapUsesSetupTimeout proves the independent | |
| 524 | + | // broker-side setup timeout bounds the readiness wait: with no caller deadline a | |
| 525 | + | // helper that stalls without printing readiness is still interrupted within the | |
| 526 | + | // bound, and its child is killed and reaped. It is deterministic because the | |
| 527 | + | // effective bound is narrowed to a test value instead of the production one. | |
| 528 | + | func TestBrokerMuxDialStalledBootstrapUsesSetupTimeout(t *testing.T) { | |
| 529 | + | isolateSandboxEnv(t) | |
| 530 | + | installFakeSSH(t, fakeSSHStallNeverReady) | |
| 531 | + | pidFile := filepath.Join(t.TempDir(), "ssh.pid") | |
| 532 | + | t.Setenv(fakeSSHPIDEnv, pidFile) | |
| 533 | + | ||
| 534 | + | restore := brokerMuxBootstrapReadyTimeout | |
| 535 | + | brokerMuxBootstrapReadyTimeout = 250 * time.Millisecond | |
| 536 | + | t.Cleanup(func() { brokerMuxBootstrapReadyTimeout = restore }) | |
| 537 | + | ||
| 538 | + | route := loadTestRoute(t, map[string]any{ | |
| 539 | + | "kind": "ssh-quic", | |
| 540 | + | "target": "127.0.0.1", | |
| 541 | + | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 542 | + | }) | |
| 543 | + | start := time.Now() | |
| 544 | + | _, err := dialBrokerRoute(context.Background(), route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 545 | + | elapsed := time.Since(start) | |
| 546 | + | require.Error(t, err) | |
| 547 | + | require.Contains(t, err.Error(), "read bootstrap readiness") | |
| 548 | + | require.GreaterOrEqual(t, elapsed, 200*time.Millisecond, "the independent bound must not return early") | |
| 549 | + | require.Less(t, elapsed, 5*time.Second, "the independent bound must interrupt a stalled bootstrap") | |
| 550 | + | ||
| 551 | + | pid := readPID(t, pidFile) | |
| 552 | + | require.NoError(t, waitForProcessExit(pid, 3*time.Second), "the stalled bootstrap must be reaped") | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // TestBrokerServeUnixRouteRoundTrip proves the transport selector also handles | |
| 556 | + | // the explicit unix route variant, not just the legacy path string. | |
| 557 | + | func TestBrokerServeUnixRouteRoundTrip(t *testing.T) { | |
| 558 | + | env := newMuxTestEnv(t) | |
| 559 | + | fixture := newBrokerMuxFixture(t, brokerTestPolicy()) | |
| 560 | + | localRoot := writeMuxRoot(t, map[string]any{"kind": "unix", "path": fixture.route}) | |
| 561 | + | ||
| 562 | + | socketPath, stop := startMuxBrokerServe(t, localRoot) | |
| 563 | + | defer stop() | |
| 564 | + | requireBrokerPing(t, socketPath) | |
| 565 | + | require.Positive(t, fixture.streams.Load()) | |
| 566 | + | ||
| 567 | + | stop() | |
| 568 | + | env.requireProductionUntouched(t) | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | // startMuxBrokerServe starts one offline broker over localRoot and returns its | |
| 572 | + | // socket path plus an idempotent stop. | |
| 573 | + | func startMuxBrokerServe(t *testing.T, localRoot string) (string, func()) { | |
| 574 | + | t.Helper() | |
| 575 | + | deps, ready := testBrokerServeDeps(newSandboxClock()) | |
| 576 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: localRoot}, deps) | |
| 578 | + | socketPath := awaitSandboxReady(t, ready) | |
| 579 | + | var once bool | |
| 580 | + | stop := func() { | |
| 581 | + | if once { | |
| 582 | + | return | |
| 583 | + | } | |
| 584 | + | once = true | |
| 585 | + | awaitSandboxCancel(t, cancel, done) | |
| 586 | + | requireSocketRemoved(t, socketPath) | |
| 587 | + | } | |
| 588 | + | return socketPath, stop | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | // requireBrokerPing opens one control stream and requires one Pong. | |
| 592 | + | func requireBrokerPing(t *testing.T, socketPath string) { | |
| 593 | + | t.Helper() | |
| 594 | + | clientCtx, clientCancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 595 | + | defer clientCancel() | |
| 596 | + | service, err := brokeripc.Dial(clientCtx, socketPath, brokeripc.Config{}) | |
| 597 | + | require.NoError(t, err) | |
| 598 | + | defer func() { _ = service.Close() }() | |
| 599 | + | ||
| 600 | + | stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{ | |
| 601 | + | Purpose: ports.BrokerStreamControl, | |
| 602 | + | Endpoint: brokerTestEndpoint, | |
| 603 | + | Registration: brokerTestRegistration(), | |
| 604 | + | Policy: brokerTestPolicy(), | |
| 605 | + | }) | |
| 606 | + | require.NoError(t, err) | |
| 607 | + | defer func() { _ = stream.Close() }() | |
| 608 | + | ||
| 609 | + | type pingResult struct { | |
| 610 | + | message protocol.ServerMessage | |
| 611 | + | err error | |
| 612 | + | } | |
| 613 | + | result := make(chan pingResult, 1) | |
| 614 | + | go func() { | |
| 615 | + | if sendErr := stream.SendClient(protocol.Ping{}); sendErr != nil { | |
| 616 | + | result <- pingResult{err: sendErr} | |
| 617 | + | return | |
| 618 | + | } | |
| 619 | + | message, recvErr := stream.ReceiveServer() | |
| 620 | + | result <- pingResult{message: message, err: recvErr} | |
| 621 | + | }() | |
| 622 | + | select { | |
| 623 | + | case outcome := <-result: | |
| 624 | + | require.NoError(t, outcome.err) | |
| 625 | + | require.Equal(t, protocol.Pong{}, outcome.message) | |
| 626 | + | case <-time.After(brokerTestWait): | |
| 627 | + | t.Fatal("typed broker -> mux round trip timed out") | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | // requireBrokerErrorHasNoReadiness asserts no readiness secret or stderr leaks | |
| 632 | + | // into a returned error. | |
| 633 | + | func requireBrokerErrorHasNoReadiness(t *testing.T, err error, readiness quic.Readiness) { | |
| 634 | + | t.Helper() | |
| 635 | + | message := err.Error() | |
| 636 | + | require.NotContains(t, message, readiness.Token) | |
| 637 | + | require.NotContains(t, message, readiness.Nonce) | |
| 638 | + | require.NotContains(t, message, readiness.Fingerprint) | |
| 639 | + | require.NotContains(t, message, "token") | |
| 640 | + | require.NotContains(t, message, "nonce") | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | // requireProductionUntouched asserts no production runtime or state entry was | |
| 644 | + | // created by the composition or its helpers. | |
| 645 | + | func (e *muxTestEnv) requireProductionUntouched(t *testing.T) { | |
| 646 | + | t.Helper() | |
| 647 | + | requireProductionUntouched(t, e.prodRuntime, e.prodState) | |
| 648 | + | entries, err := os.ReadDir(e.prodRuntime) | |
| 649 | + | require.NoError(t, err) | |
| 650 | + | for _, entry := range entries { | |
| 651 | + | require.NotContains(t, entry.Name(), "daemon.sock", "helpers must never start an ordinary daemon") | |
| 652 | + | } | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func mustJSON(t *testing.T, value any) []byte { | |
| 656 | + | t.Helper() | |
| 657 | + | raw, err := json.Marshal(value) | |
| 658 | + | require.NoError(t, err) | |
| 659 | + | return raw | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | func readFileString(t *testing.T, path string) string { | |
| 663 | + | t.Helper() | |
| 664 | + | raw, err := os.ReadFile(path) | |
| 665 | + | require.NoError(t, err) | |
| 666 | + | return string(raw) | |
| 667 | + | } | |
| 668 | + | ||
| 669 | + | func readPID(t *testing.T, path string) int { | |
| 670 | + | t.Helper() | |
| 671 | + | deadline := time.Now().Add(3 * time.Second) | |
| 672 | + | for { | |
| 673 | + | raw, err := os.ReadFile(path) | |
| 674 | + | if err == nil { | |
| 675 | + | pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw))) | |
| 676 | + | require.NoError(t, convErr) | |
| 677 | + | return pid | |
| 678 | + | } | |
| 679 | + | if !time.Now().Before(deadline) { | |
| 680 | + | t.Fatalf("read pid file %s: %v", path, err) | |
| 681 | + | } | |
| 682 | + | time.Sleep(10 * time.Millisecond) | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | func indexOf(values []string, want string) int { | |
| 687 | + | for i, value := range values { | |
| 688 | + | if value == want { | |
| 689 | + | return i | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | return -1 | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | // bootstrapDiagnostic is one decoded broker_mux_bootstrap_stderr log record. | |
| 696 | + | type bootstrapDiagnostic struct { | |
| 697 | + | Message string `json:"msg"` | |
| 698 | + | Diagnostic string `json:"diagnostic"` | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | // decodeBootstrapDiagnostic decodes the single sanitized diagnostic the broker | |
| 702 | + | // logs for a failed bootstrap. | |
| 703 | + | func decodeBootstrapDiagnostic(t *testing.T, raw []byte) bootstrapDiagnostic { | |
| 704 | + | t.Helper() | |
| 705 | + | var record bootstrapDiagnostic | |
| 706 | + | require.NoError(t, json.Unmarshal(bytes.TrimSpace(raw), &record)) | |
| 707 | + | require.Equal(t, "broker_mux_bootstrap_stderr", record.Message) | |
| 708 | + | return record | |
| 709 | + | } | |
A internal/app/broker_offline_status.go +674 -0
| @@ -0,0 +1,674 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "io/fs" | |
| 11 | + | "net" | |
| 12 | + | "os" | |
| 13 | + | "os/exec" | |
| 14 | + | "os/signal" | |
| 15 | + | "strings" | |
| 16 | + | "syscall" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 21 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 22 | + | "github.com/bnema/vev/internal/platform" | |
| 23 | + | "github.com/bnema/vev/internal/ports" | |
| 24 | + | "github.com/bnema/vev/pkg/safedir" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Detached connect-or-spawn and status (Plan 001 P3.4 slice D). | |
| 28 | + | // | |
| 29 | + | // `_broker-launcher` is the intermediate half of a double fork: it starts one | |
| 30 | + | // hidden `_broker-serve` in a new session and exits immediately, exactly like | |
| 31 | + | // the daemon launcher, so the broker is reparented before the status caller | |
| 32 | + | // continues. `_broker-status` is the client-facing probe: without --ensure it | |
| 33 | + | // is dial-only and renders the observable state, and with --ensure it | |
| 34 | + | // coordinates a race-safe connect-or-spawn. | |
| 35 | + | // | |
| 36 | + | // Both commands are hidden from public help and change no ordinary command, | |
| 37 | + | // path, or factory. They operate only inside the operator-supplied offline | |
| 38 | + | // root; a root that overlaps the production runtime or state directories is | |
| 39 | + | // refused before anything is created. | |
| 40 | + | // | |
| 41 | + | // Race safety rests on two exclusive descriptor-backed locks that are never | |
| 42 | + | // stolen by age and whose inodes are never unlinked: | |
| 43 | + | // | |
| 44 | + | // - the broker lifetime lock (`layout.Runtime/lifecycle.lock`) is held by the | |
| 45 | + | // foreground `_broker-serve` for its whole run; a status probe uses it only | |
| 46 | + | // to detect that a broker already owns the sandbox and therefore must not | |
| 47 | + | // be duplicated. | |
| 48 | + | // - the spawn-election lock (`layout.Spawn/lifecycle.lock`) elects exactly | |
| 49 | + | // one status caller to spawn. Holding it across the readiness wait keeps | |
| 50 | + | // late callers waiting on the winner instead of racing a second spawn; if | |
| 51 | + | // the winner dies, the kernel releases the lock, so a waiter can take over | |
| 52 | + | // without ever unlinking or age-stealing the lock file. | |
| 53 | + | // | |
| 54 | + | // The elected spawner holds the election until the endpoint answers a valid | |
| 55 | + | // broker registration and publishes its initial snapshot, or until the one | |
| 56 | + | // absolute overall deadline expires. A live-but-incompatible endpoint and a | |
| 57 | + | // stalled handshake are diagnosed without spawning. | |
| 58 | + | ||
| 59 | + | const ( | |
| 60 | + | // brokerLauncherCommand is the hidden intermediate that starts one detached | |
| 61 | + | // `_broker-serve` and exits. | |
| 62 | + | brokerLauncherCommand = "_broker-launcher" | |
| 63 | + | // brokerStatusCommand is the hidden broker status probe. | |
| 64 | + | brokerStatusCommand = "_broker-status" | |
| 65 | + | ) | |
| 66 | + | ||
| 67 | + | // Bounds for the detached connect-or-spawn path. Every wait is bounded by the | |
| 68 | + | // one absolute overall deadline, and the first dial is bounded on its own so a | |
| 69 | + | // stalled endpoint cannot consume the whole budget before classification. | |
| 70 | + | const ( | |
| 71 | + | brokerStatusDefaultTimeout = 10 * time.Second | |
| 72 | + | brokerStatusProbeBudget = 2 * time.Second | |
| 73 | + | brokerStatusInitialBackoff = 5 * time.Millisecond | |
| 74 | + | brokerStatusMaxBackoff = 50 * time.Millisecond | |
| 75 | + | brokerStatusMaxReportBytes = 4 << 10 | |
| 76 | + | ) | |
| 77 | + | ||
| 78 | + | var ( | |
| 79 | + | // errBrokerAbsent reports that no broker is bound at the endpoint, so a | |
| 80 | + | // spawn may recover it. A missing socket and a stale socket left by a dead | |
| 81 | + | // owner are both absence. | |
| 82 | + | errBrokerAbsent = errors.New("vev: broker endpoint is absent") | |
| 83 | + | // errBrokerIncompatible reports that the endpoint exists but is not a | |
| 84 | + | // compatible broker (a foreign path, a live endpoint that fails the broker | |
| 85 | + | // handshake, or a handshake that stalls past its bound). It is never an | |
| 86 | + | // invitation to spawn. | |
| 87 | + | errBrokerIncompatible = errors.New("vev: broker endpoint is not a compatible broker") | |
| 88 | + | // errBrokerNotReady reports that --ensure did not observe a ready broker | |
| 89 | + | // before the overall deadline. | |
| 90 | + | errBrokerNotReady = errors.New("vev: broker did not become ready") | |
| 91 | + | ) | |
| 92 | + | ||
| 93 | + | // brokerLauncherOptions is the parsed `_broker-launcher` invocation. | |
| 94 | + | type brokerLauncherOptions struct { | |
| 95 | + | offlineRoot string | |
| 96 | + | idleGrace time.Duration | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // brokerStatusOptions is the parsed `_broker-status` invocation. | |
| 100 | + | type brokerStatusOptions struct { | |
| 101 | + | offlineRoot string | |
| 102 | + | ensure bool | |
| 103 | + | timeout time.Duration | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // brokerStatusReport is the bounded JSON status document. It carries exactly | |
| 107 | + | // the ready/offline state, the broker endpoint, and the broker's epoch, | |
| 108 | + | // revision, and host count. Epoch, revision, and host count are zero when the | |
| 109 | + | // broker is offline. | |
| 110 | + | type brokerStatusReport struct { | |
| 111 | + | Status string `json:"status"` | |
| 112 | + | Endpoint string `json:"endpoint"` | |
| 113 | + | Epoch ports.BrokerEpoch `json:"epoch"` | |
| 114 | + | Revision ports.BrokerRevision `json:"revision"` | |
| 115 | + | HostCount int `json:"host_count"` | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // brokerStatusLock is one held exclusive election lock. | |
| 119 | + | type brokerStatusLock interface { | |
| 120 | + | Release() error | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // brokerStatusDeps are the injectable seams of `_broker-status`. Every seam has | |
| 124 | + | // a production default; tests substitute them to drive the election, deadline, | |
| 125 | + | // and classification deterministically. | |
| 126 | + | type brokerStatusDeps struct { | |
| 127 | + | now func() time.Time | |
| 128 | + | wait func(context.Context, time.Duration) error | |
| 129 | + | probe func(context.Context, string) (brokerStatusReport, error) | |
| 130 | + | acquireElection func(string) (brokerStatusLock, error) | |
| 131 | + | lifetimeOwner func(string) (bool, error) | |
| 132 | + | spawn func(ctx context.Context, root string, grace time.Duration, graceSet bool) error | |
| 133 | + | stdout io.Writer | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // defaultBrokerStatusDeps returns the production seams. | |
| 137 | + | func defaultBrokerStatusDeps() brokerStatusDeps { | |
| 138 | + | return brokerStatusDeps{ | |
| 139 | + | now: time.Now, | |
| 140 | + | wait: waitBackoff, | |
| 141 | + | probe: probeBrokerStatus, | |
| 142 | + | acquireElection: func(dir string) (brokerStatusLock, error) { | |
| 143 | + | return lifecycle.TryAcquire(dir) | |
| 144 | + | }, | |
| 145 | + | lifetimeOwner: func(runtimeDir string) (bool, error) { | |
| 146 | + | owner, err := lifecycle.TryAcquire(runtimeDir) | |
| 147 | + | if err != nil { | |
| 148 | + | if errors.Is(err, lifecycle.ErrBusy) { | |
| 149 | + | return true, nil | |
| 150 | + | } | |
| 151 | + | return false, err | |
| 152 | + | } | |
| 153 | + | return false, owner.Release() | |
| 154 | + | }, | |
| 155 | + | spawn: spawnBrokerLauncher, | |
| 156 | + | stdout: os.Stdout, | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // parseBrokerLauncherArgs strictly parses | |
| 161 | + | // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags, | |
| 162 | + | // duplicate flags, positionals, and non-positive durations are refused. | |
| 163 | + | func parseBrokerLauncherArgs(args []string) (command, error) { | |
| 164 | + | var options brokerLauncherOptions | |
| 165 | + | var seenRoot, seenGrace bool | |
| 166 | + | for index := 0; index < len(args); index++ { | |
| 167 | + | switch args[index] { | |
| 168 | + | case "--offline-root": | |
| 169 | + | if seenRoot { | |
| 170 | + | return command{}, usagef("`%s` received duplicate --offline-root", brokerLauncherCommand) | |
| 171 | + | } | |
| 172 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 173 | + | return command{}, usagef("`--offline-root` requires a path") | |
| 174 | + | } | |
| 175 | + | options.offlineRoot = args[index+1] | |
| 176 | + | seenRoot = true | |
| 177 | + | index++ | |
| 178 | + | case "--idle-grace": | |
| 179 | + | if seenGrace { | |
| 180 | + | return command{}, usagef("`%s` received duplicate --idle-grace", brokerLauncherCommand) | |
| 181 | + | } | |
| 182 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 183 | + | return command{}, usagef("`--idle-grace` requires a duration") | |
| 184 | + | } | |
| 185 | + | grace, err := time.ParseDuration(args[index+1]) | |
| 186 | + | if err != nil { | |
| 187 | + | return command{}, usagef("`--idle-grace` %q is not a duration", args[index+1]) | |
| 188 | + | } | |
| 189 | + | if grace <= 0 { | |
| 190 | + | return command{}, usagef("`--idle-grace` must be positive") | |
| 191 | + | } | |
| 192 | + | options.idleGrace = grace | |
| 193 | + | seenGrace = true | |
| 194 | + | index++ | |
| 195 | + | default: | |
| 196 | + | if strings.HasPrefix(args[index], "-") { | |
| 197 | + | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerLauncherCommand) | |
| 198 | + | } | |
| 199 | + | return command{}, usagef("`%s` does not accept positional arguments", brokerLauncherCommand) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if options.offlineRoot == "" { | |
| 203 | + | return command{}, usagef("`%s` requires --offline-root", brokerLauncherCommand) | |
| 204 | + | } | |
| 205 | + | return command{kind: kindBrokerLauncher, brokerLauncher: options}, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // parseBrokerStatusArgs strictly parses | |
| 209 | + | // `_broker-status --offline-root ABS [--ensure] [--timeout DURATION]`. Unknown | |
| 210 | + | // flags, duplicate flags, positionals, and non-positive timeouts are refused. | |
| 211 | + | func parseBrokerStatusArgs(args []string) (command, error) { | |
| 212 | + | options := brokerStatusOptions{timeout: brokerStatusDefaultTimeout} | |
| 213 | + | var seenRoot, seenEnsure, seenTimeout bool | |
| 214 | + | for index := 0; index < len(args); index++ { | |
| 215 | + | switch args[index] { | |
| 216 | + | case "--offline-root": | |
| 217 | + | if seenRoot { | |
| 218 | + | return command{}, usagef("`%s` received duplicate --offline-root", brokerStatusCommand) | |
| 219 | + | } | |
| 220 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 221 | + | return command{}, usagef("`--offline-root` requires a path") | |
| 222 | + | } | |
| 223 | + | options.offlineRoot = args[index+1] | |
| 224 | + | seenRoot = true | |
| 225 | + | index++ | |
| 226 | + | case "--ensure": | |
| 227 | + | if seenEnsure { | |
| 228 | + | return command{}, usagef("`%s` received duplicate --ensure", brokerStatusCommand) | |
| 229 | + | } | |
| 230 | + | options.ensure = true | |
| 231 | + | seenEnsure = true | |
| 232 | + | case "--timeout": | |
| 233 | + | if seenTimeout { | |
| 234 | + | return command{}, usagef("`%s` received duplicate --timeout", brokerStatusCommand) | |
| 235 | + | } | |
| 236 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 237 | + | return command{}, usagef("`--timeout` requires a duration") | |
| 238 | + | } | |
| 239 | + | timeout, err := time.ParseDuration(args[index+1]) | |
| 240 | + | if err != nil { | |
| 241 | + | return command{}, usagef("`--timeout` %q is not a duration", args[index+1]) | |
| 242 | + | } | |
| 243 | + | if timeout <= 0 { | |
| 244 | + | return command{}, usagef("`--timeout` must be positive") | |
| 245 | + | } | |
| 246 | + | options.timeout = timeout | |
| 247 | + | seenTimeout = true | |
| 248 | + | index++ | |
| 249 | + | default: | |
| 250 | + | if strings.HasPrefix(args[index], "-") { | |
| 251 | + | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerStatusCommand) | |
| 252 | + | } | |
| 253 | + | return command{}, usagef("`%s` does not accept positional arguments", brokerStatusCommand) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | if options.offlineRoot == "" { | |
| 257 | + | return command{}, usagef("`%s` requires --offline-root", brokerStatusCommand) | |
| 258 | + | } | |
| 259 | + | return command{kind: kindBrokerStatus, brokerStatus: options}, nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | |
| 263 | + | // command and never runs in production flows. | |
| 264 | + | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | |
| 265 | + | return runBrokerLauncher(options) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // runBrokerLauncher validates the sandbox configuration, diagnoses an explicit | |
| 269 | + | // idle grace that conflicts with the provisioned one, then starts one detached | |
| 270 | + | // `_broker-serve` in a new session and returns. | |
| 271 | + | func runBrokerLauncher(options brokerLauncherOptions) error { | |
| 272 | + | layout, err := offlineLayout(options.offlineRoot) | |
| 273 | + | if err != nil { | |
| 274 | + | return err | |
| 275 | + | } | |
| 276 | + | if err := safedir.EnsurePrivate(layout.Root); err != nil { | |
| 277 | + | return fmt.Errorf("vev: secure broker sandbox root: %w", err) | |
| 278 | + | } | |
| 279 | + | config, err := brokerconfig.Load(layout) | |
| 280 | + | if err != nil { | |
| 281 | + | return err | |
| 282 | + | } | |
| 283 | + | grace, err := effectiveIdleGrace(config, options.idleGrace) | |
| 284 | + | if err != nil { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | args := []string{brokerServeCommand, "--offline-root", options.offlineRoot} | |
| 288 | + | if grace > 0 { | |
| 289 | + | args = append(args, "--idle-grace", grace.String()) | |
| 290 | + | } | |
| 291 | + | return startDetachedBrokerServe(args) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | |
| 295 | + | // releases it, so the broker outlives the short-lived launcher. It mirrors the | |
| 296 | + | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | |
| 297 | + | // the performance-trace inputs, stdio is /dev/null, and the process is | |
| 298 | + | // reparented immediately. | |
| 299 | + | func startDetachedBrokerServe(args []string) error { | |
| 300 | + | exePath, err := selfExePath() | |
| 301 | + | if err != nil { | |
| 302 | + | return fmt.Errorf("vev: resolving executable path: %w", err) | |
| 303 | + | } | |
| 304 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 305 | + | if err != nil { | |
| 306 | + | return fmt.Errorf("vev: opening %s: %w", os.DevNull, err) | |
| 307 | + | } | |
| 308 | + | defer func() { _ = devNull.Close() }() | |
| 309 | + | ||
| 310 | + | cmd := exec.Command(exePath, args...) | |
| 311 | + | cmd.Env = withoutPerformanceTraceEnv(os.Environ()) | |
| 312 | + | cmd.Dir = platform.DirOrHome("") | |
| 313 | + | cmd.Stdin = devNull | |
| 314 | + | cmd.Stdout = devNull | |
| 315 | + | cmd.Stderr = devNull | |
| 316 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 317 | + | if err := cmd.Start(); err != nil { | |
| 318 | + | return fmt.Errorf("vev: starting detached broker: %w", err) | |
| 319 | + | } | |
| 320 | + | return cmd.Process.Release() | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // spawnBrokerLauncher runs the hidden launcher, bounded by ctx, and waits for | |
| 324 | + | // it to exit, so the broker has been reparented before the caller retries the | |
| 325 | + | // endpoint. exec.CommandContext is deliberate: the overall status deadline or a | |
| 326 | + | // signal kills a launcher that is still waiting, while killing the launcher | |
| 327 | + | // after it has started the detached `_broker-serve` does not touch that serve -- | |
| 328 | + | // it is in its own session and was released, so it survives. | |
| 329 | + | func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, graceSet bool) error { | |
| 330 | + | exePath, err := selfExePath() | |
| 331 | + | if err != nil { | |
| 332 | + | return fmt.Errorf("vev: resolving executable path: %w", err) | |
| 333 | + | } | |
| 334 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 335 | + | if err != nil { | |
| 336 | + | return fmt.Errorf("vev: opening %s: %w", os.DevNull, err) | |
| 337 | + | } | |
| 338 | + | defer func() { _ = devNull.Close() }() | |
| 339 | + | ||
| 340 | + | args := []string{brokerLauncherCommand, "--offline-root", root} | |
| 341 | + | if graceSet { | |
| 342 | + | args = append(args, "--idle-grace", grace.String()) | |
| 343 | + | } | |
| 344 | + | var stderr bytes.Buffer | |
| 345 | + | cmd := exec.CommandContext(ctx, exePath, args...) | |
| 346 | + | cmd.Env = withoutPerformanceTraceEnv(os.Environ()) | |
| 347 | + | cmd.Dir = platform.DirOrHome("") | |
| 348 | + | cmd.Stdin = devNull | |
| 349 | + | cmd.Stdout = devNull | |
| 350 | + | cmd.Stderr = &stderr | |
| 351 | + | if err := cmd.Run(); err != nil { | |
| 352 | + | if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 { | |
| 353 | + | return fmt.Errorf("vev: launching broker: %w: %s", err, detail) | |
| 354 | + | } | |
| 355 | + | return fmt.Errorf("vev: launching broker: %w", err) | |
| 356 | + | } | |
| 357 | + | return nil | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // runBrokerStatusCommand runs the hidden status probe, bounded by a signal-aware | |
| 361 | + | // parent context. | |
| 362 | + | func runBrokerStatusCommand(ctx context.Context, options brokerStatusOptions) error { | |
| 363 | + | ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 364 | + | defer stop() | |
| 365 | + | return runBrokerStatus(ctx, options, defaultBrokerStatusDeps()) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // runBrokerStatus renders the broker status as one bounded JSON document. | |
| 369 | + | // | |
| 370 | + | // Without --ensure it is dial-only: it performs one probe bounded by | |
| 371 | + | // --timeout and always succeeds, reporting ready or offline, so the JSON is the | |
| 372 | + | // sole result and a caller never has to interpret an exit code to read a state. | |
| 373 | + | // The timeout is the single absolute probe bound; it is never silently replaced | |
| 374 | + | // by the ensure probe budget, so a caller can tighten it (or rely on the | |
| 375 | + | // default) and observe the same bound. With --ensure it uses one absolute | |
| 376 | + | // overall deadline; on success it prints the ready report and | |
| 377 | + | // returns nil, and on any layout, configuration, or startup failure it still | |
| 378 | + | // prints the offline report but returns a code-3 error so the caller always | |
| 379 | + | // observes one offline JSON document and a distinct exit code. | |
| 380 | + | func runBrokerStatus(ctx context.Context, options brokerStatusOptions, deps brokerStatusDeps) error { | |
| 381 | + | layout, err := offlineLayout(options.offlineRoot) | |
| 382 | + | if err != nil { | |
| 383 | + | if options.ensure { | |
| 384 | + | return ensureOfflineFailure(deps.stdout, brokerStatusReport{Status: "offline"}, err) | |
| 385 | + | } | |
| 386 | + | return err | |
| 387 | + | } | |
| 388 | + | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 389 | + | offline := brokerStatusReport{Status: "offline", Endpoint: socketPath} | |
| 390 | + | ||
| 391 | + | if !options.ensure { | |
| 392 | + | probeCtx, cancel := context.WithTimeout(ctx, options.timeout) | |
| 393 | + | defer cancel() | |
| 394 | + | report, err := deps.probe(probeCtx, socketPath) | |
| 395 | + | if err != nil { | |
| 396 | + | // Dial-only renders the observable state: absence and an | |
| 397 | + | // incompatible live endpoint both read as offline. | |
| 398 | + | return writeBrokerStatusReport(deps.stdout, offline) | |
| 399 | + | } | |
| 400 | + | return writeBrokerStatusReport(deps.stdout, report) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // Validate and secure the sandbox root exactly as the launcher does before | |
| 404 | + | // the spawn directory is created or any lock is taken, so status --ensure can | |
| 405 | + | // never operate on paths the launcher would refuse. | |
| 406 | + | if err := safedir.EnsurePrivate(layout.Root); err != nil { | |
| 407 | + | return ensureOfflineFailure(deps.stdout, offline, fmt.Errorf("vev: secure broker sandbox root: %w", err)) | |
| 408 | + | } | |
| 409 | + | config, err := brokerconfig.Load(layout) | |
| 410 | + | if err != nil { | |
| 411 | + | return ensureOfflineFailure(deps.stdout, offline, err) | |
| 412 | + | } | |
| 413 | + | grace, graceSet := config.IdleGrace() | |
| 414 | + | ||
| 415 | + | deadline := deps.now().Add(options.timeout) | |
| 416 | + | report, err := ensureBrokerReady(ctx, brokerStatusRequest{ | |
| 417 | + | layout: layout, | |
| 418 | + | socketPath: socketPath, | |
| 419 | + | deadline: deadline, | |
| 420 | + | timeout: options.timeout, | |
| 421 | + | root: options.offlineRoot, | |
| 422 | + | grace: grace, | |
| 423 | + | graceSet: graceSet, | |
| 424 | + | }, deps) | |
| 425 | + | if err != nil { | |
| 426 | + | return ensureOfflineFailure(deps.stdout, offline, err) | |
| 427 | + | } | |
| 428 | + | return writeBrokerStatusReport(deps.stdout, report) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // ensureOfflineFailure renders one offline report and returns the ensure failure | |
| 432 | + | // wrapped in exit code 3, so a layout, configuration, or startup failure always | |
| 433 | + | // leaves the caller with the offline JSON document and a distinct exit code. | |
| 434 | + | func ensureOfflineFailure(stdout io.Writer, offline brokerStatusReport, err error) error { | |
| 435 | + | if writeErr := writeBrokerStatusReport(stdout, offline); writeErr != nil { | |
| 436 | + | return errors.Join(err, writeErr) | |
| 437 | + | } | |
| 438 | + | return &exitCoded{code: 3, err: err} | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // brokerStatusRequest carries one ensure run's fixed inputs. | |
| 442 | + | type brokerStatusRequest struct { | |
| 443 | + | layout brokerconfig.Layout | |
| 444 | + | socketPath string | |
| 445 | + | deadline time.Time | |
| 446 | + | timeout time.Duration | |
| 447 | + | root string | |
| 448 | + | grace time.Duration | |
| 449 | + | graceSet bool | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // ensureBrokerReady connects to an already-ready broker or elects one spawner | |
| 453 | + | // and holds the election until the endpoint answers a valid broker registration | |
| 454 | + | // and publishes its initial snapshot, or until the absolute deadline expires. | |
| 455 | + | func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps brokerStatusDeps) (brokerStatusReport, error) { | |
| 456 | + | ctx, cancel := context.WithDeadline(ctx, req.deadline) | |
| 457 | + | defer cancel() | |
| 458 | + | ||
| 459 | + | // Secure the sandbox paths before any lock is taken: the spawn-election | |
| 460 | + | // directory lives beneath the runtime directory, and both must be private | |
| 461 | + | // and symlink-free before they can hold an exclusive lock. | |
| 462 | + | if err := safedir.EnsurePrivate(req.layout.Spawn); err != nil { | |
| 463 | + | return brokerStatusReport{}, fmt.Errorf("vev: secure broker sandbox spawn directory: %w", err) | |
| 464 | + | } | |
| 465 | + | if err := req.layout.VerifyCreated(); err != nil { | |
| 466 | + | return brokerStatusReport{}, fmt.Errorf("vev: verify broker sandbox paths: %w", err) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | var held brokerStatusLock | |
| 470 | + | defer func() { | |
| 471 | + | if held != nil { | |
| 472 | + | _ = held.Release() | |
| 473 | + | } | |
| 474 | + | }() | |
| 475 | + | spawned := false | |
| 476 | + | backoff := brokerStatusInitialBackoff | |
| 477 | + | ||
| 478 | + | for { | |
| 479 | + | if err := ctx.Err(); err != nil { | |
| 480 | + | return brokerStatusReport{}, err | |
| 481 | + | } | |
| 482 | + | if !deps.now().Before(req.deadline) { | |
| 483 | + | return brokerStatusReport{}, fmt.Errorf("%w: endpoint %s did not answer Register within %s", errBrokerNotReady, req.socketPath, req.timeout) | |
| 484 | + | } | |
| 485 | + | report, err := probeWithin(ctx, deps, req.socketPath, req.deadline) | |
| 486 | + | if err == nil { | |
| 487 | + | return report, nil | |
| 488 | + | } | |
| 489 | + | if ctx.Err() != nil { | |
| 490 | + | return brokerStatusReport{}, ctx.Err() | |
| 491 | + | } | |
| 492 | + | // A probe that finished after the deadline is a bounded failure, never | |
| 493 | + | // an absence that would justify one more spawn. | |
| 494 | + | if !deps.now().Before(req.deadline) { | |
| 495 | + | return brokerStatusReport{}, fmt.Errorf("%w: endpoint %s did not answer Register within %s", errBrokerNotReady, req.socketPath, req.timeout) | |
| 496 | + | } | |
| 497 | + | if !errors.Is(err, errBrokerAbsent) { | |
| 498 | + | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w", req.socketPath, err) | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | if held == nil { | |
| 502 | + | lock, err := deps.acquireElection(req.layout.Spawn) | |
| 503 | + | switch { | |
| 504 | + | case err == nil: | |
| 505 | + | held = lock | |
| 506 | + | // Re-dial after winning the election: the previous holder may | |
| 507 | + | // already have published readiness. | |
| 508 | + | report, err := probeWithin(ctx, deps, req.socketPath, req.deadline) | |
| 509 | + | if err == nil { | |
| 510 | + | return report, nil | |
| 511 | + | } | |
| 512 | + | if ctx.Err() != nil { | |
| 513 | + | return brokerStatusReport{}, ctx.Err() | |
| 514 | + | } | |
| 515 | + | if !errors.Is(err, errBrokerAbsent) { | |
| 516 | + | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w", req.socketPath, err) | |
| 517 | + | } | |
| 518 | + | case errors.Is(err, lifecycle.ErrBusy): | |
| 519 | + | // Another caller is elected and spawning. Only the elected | |
| 520 | + | // spawner ever probes the broker lifetime lock, so a waiter can | |
| 521 | + | // never briefly steal ownership from the broker it is waiting for. | |
| 522 | + | if err := deps.wait(ctx, backoff); err != nil { | |
| 523 | + | return brokerStatusReport{}, err | |
| 524 | + | } | |
| 525 | + | backoff *= 2 | |
| 526 | + | if backoff > brokerStatusMaxBackoff { | |
| 527 | + | backoff = brokerStatusMaxBackoff | |
| 528 | + | } | |
| 529 | + | continue | |
| 530 | + | default: | |
| 531 | + | return brokerStatusReport{}, fmt.Errorf("vev: elect broker spawner: %w", err) | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | if held != nil && !spawned { | |
| 535 | + | busy, err := deps.lifetimeOwner(req.layout.Runtime) | |
| 536 | + | if err != nil { | |
| 537 | + | return brokerStatusReport{}, fmt.Errorf("vev: inspect broker lifetime owner: %w", err) | |
| 538 | + | } | |
| 539 | + | if !busy { | |
| 540 | + | if err := deps.spawn(ctx, req.root, req.grace, req.graceSet); err != nil { | |
| 541 | + | return brokerStatusReport{}, err | |
| 542 | + | } | |
| 543 | + | spawned = true | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | if err := deps.wait(ctx, backoff); err != nil { | |
| 548 | + | return brokerStatusReport{}, err | |
| 549 | + | } | |
| 550 | + | backoff *= 2 | |
| 551 | + | if backoff > brokerStatusMaxBackoff { | |
| 552 | + | backoff = brokerStatusMaxBackoff | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | // probeWithin bounds one probe by the smaller of the probe budget and the | |
| 558 | + | // remaining overall time. | |
| 559 | + | func probeWithin(ctx context.Context, deps brokerStatusDeps, socketPath string, deadline time.Time) (brokerStatusReport, error) { | |
| 560 | + | budget := brokerStatusProbeBudget | |
| 561 | + | if remaining := deadline.Sub(deps.now()); remaining < budget { | |
| 562 | + | budget = remaining | |
| 563 | + | } | |
| 564 | + | if budget <= 0 { | |
| 565 | + | return brokerStatusReport{}, context.DeadlineExceeded | |
| 566 | + | } | |
| 567 | + | probeCtx, cancel := context.WithTimeout(ctx, budget) | |
| 568 | + | defer cancel() | |
| 569 | + | return deps.probe(probeCtx, socketPath) | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | // probeBrokerStatus is the production probe: it classifies endpoint absence | |
| 573 | + | // first, dials with the bounded broker setup, then waits for the broker to | |
| 574 | + | // register the connection and publish its initial snapshot. | |
| 575 | + | func probeBrokerStatus(ctx context.Context, socketPath string) (brokerStatusReport, error) { | |
| 576 | + | info, err := os.Lstat(socketPath) | |
| 577 | + | switch { | |
| 578 | + | case errors.Is(err, fs.ErrNotExist): | |
| 579 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 580 | + | case err != nil: | |
| 581 | + | return brokerStatusReport{}, fmt.Errorf("vev: inspect broker endpoint %s: %w", socketPath, err) | |
| 582 | + | case info.Mode()&os.ModeSocket == 0: | |
| 583 | + | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("path is not a Unix socket")) | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | service, err := brokeripc.Dial(ctx, socketPath, brokeripc.Config{}) | |
| 587 | + | if err != nil { | |
| 588 | + | if backendAbsent(err) { | |
| 589 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 590 | + | } | |
| 591 | + | if ctx.Err() != nil { | |
| 592 | + | return brokerStatusReport{}, ctx.Err() | |
| 593 | + | } | |
| 594 | + | // The endpoint may have retired between the stat and the dial; re-check | |
| 595 | + | // so a broker that just shut down is classified as absent, not as an | |
| 596 | + | // incompatible endpoint that must never be respawned. | |
| 597 | + | if _, statErr := os.Lstat(socketPath); errors.Is(statErr, fs.ErrNotExist) { | |
| 598 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 599 | + | } | |
| 600 | + | return brokerStatusReport{}, incompatibleError(socketPath, err) | |
| 601 | + | } | |
| 602 | + | defer func() { _ = service.Close() }() | |
| 603 | + | ||
| 604 | + | sub, err := service.Subscribe() | |
| 605 | + | if err != nil { | |
| 606 | + | return brokerStatusReport{}, incompatibleError(socketPath, err) | |
| 607 | + | } | |
| 608 | + | defer sub.Close() | |
| 609 | + | changed := sub.Changed() | |
| 610 | + | for { | |
| 611 | + | if snapshot := service.Snapshot(); snapshot.Revision != 0 { | |
| 612 | + | return readyBrokerStatus(socketPath, snapshot), nil | |
| 613 | + | } | |
| 614 | + | select { | |
| 615 | + | case <-ctx.Done(): | |
| 616 | + | return brokerStatusReport{}, ctx.Err() | |
| 617 | + | case _, ok := <-changed: | |
| 618 | + | if ok { | |
| 619 | + | continue | |
| 620 | + | } | |
| 621 | + | if snapshot := service.Snapshot(); snapshot.Revision != 0 { | |
| 622 | + | return readyBrokerStatus(socketPath, snapshot), nil | |
| 623 | + | } | |
| 624 | + | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("connection ended before the initial snapshot")) | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | // readyBrokerStatus projects one committed snapshot into the ready report. | |
| 630 | + | func readyBrokerStatus(socketPath string, snapshot ports.BrokerSnapshot) brokerStatusReport { | |
| 631 | + | return brokerStatusReport{ | |
| 632 | + | Status: "ready", | |
| 633 | + | Endpoint: socketPath, | |
| 634 | + | Epoch: snapshot.Epoch, | |
| 635 | + | Revision: snapshot.Revision, | |
| 636 | + | HostCount: len(snapshot.Hosts), | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | // backendAbsent reports whether one dial failure means no broker is bound: | |
| 641 | + | // a missing socket or a stale socket whose dead owner left the pathname behind. | |
| 642 | + | // Any other dial failure is a live endpoint that must not be respawned. | |
| 643 | + | func backendAbsent(err error) bool { | |
| 644 | + | var opErr *net.OpError | |
| 645 | + | if !errors.As(err, &opErr) { | |
| 646 | + | return false | |
| 647 | + | } | |
| 648 | + | return errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | // absentError wraps one recoverable-absence diagnosis. | |
| 652 | + | func absentError(socketPath string) error { | |
| 653 | + | return fmt.Errorf("%w: %s", errBrokerAbsent, socketPath) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | // incompatibleError wraps one live-but-incompatible diagnosis. | |
| 657 | + | func incompatibleError(socketPath string, cause error) error { | |
| 658 | + | return fmt.Errorf("%w: %s: %w", errBrokerIncompatible, socketPath, cause) | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // writeBrokerStatusReport writes exactly one bounded JSON document. | |
| 662 | + | func writeBrokerStatusReport(w io.Writer, report brokerStatusReport) error { | |
| 663 | + | data, err := json.Marshal(report) | |
| 664 | + | if err != nil { | |
| 665 | + | return fmt.Errorf("vev: encode broker status: %w", err) | |
| 666 | + | } | |
| 667 | + | if len(data) > brokerStatusMaxReportBytes { | |
| 668 | + | return errors.New("vev: broker status report exceeds the bound") | |
| 669 | + | } | |
| 670 | + | if _, err := w.Write(append(data, '\n')); err != nil { | |
| 671 | + | return fmt.Errorf("vev: write broker status: %w", err) | |
| 672 | + | } | |
| 673 | + | return nil | |
| 674 | + | } | |
A internal/app/broker_offline_status_subprocess_test.go +436 -0
| @@ -0,0 +1,436 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | "syscall" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 21 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 22 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Real-subprocess coverage for the hidden launcher and status entry points. | |
| 26 | + | // | |
| 27 | + | // These tests re-execute the test binary through the production dispatch path: | |
| 28 | + | // TestMain intercepts the hidden broker commands when brokerHelperEnv is set and | |
| 29 | + | // runs app.Run, so `_broker-status` really elects a spawner, `_broker-launcher` | |
| 30 | + | // really double-forks, and `_broker-serve` really composes the offline broker. | |
| 31 | + | // Every helper records its PID under brokerHelperRecordDirEnv, so a test can | |
| 32 | + | // observe how many processes were spawned and terminate every one of them. | |
| 33 | + | ||
| 34 | + | const ( | |
| 35 | + | brokerHelperEnv = "VEV_BROKER_HELPER" | |
| 36 | + | brokerHelperRecordDirEnv = "VEV_BROKER_HELPER_RECORD_DIR" | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // Controlled-launcher test env: when set, the hidden `_broker-launcher` helper | |
| 40 | + | // records its PID and waits for the release marker instead of running the real | |
| 41 | + | // double-fork, so a test can hold a launcher open and prove the spawn wait is | |
| 42 | + | // bounded by its context. | |
| 43 | + | const ( | |
| 44 | + | brokerLauncherBlockRecordEnv = "VEV_BROKER_LAUNCHER_BLOCK_RECORD" | |
| 45 | + | brokerLauncherBlockReleaseEnv = "VEV_BROKER_LAUNCHER_BLOCK_RELEASE" | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | // recordBrokerHelperProcess appends one helper process's PID, so a test can | |
| 49 | + | // discover and later terminate a detached broker or launcher. | |
| 50 | + | func recordBrokerHelperProcess(role string) { | |
| 51 | + | dir := os.Getenv(brokerHelperRecordDirEnv) | |
| 52 | + | if dir == "" { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | file, err := os.OpenFile(filepath.Join(dir, role+".pids"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) | |
| 56 | + | if err != nil { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | defer func() { _ = file.Close() }() | |
| 60 | + | _, _ = fmt.Fprintf(file, "%d\n", os.Getpid()) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // shortTempDir creates one temporary directory with a short absolute path. | |
| 64 | + | // | |
| 65 | + | // The AF_UNIX pathname limit is about 103 bytes, and t.TempDir embeds the full | |
| 66 | + | // test name; the broker socket lives several components beneath the sandbox | |
| 67 | + | // root, so a long test name would overflow the socket path. A short base keeps | |
| 68 | + | // the real subprocess tests honest. | |
| 69 | + | func shortTempDir(t *testing.T, prefix string) string { | |
| 70 | + | t.Helper() | |
| 71 | + | dir, err := os.MkdirTemp("", prefix) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |
| 74 | + | return dir | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // brokerSubprocessSandbox isolates one real offline root and records every | |
| 78 | + | // spawned helper process. | |
| 79 | + | type brokerSubprocessSandbox struct { | |
| 80 | + | t *testing.T | |
| 81 | + | root string | |
| 82 | + | layout brokerconfig.Layout | |
| 83 | + | recordDir string | |
| 84 | + | prodRuntime string | |
| 85 | + | prodState string | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // newBrokerSubprocessSandbox isolates XDG roots, writes one marker-valid empty | |
| 89 | + | // sandbox, and arranges cleanup of every recorded broker and launcher process. | |
| 90 | + | func newBrokerSubprocessSandbox(t *testing.T, idleGrace string) *brokerSubprocessSandbox { | |
| 91 | + | t.Helper() | |
| 92 | + | prodRuntime, prodState := t.TempDir(), t.TempDir() | |
| 93 | + | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |
| 94 | + | t.Setenv("XDG_STATE_HOME", prodState) | |
| 95 | + | root := filepath.Join(shortTempDir(t, "vevb"), "s") | |
| 96 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 97 | + | writeSandboxConfig(t, root, sandboxEmptyDocument(idleGrace)) | |
| 98 | + | recordDir := filepath.Join(shortTempDir(t, "vevr"), "r") | |
| 99 | + | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | |
| 100 | + | t.Setenv(brokerHelperEnv, "1") | |
| 101 | + | t.Setenv(brokerHelperRecordDirEnv, recordDir) | |
| 102 | + | layout, err := offlineLayout(root) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | sandbox := &brokerSubprocessSandbox{t: t, root: root, layout: layout, recordDir: recordDir, prodRuntime: prodRuntime, prodState: prodState} | |
| 105 | + | t.Cleanup(sandbox.cleanup) | |
| 106 | + | return sandbox | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // runBrokerCommand executes one helper through the test binary. | |
| 110 | + | func runBrokerCommand(t *testing.T, args ...string) (stdout, stderr string, code int) { | |
| 111 | + | t.Helper() | |
| 112 | + | cmd := exec.Command(os.Args[0], args...) | |
| 113 | + | cmd.Env = os.Environ() | |
| 114 | + | var out, errb bytes.Buffer | |
| 115 | + | cmd.Stdout = &out | |
| 116 | + | cmd.Stderr = &errb | |
| 117 | + | err := cmd.Run() | |
| 118 | + | if err != nil { | |
| 119 | + | var exitErr *exec.ExitError | |
| 120 | + | if !errors.As(err, &exitErr) { | |
| 121 | + | t.Fatalf("run broker command %v: %v", args, err) | |
| 122 | + | } | |
| 123 | + | code = exitErr.ExitCode() | |
| 124 | + | } | |
| 125 | + | return out.String(), errb.String(), code | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // status runs `_broker-status` for this sandbox with the supplied extra flags. | |
| 129 | + | func (s *brokerSubprocessSandbox) status(extra ...string) (stdout, stderr string, code int) { | |
| 130 | + | s.t.Helper() | |
| 131 | + | args := append([]string{brokerStatusCommand, "--offline-root", s.root}, extra...) | |
| 132 | + | return runBrokerCommand(s.t, args...) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // socketPath is the broker endpoint inside the sandbox runtime directory. | |
| 136 | + | func (s *brokerSubprocessSandbox) socketPath() string { | |
| 137 | + | return brokeripc.SocketPath(s.layout.Runtime) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // recorded returns every PID a role recorded, in spawn order. | |
| 141 | + | func (s *brokerSubprocessSandbox) recorded(role string) []int { | |
| 142 | + | s.t.Helper() | |
| 143 | + | raw, err := os.ReadFile(filepath.Join(s.recordDir, role+".pids")) | |
| 144 | + | if errors.Is(err, os.ErrNotExist) { | |
| 145 | + | return nil | |
| 146 | + | } | |
| 147 | + | require.NoError(s.t, err) | |
| 148 | + | var pids []int | |
| 149 | + | for _, field := range strings.Fields(string(raw)) { | |
| 150 | + | pid, convErr := strconv.Atoi(field) | |
| 151 | + | require.NoError(s.t, convErr) | |
| 152 | + | pids = append(pids, pid) | |
| 153 | + | } | |
| 154 | + | return pids | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // live returns the recorded PIDs that still exist. | |
| 158 | + | func (s *brokerSubprocessSandbox) live(role string) []int { | |
| 159 | + | s.t.Helper() | |
| 160 | + | var live []int | |
| 161 | + | for _, pid := range s.recorded(role) { | |
| 162 | + | if err := syscall.Kill(pid, 0); err == nil || errors.Is(err, syscall.EPERM) { | |
| 163 | + | live = append(live, pid) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | return live | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // terminate signals every recorded PID of one role and waits for each exit. | |
| 170 | + | func (s *brokerSubprocessSandbox) terminate(role string, signal syscall.Signal) { | |
| 171 | + | s.t.Helper() | |
| 172 | + | for _, pid := range s.recorded(role) { | |
| 173 | + | if err := syscall.Kill(pid, signal); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 174 | + | s.t.Errorf("signal %v pid %d: %v", signal, pid, err) | |
| 175 | + | continue | |
| 176 | + | } | |
| 177 | + | require.NoError(s.t, waitForProcessExit(pid, 10*time.Second)) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // waitForSocketGone waits until the broker endpoint no longer exists. | |
| 182 | + | func (s *brokerSubprocessSandbox) waitForSocketGone(timeout time.Duration) bool { | |
| 183 | + | deadline := time.Now().Add(timeout) | |
| 184 | + | for time.Now().Before(deadline) { | |
| 185 | + | if _, err := os.Lstat(s.socketPath()); errors.Is(err, os.ErrNotExist) { | |
| 186 | + | return true | |
| 187 | + | } | |
| 188 | + | time.Sleep(20 * time.Millisecond) | |
| 189 | + | } | |
| 190 | + | return false | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // cleanup terminates every recorded broker and launcher so no test leaks an | |
| 194 | + | // orphan process. | |
| 195 | + | func (s *brokerSubprocessSandbox) cleanup() { | |
| 196 | + | for _, role := range []string{brokerServeCommand, brokerLauncherCommand} { | |
| 197 | + | for _, pid := range s.recorded(role) { | |
| 198 | + | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | _ = waitForProcessExit(pid, 10*time.Second) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // TestBrokerStatusSubprocessElectsExactlyOneBroker races several real status | |
| 207 | + | // processes against one socket: exactly one broker process, one lifetime owner, | |
| 208 | + | // and one epoch must result. | |
| 209 | + | func TestBrokerStatusSubprocessElectsExactlyOneBroker(t *testing.T) { | |
| 210 | + | sandbox := newBrokerSubprocessSandbox(t, "5s") | |
| 211 | + | const racers = 4 | |
| 212 | + | ||
| 213 | + | type outcome struct { | |
| 214 | + | stdout, stderr string | |
| 215 | + | code int | |
| 216 | + | } | |
| 217 | + | results := make([]outcome, racers) | |
| 218 | + | var wg sync.WaitGroup | |
| 219 | + | for i := 0; i < racers; i++ { | |
| 220 | + | wg.Add(1) | |
| 221 | + | go func(index int) { | |
| 222 | + | defer wg.Done() | |
| 223 | + | stdout, stderr, code := runBrokerCommand(t, brokerStatusCommand, "--offline-root", sandbox.root, "--ensure", "--timeout", "20s") | |
| 224 | + | results[index] = outcome{stdout: stdout, stderr: stderr, code: code} | |
| 225 | + | }(i) | |
| 226 | + | } | |
| 227 | + | wg.Wait() | |
| 228 | + | ||
| 229 | + | epochs := make(map[uint64]struct{}, racers) | |
| 230 | + | for i, result := range results { | |
| 231 | + | require.Equalf(t, 0, result.code, "racer %d exited %d: %s", i, result.code, result.stderr) | |
| 232 | + | report := decodeOneReport(t, result.stdout) | |
| 233 | + | require.Equal(t, "ready", report.Status) | |
| 234 | + | require.Equal(t, sandbox.socketPath(), report.Endpoint) | |
| 235 | + | epochs[uint64(report.Epoch)] = struct{}{} | |
| 236 | + | } | |
| 237 | + | require.Len(t, epochs, 1, "every racer must observe the same broker epoch") | |
| 238 | + | require.Len(t, sandbox.recorded(brokerServeCommand), 1, "exactly one broker process must ever be spawned") | |
| 239 | + | require.Len(t, sandbox.live(brokerServeCommand), 1, "exactly one broker process must be live") | |
| 240 | + | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // TestBrokerStatusSubprocessIdleExitRestartsWithNewEpoch lets the broker idle | |
| 244 | + | // out, then restarts it on demand and proves the new process samples a fresh | |
| 245 | + | // epoch. | |
| 246 | + | func TestBrokerStatusSubprocessIdleExitRestartsWithNewEpoch(t *testing.T) { | |
| 247 | + | sandbox := newBrokerSubprocessSandbox(t, "2s") | |
| 248 | + | ||
| 249 | + | stdout, stderr, code := sandbox.status("--ensure", "--timeout", "20s") | |
| 250 | + | require.Equal(t, 0, code, stderr) | |
| 251 | + | first := decodeOneReport(t, stdout) | |
| 252 | + | require.Equal(t, "ready", first.Status) | |
| 253 | + | require.Positive(t, first.Epoch) | |
| 254 | + | ||
| 255 | + | require.True(t, sandbox.waitForSocketGone(15*time.Second), "the idle broker must shut itself down") | |
| 256 | + | for _, pid := range sandbox.recorded(brokerServeCommand) { | |
| 257 | + | require.NoError(t, waitForProcessExit(pid, 10*time.Second), "the idle broker must exit") | |
| 258 | + | } | |
| 259 | + | require.Empty(t, sandbox.live(brokerServeCommand), "the idle broker must exit") | |
| 260 | + | ||
| 261 | + | stdout, stderr, code = sandbox.status("--ensure", "--timeout", "20s") | |
| 262 | + | require.Equal(t, 0, code, stderr) | |
| 263 | + | second := decodeOneReport(t, stdout) | |
| 264 | + | require.Equal(t, "ready", second.Status) | |
| 265 | + | require.NotEqual(t, first.Epoch, second.Epoch, "an on-demand restart must sample a new epoch") | |
| 266 | + | require.Len(t, sandbox.recorded(brokerServeCommand), 2, "one broker per ensure is expected") | |
| 267 | + | require.Len(t, sandbox.live(brokerServeCommand), 1, "only the restarted broker may remain") | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // TestBrokerStatusSubprocessRecoversStaleSocket proves a stale socket left by a | |
| 271 | + | // dead owner is recoverable absence, not an incompatible endpoint. | |
| 272 | + | func TestBrokerStatusSubprocessRecoversStaleSocket(t *testing.T) { | |
| 273 | + | sandbox := newBrokerSubprocessSandbox(t, "3s") | |
| 274 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 275 | + | bindStaleUnixSocket(t, sandbox.socketPath()) | |
| 276 | + | ||
| 277 | + | stdout, stderr, code := sandbox.status("--ensure", "--timeout", "20s") | |
| 278 | + | require.Equal(t, 0, code, stderr) | |
| 279 | + | report := decodeOneReport(t, stdout) | |
| 280 | + | require.Equal(t, "ready", report.Status) | |
| 281 | + | require.Len(t, sandbox.live(brokerServeCommand), 1) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // TestBrokerStatusSubprocessWaitsForLiveSpawnerThenRecovers holds the | |
| 285 | + | // descriptor-backed election lock like a spawner that died mid-startup: the | |
| 286 | + | // waiter must not spawn, and must take over once the lock is released. | |
| 287 | + | func TestBrokerStatusSubprocessWaitsForLiveSpawnerThenRecovers(t *testing.T) { | |
| 288 | + | sandbox := newBrokerSubprocessSandbox(t, "3s") | |
| 289 | + | ||
| 290 | + | spawner, err := lifecycle.TryAcquire(sandbox.layout.Spawn) | |
| 291 | + | require.NoError(t, err) | |
| 292 | + | ||
| 293 | + | stdout, _, code := sandbox.status("--ensure", "--timeout", "500ms") | |
| 294 | + | require.Equal(t, 3, code) | |
| 295 | + | require.Equal(t, "offline", decodeOneReport(t, stdout).Status) | |
| 296 | + | require.Empty(t, sandbox.recorded(brokerServeCommand), "a waiter must not spawn while another spawner is live") | |
| 297 | + | ||
| 298 | + | // The kernel releases the spawner's descriptor when it dies; releasing here | |
| 299 | + | // is exactly that event. | |
| 300 | + | require.NoError(t, spawner.Release()) | |
| 301 | + | stdout, stderr, code := sandbox.status("--ensure", "--timeout", "20s") | |
| 302 | + | require.Equal(t, 0, code, stderr) | |
| 303 | + | require.Equal(t, "ready", decodeOneReport(t, stdout).Status) | |
| 304 | + | require.Len(t, sandbox.live(brokerServeCommand), 1) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // TestBrokerStatusSubprocessSilentEndpointFailsWithoutSpawning proves a live | |
| 308 | + | // endpoint that never answers the broker handshake is diagnosed as | |
| 309 | + | // incompatible under the probe budget, and never respawned over. | |
| 310 | + | func TestBrokerStatusSubprocessSilentEndpointFailsWithoutSpawning(t *testing.T) { | |
| 311 | + | sandbox := newBrokerSubprocessSandbox(t, "3s") | |
| 312 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 313 | + | // A listener that accepts and never answers: the broker preamble stalls. | |
| 314 | + | _ = bindUnixSocket(t, sandbox.socketPath()) | |
| 315 | + | ||
| 316 | + | // The timeout must exceed the probe budget so the stall is classified as a | |
| 317 | + | // live-but-incompatible endpoint instead of exhausting the overall deadline. | |
| 318 | + | start := time.Now() | |
| 319 | + | stdout, stderr, code := sandbox.status("--ensure", "--timeout", "5s") | |
| 320 | + | require.Equal(t, 3, code) | |
| 321 | + | require.Equal(t, "offline", decodeOneReport(t, stdout).Status) | |
| 322 | + | require.Contains(t, stderr, "live but incompatible", "a silent live endpoint must be classified incompatible") | |
| 323 | + | require.Less(t, time.Since(start), 5*time.Second, "the probe budget must classify a silent endpoint before the overall deadline") | |
| 324 | + | require.Empty(t, sandbox.recorded(brokerServeCommand), "a live-but-silent endpoint must not be respawned over") | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // TestBrokerLauncherSpawnWaitIsBoundedByContext proves a controlled blocking | |
| 328 | + | // launcher is terminated when the spawn context is cancelled, so the overall | |
| 329 | + | // deadline/signal bounds the launcher wait. The helper never starts a detached | |
| 330 | + | // serve, so this is purely the bounded-wait half; other subprocess tests prove a | |
| 331 | + | // successful launcher's detached serve survives. | |
| 332 | + | func TestBrokerLauncherSpawnWaitIsBoundedByContext(t *testing.T) { | |
| 333 | + | record := filepath.Join(shortTempDir(t, "vevl"), "launcher.pid") | |
| 334 | + | release := filepath.Join(t.TempDir(), "release") | |
| 335 | + | t.Setenv(brokerHelperEnv, "1") | |
| 336 | + | t.Setenv(brokerLauncherBlockRecordEnv, record) | |
| 337 | + | t.Setenv(brokerLauncherBlockReleaseEnv, release) | |
| 338 | + | t.Cleanup(func() { _ = os.WriteFile(release, nil, 0o600) }) | |
| 339 | + | ||
| 340 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 341 | + | defer cancel() | |
| 342 | + | result := make(chan error, 1) | |
| 343 | + | go func() { result <- spawnBrokerLauncher(ctx, t.TempDir(), 0, false) }() | |
| 344 | + | ||
| 345 | + | pid := waitForProcessRecord(t, record) | |
| 346 | + | select { | |
| 347 | + | case err := <-result: | |
| 348 | + | t.Fatalf("spawnBrokerLauncher returned while the controlled launcher %d was still running: %v", pid, err) | |
| 349 | + | default: | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | cancel() | |
| 353 | + | select { | |
| 354 | + | case err := <-result: | |
| 355 | + | require.Error(t, err, "cancelling the context must abort the launcher wait") | |
| 356 | + | case <-time.After(5 * time.Second): | |
| 357 | + | t.Fatal("cancelling the context must bound the launcher spawn wait") | |
| 358 | + | } | |
| 359 | + | require.NoError(t, waitForProcessExit(pid, 5*time.Second), "CommandContext must terminate a blocking launcher") | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // TestBrokerStatusSubprocessLauncherDiagnosesConflicts proves an explicit | |
| 363 | + | // launcher grace that conflicts with the provisioned one is refused before any | |
| 364 | + | // process is spawned, and that a missing configuration fails the launcher. | |
| 365 | + | func TestBrokerStatusSubprocessLauncherDiagnosesConflicts(t *testing.T) { | |
| 366 | + | t.Run("conflicting idle grace", func(t *testing.T) { | |
| 367 | + | sandbox := newBrokerSubprocessSandbox(t, "90s") | |
| 368 | + | stdout, stderr, code := runBrokerCommand(t, brokerLauncherCommand, "--offline-root", sandbox.root, "--idle-grace", "1m") | |
| 369 | + | require.NotEqual(t, 0, code) | |
| 370 | + | require.Contains(t, stderr, "conflicts with the provisioned idle grace") | |
| 371 | + | require.Empty(t, strings.TrimSpace(stdout)) | |
| 372 | + | require.Empty(t, sandbox.recorded(brokerServeCommand)) | |
| 373 | + | }) | |
| 374 | + | ||
| 375 | + | t.Run("missing configuration", func(t *testing.T) { | |
| 376 | + | prodRuntime, prodState := t.TempDir(), t.TempDir() | |
| 377 | + | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |
| 378 | + | t.Setenv("XDG_STATE_HOME", prodState) | |
| 379 | + | recordDir := filepath.Join(t.TempDir(), "records") | |
| 380 | + | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | |
| 381 | + | t.Setenv(brokerHelperEnv, "1") | |
| 382 | + | t.Setenv(brokerHelperRecordDirEnv, recordDir) | |
| 383 | + | root := filepath.Join(shortTempDir(t, "vevx"), "s") | |
| 384 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 385 | + | ||
| 386 | + | _, stderr, code := runBrokerCommand(t, brokerLauncherCommand, "--offline-root", root) | |
| 387 | + | require.NotEqual(t, 0, code) | |
| 388 | + | require.NotEmpty(t, strings.TrimSpace(stderr)) | |
| 389 | + | require.Empty(t, recordedPIDsIn(t, recordDir, brokerServeCommand)) | |
| 390 | + | }) | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // TestBrokerStatusSubprocessNoOrphanProcess proves the launcher exits on its | |
| 394 | + | // own, one broker remains, and terminating it leaves no recorded process live. | |
| 395 | + | func TestBrokerStatusSubprocessNoOrphanProcess(t *testing.T) { | |
| 396 | + | sandbox := newBrokerSubprocessSandbox(t, "5s") | |
| 397 | + | ||
| 398 | + | stdout, stderr, code := sandbox.status("--ensure", "--timeout", "20s") | |
| 399 | + | require.Equal(t, 0, code, stderr) | |
| 400 | + | require.Equal(t, "ready", decodeOneReport(t, stdout).Status) | |
| 401 | + | ||
| 402 | + | require.NotEmpty(t, sandbox.recorded(brokerLauncherCommand)) | |
| 403 | + | for _, pid := range sandbox.recorded(brokerLauncherCommand) { | |
| 404 | + | require.NoError(t, waitForProcessExit(pid, 5*time.Second), "the short-lived launcher must exit") | |
| 405 | + | } | |
| 406 | + | require.Len(t, sandbox.live(brokerServeCommand), 1) | |
| 407 | + | ||
| 408 | + | _, err := lifecycle.TryAcquire(sandbox.layout.Runtime) | |
| 409 | + | require.ErrorIs(t, err, lifecycle.ErrBusy, "the live broker must own the lifetime lock") | |
| 410 | + | ||
| 411 | + | sandbox.terminate(brokerServeCommand, syscall.SIGTERM) | |
| 412 | + | require.Empty(t, sandbox.live(brokerLauncherCommand), "no launcher may be left behind") | |
| 413 | + | require.Empty(t, sandbox.live(brokerServeCommand), "no broker may be left behind") | |
| 414 | + | require.True(t, sandbox.waitForSocketGone(5*time.Second), "an orderly broker shutdown must unlink its socket") | |
| 415 | + | owner, err := lifecycle.TryAcquire(sandbox.layout.Runtime) | |
| 416 | + | require.NoError(t, err, "the terminated broker must release the lifetime lock") | |
| 417 | + | require.NoError(t, owner.Release()) | |
| 418 | + | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // recordedPIDsIn reads one role's PID record from an arbitrary directory. | |
| 422 | + | func recordedPIDsIn(t *testing.T, dir, role string) []int { | |
| 423 | + | t.Helper() | |
| 424 | + | raw, err := os.ReadFile(filepath.Join(dir, role+".pids")) | |
| 425 | + | if errors.Is(err, os.ErrNotExist) { | |
| 426 | + | return nil | |
| 427 | + | } | |
| 428 | + | require.NoError(t, err) | |
| 429 | + | var pids []int | |
| 430 | + | for _, field := range strings.Fields(string(raw)) { | |
| 431 | + | pid, convErr := strconv.Atoi(field) | |
| 432 | + | require.NoError(t, convErr) | |
| 433 | + | pids = append(pids, pid) | |
| 434 | + | } | |
| 435 | + | return pids | |
| 436 | + | } | |
A internal/app/broker_offline_status_test.go +684 -0
| @@ -0,0 +1,684 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "sync/atomic" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // sandboxEmptyDocument is a marker-valid sandbox configuration with no | |
| 24 | + | // registrations and an optional provisioned idle grace. | |
| 25 | + | func sandboxEmptyDocument(idleGrace string) map[string]any { | |
| 26 | + | document := map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}} | |
| 27 | + | if idleGrace != "" { | |
| 28 | + | document["idleGrace"] = idleGrace | |
| 29 | + | } | |
| 30 | + | return document | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // emptySandboxRoot writes one marker-valid sandbox root and returns its layout. | |
| 34 | + | func emptySandboxRoot(t *testing.T, idleGrace string) brokerconfig.Layout { | |
| 35 | + | t.Helper() | |
| 36 | + | isolateSandboxEnv(t) | |
| 37 | + | root := filepath.Join(shortTempDir(t, "vevu"), "s") | |
| 38 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 39 | + | writeSandboxConfig(t, root, sandboxEmptyDocument(idleGrace)) | |
| 40 | + | layout, err := offlineLayout(root) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | return layout | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // fakeStatusLock records releases for one held election. | |
| 46 | + | type fakeStatusLock struct{ releases atomic.Int32 } | |
| 47 | + | ||
| 48 | + | func (l *fakeStatusLock) Release() error { | |
| 49 | + | l.releases.Add(1) | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // readyReport is the report returned when a scripted probe succeeds. | |
| 54 | + | func readyReport(socketPath string) brokerStatusReport { | |
| 55 | + | return brokerStatusReport{Status: "ready", Endpoint: socketPath, Epoch: 7, Revision: 3, HostCount: 2} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // testStatusDeps builds deterministic status seams. Virtual time starts at the | |
| 59 | + | // real instant and only advances when the loop waits, so a short timeout | |
| 60 | + | // exhausts without real sleeping while the production context deadline stays | |
| 61 | + | // comfortably in the future. The default probe always reports absence. | |
| 62 | + | func testStatusDeps(t *testing.T) (brokerStatusDeps, *statusRecorder) { | |
| 63 | + | t.Helper() | |
| 64 | + | rec := &statusRecorder{ | |
| 65 | + | now: time.Now(), | |
| 66 | + | stdout: &bytes.Buffer{}, | |
| 67 | + | } | |
| 68 | + | deps := brokerStatusDeps{ | |
| 69 | + | now: func() time.Time { return rec.now }, | |
| 70 | + | wait: func(ctx context.Context, d time.Duration) error { | |
| 71 | + | rec.now = rec.now.Add(d) | |
| 72 | + | return ctx.Err() | |
| 73 | + | }, | |
| 74 | + | acquireElection: func(string) (brokerStatusLock, error) { | |
| 75 | + | rec.elections.Add(1) | |
| 76 | + | lock := &fakeStatusLock{} | |
| 77 | + | rec.locks = append(rec.locks, lock) | |
| 78 | + | return lock, nil | |
| 79 | + | }, | |
| 80 | + | lifetimeOwner: func(string) (bool, error) { return false, nil }, | |
| 81 | + | spawn: func(context.Context, string, time.Duration, bool) error { | |
| 82 | + | rec.spawns.Add(1) | |
| 83 | + | return nil | |
| 84 | + | }, | |
| 85 | + | stdout: rec.stdout, | |
| 86 | + | } | |
| 87 | + | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 88 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 89 | + | }) | |
| 90 | + | return deps, rec | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // statusRecorder captures one status run's observable effects. | |
| 94 | + | type statusRecorder struct { | |
| 95 | + | now time.Time | |
| 96 | + | probes atomic.Int32 | |
| 97 | + | spawns atomic.Int32 | |
| 98 | + | elections atomic.Int32 | |
| 99 | + | locks []*fakeStatusLock | |
| 100 | + | stdout *bytes.Buffer | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // setProbe installs one scripted probe that sees its 1-based attempt number. | |
| 104 | + | func (r *statusRecorder) setProbe(deps *brokerStatusDeps, probe func(attempt int, socketPath string) (brokerStatusReport, error)) { | |
| 105 | + | deps.probe = func(_ context.Context, socketPath string) (brokerStatusReport, error) { | |
| 106 | + | return probe(int(r.probes.Add(1)), socketPath) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (r *statusRecorder) report(t *testing.T) brokerStatusReport { | |
| 111 | + | t.Helper() | |
| 112 | + | var report brokerStatusReport | |
| 113 | + | require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(r.stdout.String())), &report)) | |
| 114 | + | return report | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // decodeOneReport asserts stdout holds exactly one JSON document and decodes it. | |
| 118 | + | func decodeOneReport(t *testing.T, raw string) brokerStatusReport { | |
| 119 | + | t.Helper() | |
| 120 | + | trimmed := strings.TrimSpace(raw) | |
| 121 | + | require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "stdout must hold exactly one report line") | |
| 122 | + | var report brokerStatusReport | |
| 123 | + | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | |
| 124 | + | return report | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestParseBrokerLauncherArgs(t *testing.T) { | |
| 128 | + | tests := []struct { | |
| 129 | + | name string | |
| 130 | + | args []string | |
| 131 | + | want brokerLauncherOptions | |
| 132 | + | wantErr string | |
| 133 | + | }{ | |
| 134 | + | {name: "root only", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerLauncherOptions{offlineRoot: "/srv/sandbox"}}, | |
| 135 | + | { | |
| 136 | + | name: "root and grace", | |
| 137 | + | args: []string{"--offline-root", "/srv/sandbox", "--idle-grace", "90s"}, | |
| 138 | + | want: brokerLauncherOptions{offlineRoot: "/srv/sandbox", idleGrace: 90 * time.Second}, | |
| 139 | + | }, | |
| 140 | + | {name: "missing root", args: nil, wantErr: "requires --offline-root"}, | |
| 141 | + | {name: "missing root value", args: []string{"--offline-root"}, wantErr: "requires a path"}, | |
| 142 | + | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |
| 143 | + | {name: "duplicate grace", args: []string{"--offline-root", "/a", "--idle-grace", "1s", "--idle-grace", "2s"}, wantErr: "duplicate --idle-grace"}, | |
| 144 | + | {name: "zero grace", args: []string{"--offline-root", "/a", "--idle-grace", "0s"}, wantErr: "must be positive"}, | |
| 145 | + | {name: "invalid grace", args: []string{"--offline-root", "/a", "--idle-grace", "soon"}, wantErr: "not a duration"}, | |
| 146 | + | {name: "ensure is not a launcher flag", args: []string{"--offline-root", "/a", "--ensure"}, wantErr: "unknown flag"}, | |
| 147 | + | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 148 | + | } | |
| 149 | + | for _, tt := range tests { | |
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |
| 151 | + | command, err := parseBrokerLauncherArgs(tt.args) | |
| 152 | + | if tt.wantErr != "" { | |
| 153 | + | require.Error(t, err) | |
| 154 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Equal(t, kindBrokerLauncher, command.kind) | |
| 159 | + | require.Equal(t, tt.want, command.brokerLauncher) | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestParseBrokerStatusArgs(t *testing.T) { | |
| 165 | + | tests := []struct { | |
| 166 | + | name string | |
| 167 | + | args []string | |
| 168 | + | want brokerStatusOptions | |
| 169 | + | wantErr string | |
| 170 | + | }{ | |
| 171 | + | { | |
| 172 | + | name: "root only defaults the timeout", | |
| 173 | + | args: []string{"--offline-root", "/srv/sandbox"}, | |
| 174 | + | want: brokerStatusOptions{offlineRoot: "/srv/sandbox", timeout: brokerStatusDefaultTimeout}, | |
| 175 | + | }, | |
| 176 | + | { | |
| 177 | + | name: "ensure and timeout", | |
| 178 | + | args: []string{"--offline-root", "/a", "--ensure", "--timeout", "250ms"}, | |
| 179 | + | want: brokerStatusOptions{offlineRoot: "/a", ensure: true, timeout: 250 * time.Millisecond}, | |
| 180 | + | }, | |
| 181 | + | {name: "missing root", args: []string{"--ensure"}, wantErr: "requires --offline-root"}, | |
| 182 | + | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |
| 183 | + | {name: "duplicate ensure", args: []string{"--offline-root", "/a", "--ensure", "--ensure"}, wantErr: "duplicate --ensure"}, | |
| 184 | + | {name: "duplicate timeout", args: []string{"--offline-root", "/a", "--timeout", "1s", "--timeout", "2s"}, wantErr: "duplicate --timeout"}, | |
| 185 | + | {name: "missing timeout value", args: []string{"--offline-root", "/a", "--timeout"}, wantErr: "requires a duration"}, | |
| 186 | + | {name: "zero timeout", args: []string{"--offline-root", "/a", "--timeout", "0s"}, wantErr: "must be positive"}, | |
| 187 | + | {name: "invalid timeout", args: []string{"--offline-root", "/a", "--timeout", "soon"}, wantErr: "not a duration"}, | |
| 188 | + | {name: "idle grace is not a status flag", args: []string{"--offline-root", "/a", "--idle-grace", "1s"}, wantErr: "unknown flag"}, | |
| 189 | + | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 190 | + | } | |
| 191 | + | for _, tt := range tests { | |
| 192 | + | t.Run(tt.name, func(t *testing.T) { | |
| 193 | + | command, err := parseBrokerStatusArgs(tt.args) | |
| 194 | + | if tt.wantErr != "" { | |
| 195 | + | require.Error(t, err) | |
| 196 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.Equal(t, kindBrokerStatus, command.kind) | |
| 201 | + | require.Equal(t, tt.want, command.brokerStatus) | |
| 202 | + | }) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestBrokerLauncherAndStatusAreHiddenFromPublicHelp(t *testing.T) { | |
| 207 | + | require.NotContains(t, usageText, brokerLauncherCommand) | |
| 208 | + | require.NotContains(t, usageText, brokerStatusCommand) | |
| 209 | + | _, err := parseArgs([]string{brokerLauncherCommand, "--offline-root", "/srv/sandbox"}) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | _, err = parseArgs([]string{brokerStatusCommand, "--offline-root", "/srv/sandbox", "--ensure"}) | |
| 212 | + | require.NoError(t, err) | |
| 213 | + | _, err = parseArgs([]string{"--help"}) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestEffectiveIdleGrace(t *testing.T) { | |
| 218 | + | layout := emptySandboxRoot(t, "90s") | |
| 219 | + | config, err := brokerconfig.Load(layout) | |
| 220 | + | require.NoError(t, err) | |
| 221 | + | unprovisioned := emptySandboxRoot(t, "") | |
| 222 | + | plain, err := brokerconfig.Load(unprovisioned) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | ||
| 225 | + | tests := []struct { | |
| 226 | + | name string | |
| 227 | + | config *brokerconfig.Config | |
| 228 | + | explicit time.Duration | |
| 229 | + | want time.Duration | |
| 230 | + | wantErr string | |
| 231 | + | }{ | |
| 232 | + | {name: "unset uses provisioned", config: config, want: 90 * time.Second}, | |
| 233 | + | {name: "matching explicit is accepted", config: config, explicit: 90 * time.Second, want: 90 * time.Second}, | |
| 234 | + | {name: "conflicting explicit is diagnosed", config: config, explicit: time.Minute, wantErr: "conflicts with the provisioned idle grace"}, | |
| 235 | + | {name: "unprovisioned explicit wins", config: plain, explicit: 42 * time.Second, want: 42 * time.Second}, | |
| 236 | + | {name: "unprovisioned unset defers to the default", config: plain, want: 0}, | |
| 237 | + | } | |
| 238 | + | for _, tt := range tests { | |
| 239 | + | t.Run(tt.name, func(t *testing.T) { | |
| 240 | + | got, err := effectiveIdleGrace(tt.config, tt.explicit) | |
| 241 | + | if tt.wantErr != "" { | |
| 242 | + | require.Error(t, err) | |
| 243 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 244 | + | return | |
| 245 | + | } | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | require.Equal(t, tt.want, got) | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestBrokerServeRefusesConflictingIdleGrace(t *testing.T) { | |
| 253 | + | layout := emptySandboxRoot(t, "90s") | |
| 254 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 255 | + | defer cancel() | |
| 256 | + | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 257 | + | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: layout.Root, idleGrace: time.Minute}, deps) | |
| 258 | + | require.Error(t, err) | |
| 259 | + | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | |
| 260 | + | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestBrokerLauncherRefusesConflictingIdleGraceBeforeSpawning(t *testing.T) { | |
| 264 | + | layout := emptySandboxRoot(t, "90s") | |
| 265 | + | err := runBrokerLauncher(brokerLauncherOptions{offlineRoot: layout.Root, idleGrace: time.Minute}) | |
| 266 | + | require.Error(t, err) | |
| 267 | + | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestBrokerStatusDialOnlyReportsOfflineWithoutCreatingAnything(t *testing.T) { | |
| 271 | + | layout := emptySandboxRoot(t, "") | |
| 272 | + | deps := defaultBrokerStatusDeps() | |
| 273 | + | var out bytes.Buffer | |
| 274 | + | deps.stdout = &out | |
| 275 | + | ||
| 276 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: time.Second}, deps)) | |
| 277 | + | report := decodeOneReport(t, out.String()) | |
| 278 | + | require.Equal(t, "offline", report.Status) | |
| 279 | + | require.Equal(t, brokeripc.SocketPath(layout.Runtime), report.Endpoint) | |
| 280 | + | require.Zero(t, report.Epoch) | |
| 281 | + | require.Zero(t, report.Revision) | |
| 282 | + | require.Zero(t, report.HostCount) | |
| 283 | + | ||
| 284 | + | require.NoDirExists(t, layout.Runtime, "dial-only status must not create the runtime directory") | |
| 285 | + | require.NoDirExists(t, layout.Spawn, "dial-only status must not create the spawn directory") | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) { | |
| 289 | + | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 290 | + | fixture := newBrokerMuxFixture(t, brokerTestPolicy()) | |
| 291 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 292 | + | writeSandboxConfig(t, root, sandboxRegistrationDocument(fixture.route)) | |
| 293 | + | ||
| 294 | + | clk := newSandboxClock() | |
| 295 | + | serveDeps, ready := testBrokerServeDeps(clk) | |
| 296 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 297 | + | defer cancel() | |
| 298 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps) | |
| 299 | + | awaitSandboxReady(t, ready) | |
| 300 | + | ||
| 301 | + | deps := defaultBrokerStatusDeps() | |
| 302 | + | var out bytes.Buffer | |
| 303 | + | deps.stdout = &out | |
| 304 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: root, timeout: time.Second}, deps)) | |
| 305 | + | report := decodeOneReport(t, out.String()) | |
| 306 | + | require.Equal(t, "ready", report.Status) | |
| 307 | + | require.Positive(t, report.Epoch) | |
| 308 | + | require.Positive(t, report.Revision) | |
| 309 | + | // The sandbox registry restores from its own empty durable store; the | |
| 310 | + | // provisioned endpoints feed the resolver, not the host inventory. | |
| 311 | + | require.Zero(t, report.HostCount) | |
| 312 | + | ||
| 313 | + | awaitSandboxCancel(t, cancel, done) | |
| 314 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestBrokerStatusEnsureSpawnsOnceThenReportsReady(t *testing.T) { | |
| 318 | + | layout := emptySandboxRoot(t, "") | |
| 319 | + | deps, rec := testStatusDeps(t) | |
| 320 | + | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 321 | + | if rec.spawns.Load() > 0 { | |
| 322 | + | return readyReport(socketPath), nil | |
| 323 | + | } | |
| 324 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 325 | + | }) | |
| 326 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 327 | + | ||
| 328 | + | require.Equal(t, int32(1), rec.spawns.Load(), "exactly one spawn is expected") | |
| 329 | + | require.Equal(t, int32(1), rec.elections.Load(), "exactly one election is expected") | |
| 330 | + | require.Len(t, rec.locks, 1) | |
| 331 | + | require.Equal(t, int32(1), rec.locks[0].releases.Load(), "the election must be released after readiness") | |
| 332 | + | require.Equal(t, "ready", rec.report(t).Status) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func TestBrokerStatusEnsureWaitsForAnotherSpawner(t *testing.T) { | |
| 336 | + | layout := emptySandboxRoot(t, "") | |
| 337 | + | var readyAfter int32 = 4 | |
| 338 | + | deps, rec := testStatusDeps(t) | |
| 339 | + | rec.setProbe(&deps, func(attempt int, socketPath string) (brokerStatusReport, error) { | |
| 340 | + | if int32(attempt) >= readyAfter { | |
| 341 | + | return readyReport(socketPath), nil | |
| 342 | + | } | |
| 343 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 344 | + | }) | |
| 345 | + | deps.acquireElection = func(string) (brokerStatusLock, error) { | |
| 346 | + | rec.elections.Add(1) | |
| 347 | + | return nil, lifecycle.ErrBusy | |
| 348 | + | } | |
| 349 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 350 | + | ||
| 351 | + | require.Zero(t, rec.spawns.Load(), "a waiter must never spawn while another caller is elected") | |
| 352 | + | require.Positive(t, rec.elections.Load()) | |
| 353 | + | require.Equal(t, "ready", rec.report(t).Status) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestBrokerStatusEnsureDefersToLifetimeOwnerBeforeSpawning(t *testing.T) { | |
| 357 | + | layout := emptySandboxRoot(t, "") | |
| 358 | + | var ownerCalls int32 | |
| 359 | + | deps, rec := testStatusDeps(t) | |
| 360 | + | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 361 | + | if rec.spawns.Load() > 0 { | |
| 362 | + | return readyReport(socketPath), nil | |
| 363 | + | } | |
| 364 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 365 | + | }) | |
| 366 | + | deps.lifetimeOwner = func(string) (bool, error) { | |
| 367 | + | if atomic.AddInt32(&ownerCalls, 1) <= 3 { | |
| 368 | + | return true, nil | |
| 369 | + | } | |
| 370 | + | return false, nil | |
| 371 | + | } | |
| 372 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 373 | + | require.Equal(t, int32(1), rec.spawns.Load(), "a live lifetime owner must suppress the spawn until it is gone") | |
| 374 | + | require.Equal(t, int32(1), rec.elections.Load(), "the election is taken only after the live owner releases the sandbox") | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func TestBrokerStatusEnsureRecoversAfterSpawnerDeath(t *testing.T) { | |
| 378 | + | layout := emptySandboxRoot(t, "") | |
| 379 | + | var electionAttempts int32 | |
| 380 | + | deps, rec := testStatusDeps(t) | |
| 381 | + | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 382 | + | if rec.spawns.Load() > 0 { | |
| 383 | + | return readyReport(socketPath), nil | |
| 384 | + | } | |
| 385 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 386 | + | }) | |
| 387 | + | deps.acquireElection = func(string) (brokerStatusLock, error) { | |
| 388 | + | rec.elections.Add(1) | |
| 389 | + | if atomic.AddInt32(&electionAttempts, 1) <= 3 { | |
| 390 | + | // The previous elected spawner is still alive (or mid-startup). | |
| 391 | + | return nil, lifecycle.ErrBusy | |
| 392 | + | } | |
| 393 | + | lock := &fakeStatusLock{} | |
| 394 | + | rec.locks = append(rec.locks, lock) | |
| 395 | + | return lock, nil | |
| 396 | + | } | |
| 397 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 398 | + | require.Equal(t, int32(1), rec.spawns.Load(), "a waiter must take over once the elected spawner dies") | |
| 399 | + | require.Equal(t, "ready", rec.report(t).Status) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func TestBrokerStatusEnsureIncompatibleEndpointFailsWithoutSpawning(t *testing.T) { | |
| 403 | + | layout := emptySandboxRoot(t, "") | |
| 404 | + | deps, rec := testStatusDeps(t) | |
| 405 | + | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 406 | + | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("foreign endpoint")) | |
| 407 | + | }) | |
| 408 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 409 | + | require.ErrorIs(t, err, errBrokerIncompatible) | |
| 410 | + | require.Zero(t, rec.spawns.Load()) | |
| 411 | + | require.Zero(t, rec.elections.Load()) | |
| 412 | + | report := rec.report(t) | |
| 413 | + | require.Equal(t, "offline", report.Status) | |
| 414 | + | require.Equal(t, 3, exitCode(err)) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func TestBrokerStatusEnsureBoundedTimeoutReportsOffline(t *testing.T) { | |
| 418 | + | layout := emptySandboxRoot(t, "") | |
| 419 | + | deps, rec := testStatusDeps(t) | |
| 420 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: 50 * time.Millisecond}, deps) | |
| 421 | + | require.ErrorIs(t, err, errBrokerNotReady) | |
| 422 | + | require.Equal(t, 3, exitCode(err)) | |
| 423 | + | require.Equal(t, int32(1), rec.spawns.Load()) | |
| 424 | + | require.Equal(t, "offline", rec.report(t).Status) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func TestBrokerStatusEnsureSpawnFailureIsReported(t *testing.T) { | |
| 428 | + | layout := emptySandboxRoot(t, "") | |
| 429 | + | spawnErr := errors.New("launcher exec failed") | |
| 430 | + | deps, rec := testStatusDeps(t) | |
| 431 | + | deps.spawn = func(context.Context, string, time.Duration, bool) error { | |
| 432 | + | rec.spawns.Add(1) | |
| 433 | + | return spawnErr | |
| 434 | + | } | |
| 435 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 436 | + | require.ErrorIs(t, err, spawnErr) | |
| 437 | + | require.Equal(t, 3, exitCode(err)) | |
| 438 | + | require.Equal(t, "offline", rec.report(t).Status) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func TestBrokerStatusEnsureRejectsUnsafeRoot(t *testing.T) { | |
| 442 | + | isolateSandboxEnv(t) | |
| 443 | + | tests := []struct { | |
| 444 | + | name string | |
| 445 | + | root string | |
| 446 | + | }{ | |
| 447 | + | {name: "relative root", root: "relative"}, | |
| 448 | + | {name: "production overlap", root: brokeripc.SocketDir()}, | |
| 449 | + | } | |
| 450 | + | for _, tt := range tests { | |
| 451 | + | t.Run(tt.name, func(t *testing.T) { | |
| 452 | + | deps, rec := testStatusDeps(t) | |
| 453 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: tt.root, ensure: true, timeout: time.Second}, deps) | |
| 454 | + | require.Error(t, err) | |
| 455 | + | require.Equal(t, 3, exitCode(err), "a layout failure must still exit 3") | |
| 456 | + | require.Equal(t, "offline", rec.report(t).Status, "a layout failure must still emit one offline report") | |
| 457 | + | require.Zero(t, rec.spawns.Load()) | |
| 458 | + | }) | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // TestBrokerStatusEnsureConfigFailureReportsOffline proves a malformed or | |
| 463 | + | // missing configuration still leaves the caller with one offline JSON document | |
| 464 | + | // and a code-3 failure instead of a bare error. | |
| 465 | + | func TestBrokerStatusEnsureConfigFailureReportsOffline(t *testing.T) { | |
| 466 | + | isolateSandboxEnv(t) | |
| 467 | + | root := filepath.Join(shortTempDir(t, "vevm"), "s") | |
| 468 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 469 | + | deps, rec := testStatusDeps(t) | |
| 470 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: root, ensure: true, timeout: time.Second}, deps) | |
| 471 | + | require.Error(t, err) | |
| 472 | + | require.Contains(t, err.Error(), brokerconfig.ConfigFileName) | |
| 473 | + | require.Equal(t, 3, exitCode(err)) | |
| 474 | + | require.Equal(t, "offline", rec.report(t).Status) | |
| 475 | + | require.Zero(t, rec.spawns.Load()) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // TestBrokerStatusEnsureSecuresRootBeforeSpawning proves the ensure path | |
| 479 | + | // validates and secures the sandbox root exactly as the launcher does, before | |
| 480 | + | // the spawn directory is created or any election lock is taken. | |
| 481 | + | func TestBrokerStatusEnsureSecuresRootBeforeSpawning(t *testing.T) { | |
| 482 | + | layout := emptySandboxRoot(t, "") | |
| 483 | + | require.NoError(t, os.Chmod(layout.Root, 0o755)) | |
| 484 | + | deps, rec := testStatusDeps(t) | |
| 485 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 486 | + | require.Error(t, err) | |
| 487 | + | require.Contains(t, err.Error(), "0755") | |
| 488 | + | require.Equal(t, 3, exitCode(err)) | |
| 489 | + | require.Equal(t, "offline", rec.report(t).Status) | |
| 490 | + | require.Zero(t, rec.spawns.Load()) | |
| 491 | + | require.Zero(t, rec.elections.Load()) | |
| 492 | + | require.NoFileExists(t, filepath.Join(layout.Runtime, brokerconfig.SpawnDirName), "an insecure root must be refused before the spawn directory is created") | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // TestBrokerStatusEnsureDeadlineBoundsSpawnWait proves the overall ensure | |
| 496 | + | // deadline bounds a launcher spawn that never returns, so a blocking launcher | |
| 497 | + | // cannot wedge the status command. | |
| 498 | + | func TestBrokerStatusEnsureDeadlineBoundsSpawnWait(t *testing.T) { | |
| 499 | + | layout := emptySandboxRoot(t, "") | |
| 500 | + | deps, rec := testStatusDeps(t) | |
| 501 | + | deps.spawn = func(ctx context.Context, _ string, _ time.Duration, _ bool) error { | |
| 502 | + | <-ctx.Done() | |
| 503 | + | return ctx.Err() | |
| 504 | + | } | |
| 505 | + | start := time.Now() | |
| 506 | + | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: 200 * time.Millisecond}, deps) | |
| 507 | + | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 508 | + | require.Equal(t, 3, exitCode(err)) | |
| 509 | + | require.Less(t, time.Since(start), 5*time.Second, "the overall deadline must bound the launcher spawn wait") | |
| 510 | + | require.Equal(t, "offline", rec.report(t).Status) | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // TestBrokerStatusDialOnlyAppliesTimeout proves the dial-only probe is bounded | |
| 514 | + | // by --timeout as one absolute bound and is never silently replaced by the | |
| 515 | + | // ensure probe budget. | |
| 516 | + | func TestBrokerStatusDialOnlyAppliesTimeout(t *testing.T) { | |
| 517 | + | tests := []struct { | |
| 518 | + | name string | |
| 519 | + | timeout time.Duration | |
| 520 | + | }{ | |
| 521 | + | {name: "short timeout is the probe bound", timeout: 250 * time.Millisecond}, | |
| 522 | + | {name: "default timeout is the probe bound", timeout: brokerStatusDefaultTimeout}, | |
| 523 | + | } | |
| 524 | + | for _, tt := range tests { | |
| 525 | + | t.Run(tt.name, func(t *testing.T) { | |
| 526 | + | layout := emptySandboxRoot(t, "") | |
| 527 | + | deps, _ := testStatusDeps(t) | |
| 528 | + | var observed time.Duration | |
| 529 | + | sawDeadline := false | |
| 530 | + | deps.probe = func(ctx context.Context, socketPath string) (brokerStatusReport, error) { | |
| 531 | + | deadline, ok := ctx.Deadline() | |
| 532 | + | sawDeadline = ok | |
| 533 | + | if ok { | |
| 534 | + | observed = time.Until(deadline) | |
| 535 | + | } | |
| 536 | + | return brokerStatusReport{}, absentError(socketPath) | |
| 537 | + | } | |
| 538 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: tt.timeout}, deps)) | |
| 539 | + | require.True(t, sawDeadline, "dial-only must bound the probe with the timeout context") | |
| 540 | + | require.InDelta(t, tt.timeout.Seconds(), observed.Seconds(), 0.5) | |
| 541 | + | }) | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // TestBrokerStatusDialOnlyBoundedByTimeout proves a live-but-silent endpoint is | |
| 546 | + | // bounded by --timeout rather than the ensure probe budget. | |
| 547 | + | func TestBrokerStatusDialOnlyBoundedByTimeout(t *testing.T) { | |
| 548 | + | layout := emptySandboxRoot(t, "") | |
| 549 | + | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |
| 550 | + | _ = bindUnixSocket(t, brokeripc.SocketPath(layout.Runtime)) | |
| 551 | + | deps := defaultBrokerStatusDeps() | |
| 552 | + | var out bytes.Buffer | |
| 553 | + | deps.stdout = &out | |
| 554 | + | start := time.Now() | |
| 555 | + | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: 300 * time.Millisecond}, deps)) | |
| 556 | + | require.Less(t, time.Since(start), time.Second, "the dial-only probe must be bounded by --timeout, not the ensure budget") | |
| 557 | + | require.Equal(t, "offline", decodeOneReport(t, out.String()).Status) | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | func TestBrokerStatusEnsureHonorsCancellation(t *testing.T) { | |
| 561 | + | layout := emptySandboxRoot(t, "") | |
| 562 | + | deps, _ := testStatusDeps(t) | |
| 563 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 564 | + | cancel() | |
| 565 | + | err := runBrokerStatus(ctx, brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 566 | + | require.ErrorIs(t, err, context.Canceled) | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | func TestBrokerStatusReportIsBoundedJSON(t *testing.T) { | |
| 570 | + | var out bytes.Buffer | |
| 571 | + | require.NoError(t, writeBrokerStatusReport(&out, brokerStatusReport{ | |
| 572 | + | Status: "ready", Endpoint: brokeripc.SocketPath("/tmp/sandbox/runtime"), Epoch: 3, Revision: 9, HostCount: 4, | |
| 573 | + | })) | |
| 574 | + | var fields map[string]json.RawMessage | |
| 575 | + | require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &fields)) | |
| 576 | + | require.Equal(t, []string{"endpoint", "epoch", "host_count", "revision", "status"}, sortedKeys(fields)) | |
| 577 | + | require.LessOrEqual(t, out.Len(), brokerStatusMaxReportBytes) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // sortedKeys returns the sorted keys of one JSON object. | |
| 581 | + | func sortedKeys(fields map[string]json.RawMessage) []string { | |
| 582 | + | keys := make([]string, 0, len(fields)) | |
| 583 | + | for key := range fields { | |
| 584 | + | keys = append(keys, key) | |
| 585 | + | } | |
| 586 | + | slicesSort(keys) | |
| 587 | + | return keys | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | // slicesSort is a tiny insertion sort so the test file needs no extra import. | |
| 591 | + | func slicesSort(values []string) { | |
| 592 | + | for i := 1; i < len(values); i++ { | |
| 593 | + | for j := i; j > 0 && values[j] < values[j-1]; j-- { | |
| 594 | + | values[j], values[j-1] = values[j-1], values[j] | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | func TestProbeBrokerStatusClassifiesAbsenceAndIncompatibility(t *testing.T) { | |
| 600 | + | layout := emptySandboxRoot(t, "") | |
| 601 | + | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 602 | + | ||
| 603 | + | t.Run("missing endpoint is absence", func(t *testing.T) { | |
| 604 | + | _, err := probeBrokerStatus(context.Background(), socketPath) | |
| 605 | + | require.ErrorIs(t, err, errBrokerAbsent) | |
| 606 | + | }) | |
| 607 | + | ||
| 608 | + | t.Run("foreign path is incompatible", func(t *testing.T) { | |
| 609 | + | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |
| 610 | + | require.NoError(t, os.WriteFile(socketPath, []byte("not a socket"), 0o600)) | |
| 611 | + | _, err := probeBrokerStatus(context.Background(), socketPath) | |
| 612 | + | require.ErrorIs(t, err, errBrokerIncompatible) | |
| 613 | + | }) | |
| 614 | + | ||
| 615 | + | t.Run("stale socket is absence", func(t *testing.T) { | |
| 616 | + | require.NoError(t, os.Remove(socketPath)) | |
| 617 | + | bindStaleUnixSocket(t, socketPath) | |
| 618 | + | _, err := probeBrokerStatus(context.Background(), socketPath) | |
| 619 | + | require.ErrorIs(t, err, errBrokerAbsent) | |
| 620 | + | }) | |
| 621 | + | ||
| 622 | + | t.Run("live foreign endpoint is incompatible", func(t *testing.T) { | |
| 623 | + | require.NoError(t, os.Remove(socketPath)) | |
| 624 | + | listener := bindUnixSocket(t, socketPath) | |
| 625 | + | go func() { | |
| 626 | + | for { | |
| 627 | + | conn, err := listener.Accept() | |
| 628 | + | if err != nil { | |
| 629 | + | return | |
| 630 | + | } | |
| 631 | + | _ = conn.Close() | |
| 632 | + | } | |
| 633 | + | }() | |
| 634 | + | _, err := probeBrokerStatus(context.Background(), socketPath) | |
| 635 | + | require.ErrorIs(t, err, errBrokerIncompatible) | |
| 636 | + | }) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | func TestProbeBrokerStatusSilentRegistrationIsBounded(t *testing.T) { | |
| 640 | + | layout := emptySandboxRoot(t, "") | |
| 641 | + | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 642 | + | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |
| 643 | + | // A listener that never reads or answers: the dial succeeds and the broker | |
| 644 | + | // handshake stalls until the probe budget expires. | |
| 645 | + | _ = bindUnixSocket(t, socketPath) | |
| 646 | + | ||
| 647 | + | ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) | |
| 648 | + | defer cancel() | |
| 649 | + | start := time.Now() | |
| 650 | + | _, err := probeBrokerStatus(ctx, socketPath) | |
| 651 | + | require.Error(t, err) | |
| 652 | + | require.False(t, errors.Is(err, errBrokerAbsent), "a stalled handshake must not be classified as absence") | |
| 653 | + | require.Less(t, time.Since(start), 5*time.Second) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | // bindUnixSocket binds one real Unix listener at path. | |
| 657 | + | func bindUnixSocket(t *testing.T, path string) net.Listener { | |
| 658 | + | t.Helper() | |
| 659 | + | listener, err := net.Listen("unix", path) | |
| 660 | + | require.NoError(t, err) | |
| 661 | + | t.Cleanup(func() { _ = listener.Close() }) | |
| 662 | + | return listener | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | // bindStaleUnixSocket leaves a socket file behind with no live listener, exactly | |
| 666 | + | // like an owner that died without unlinking its endpoint. | |
| 667 | + | func bindStaleUnixSocket(t *testing.T, path string) { | |
| 668 | + | t.Helper() | |
| 669 | + | listener, err := net.Listen("unix", path) | |
| 670 | + | require.NoError(t, err) | |
| 671 | + | if unix, ok := listener.(*net.UnixListener); ok { | |
| 672 | + | unix.SetUnlinkOnClose(false) | |
| 673 | + | } | |
| 674 | + | require.NoError(t, listener.Close()) | |
| 675 | + | require.FileExists(t, path) | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | // Ensure the helper fixture contract used elsewhere stays coherent. | |
| 679 | + | func TestStatusFixtureDocument(t *testing.T) { | |
| 680 | + | document := sandboxEmptyDocument("1s") | |
| 681 | + | require.Equal(t, brokerconfig.Marker, document["marker"]) | |
| 682 | + | require.Equal(t, "1s", document["idleGrace"]) | |
| 683 | + | require.Empty(t, sandboxEmptyDocument("")["idleGrace"]) | |
| 684 | + | } | |
A internal/app/broker_offline_test.go +715 -0
| @@ -0,0 +1,715 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "log/slog" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "sort" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "sync/atomic" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 22 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 23 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 24 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 25 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 26 | + | "github.com/bnema/vev/internal/domain" | |
| 27 | + | "github.com/bnema/vev/internal/ports" | |
| 28 | + | "github.com/bnema/vev/internal/protocol" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | const ( | |
| 32 | + | brokerTestEndpoint = "offline@daemon:2222" | |
| 33 | + | brokerTestIdentity = "offline-daemon" | |
| 34 | + | brokerTestWait = 5 * time.Second | |
| 35 | + | brokerTestPollTick = 5 * time.Millisecond | |
| 36 | + | brokerTestIdleAdvance = time.Minute | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // brokerTestPolicy is the exact policy provisioned and offered by the fixture. | |
| 40 | + | func brokerTestPolicy() ports.BrokerPolicy { | |
| 41 | + | return ports.BrokerPolicy{ | |
| 42 | + | ProtocolVersion: protocol.Version, | |
| 43 | + | CatalogSchemaVersion: 3, | |
| 44 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 45 | + | Transport: "unix-mux", | |
| 46 | + | Trust: "offline-trust", | |
| 47 | + | Launch: "offline-launch", | |
| 48 | + | Isolation: "offline-isolation", | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // brokerTestRegistration is the exact registration provisioned by the sandbox | |
| 53 | + | // config and carried by the request. | |
| 54 | + | func brokerTestRegistration() domain.RemoteRegistration { | |
| 55 | + | var registration domain.RemoteRegistration | |
| 56 | + | registration.Endpoint = brokerTestEndpoint | |
| 57 | + | for i := range registration.Incarnation { | |
| 58 | + | registration.Incarnation[i] = byte(i + 1) | |
| 59 | + | } | |
| 60 | + | registration.Generation = 1 | |
| 61 | + | return registration | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // sandboxRegistrationDocument renders one registration document with the given | |
| 65 | + | // route (a Unix path string or an explicit route object). | |
| 66 | + | func sandboxRegistrationDocument(route any) map[string]any { | |
| 67 | + | policy := brokerTestPolicy() | |
| 68 | + | return map[string]any{ | |
| 69 | + | "marker": brokerconfig.Marker, | |
| 70 | + | "registrations": []any{map[string]any{ | |
| 71 | + | "endpoint": brokerTestEndpoint, | |
| 72 | + | "incarnation": "0102030405060708090a0b0c0d0e0f10", | |
| 73 | + | "generation": 1, | |
| 74 | + | "identity": brokerTestIdentity, | |
| 75 | + | "route": route, | |
| 76 | + | "policy": map[string]any{ | |
| 77 | + | "protocolVersion": policy.ProtocolVersion, | |
| 78 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 79 | + | "environmentPolicy": "client-owned", | |
| 80 | + | "transport": policy.Transport, | |
| 81 | + | "trust": policy.Trust, | |
| 82 | + | "launch": policy.Launch, | |
| 83 | + | "isolation": policy.Isolation, | |
| 84 | + | }, | |
| 85 | + | }}, | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // writeSandboxConfig marshals one document into root/config.json owner-only. | |
| 90 | + | func writeSandboxConfig(t *testing.T, root string, document any) { | |
| 91 | + | t.Helper() | |
| 92 | + | raw, err := json.Marshal(document) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.NoError(t, os.WriteFile(filepath.Join(root, brokerconfig.ConfigFileName), raw, 0o600)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // isolateSandboxEnv redirects production XDG roots into private temporary | |
| 98 | + | // directories and returns one fresh offline root beside them, so the sandbox | |
| 99 | + | // can never touch the real runtime or state paths. | |
| 100 | + | func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) { | |
| 101 | + | t.Helper() | |
| 102 | + | prodRuntime = t.TempDir() | |
| 103 | + | prodState = t.TempDir() | |
| 104 | + | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |
| 105 | + | t.Setenv("XDG_STATE_HOME", prodState) | |
| 106 | + | root = filepath.Join(shortTempDir(t, "vevs"), "sandbox") | |
| 107 | + | return root, prodRuntime, prodState | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // requireProductionUntouched asserts no production runtime or state entry was | |
| 111 | + | // created. | |
| 112 | + | func requireProductionUntouched(t *testing.T, prodRuntime, prodState string) { | |
| 113 | + | t.Helper() | |
| 114 | + | for _, dir := range []string{prodRuntime, prodState} { | |
| 115 | + | entries, err := os.ReadDir(dir) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.Empty(t, entries, "production directory %s was touched", dir) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // testBrokerServeDeps builds sandbox deps with a deterministic clock and a | |
| 122 | + | // discard logger, and reports readiness on the returned channel. | |
| 123 | + | func testBrokerServeDeps(clk ports.Clock) (brokerServeDeps, <-chan string) { | |
| 124 | + | deps := defaultBrokerServeDeps() | |
| 125 | + | deps.clock = func() ports.Clock { return clk } | |
| 126 | + | deps.newLogger = func(string) (*slog.Logger, io.Closer, error) { | |
| 127 | + | return slog.New(slog.NewTextHandler(io.Discard, nil)), io.NopCloser(strings.NewReader("")), nil | |
| 128 | + | } | |
| 129 | + | ready := make(chan string, 1) | |
| 130 | + | deps.onReady = func(socketPath string) { | |
| 131 | + | select { | |
| 132 | + | case ready <- socketPath: | |
| 133 | + | default: | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | return deps, ready | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // runSandbox starts one sandbox serve goroutine and returns its result channel. | |
| 140 | + | func runSandbox(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) <-chan error { | |
| 141 | + | done := make(chan error, 1) | |
| 142 | + | go func() { done <- runBrokerServe(ctx, options, deps) }() | |
| 143 | + | return done | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // awaitSandboxCancel cancels the sandbox and requires an orderly shutdown. | |
| 147 | + | func awaitSandboxCancel(t *testing.T, cancel context.CancelFunc, done <-chan error) { | |
| 148 | + | t.Helper() | |
| 149 | + | cancel() | |
| 150 | + | select { | |
| 151 | + | case err := <-done: | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | case <-time.After(brokerTestWait): | |
| 154 | + | t.Fatal("sandbox did not shut down after cancellation") | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // fatalListener is a ports.BrokerListener whose Accept always reports one | |
| 159 | + | // fixed terminal error, so a test can drive the accept drain without a carriage. | |
| 160 | + | type fatalListener struct{ err error } | |
| 161 | + | ||
| 162 | + | func (l fatalListener) Accept() (ports.BrokerService, error) { return nil, l.err } | |
| 163 | + | func (l fatalListener) Close() error { return nil } | |
| 164 | + | func (l fatalListener) Addr() string { return "fatal" } | |
| 165 | + | ||
| 166 | + | // TestDrainBrokerAcceptClassifiesTerminalFailure proves the drain treats a | |
| 167 | + | // listener close as an orderly end with no log and no failure signal, while any | |
| 168 | + | // other terminal accept failure is logged and reported on the failure channel so | |
| 169 | + | // the caller can commit shutdown. | |
| 170 | + | func TestDrainBrokerAcceptClassifiesTerminalFailure(t *testing.T) { | |
| 171 | + | tests := []struct { | |
| 172 | + | name string | |
| 173 | + | err error | |
| 174 | + | wantLogged bool | |
| 175 | + | wantFailed bool | |
| 176 | + | }{ | |
| 177 | + | {name: "listener close is orderly and quiet", err: brokeripc.ErrListenerClosed}, | |
| 178 | + | {name: "unexpected terminal failure is logged and signaled", err: errors.New("carriage accept failed"), wantLogged: true, wantFailed: true}, | |
| 179 | + | } | |
| 180 | + | for _, tt := range tests { | |
| 181 | + | t.Run(tt.name, func(t *testing.T) { | |
| 182 | + | var buffer bytes.Buffer | |
| 183 | + | log := slog.New(slog.NewTextHandler(&buffer, nil)) | |
| 184 | + | started := make(chan struct{}) | |
| 185 | + | drained := make(chan struct{}) | |
| 186 | + | failed := make(chan struct{}) | |
| 187 | + | go drainBrokerAccept(fatalListener{err: tt.err}, log, started, drained, failed) | |
| 188 | + | ||
| 189 | + | select { | |
| 190 | + | case <-started: | |
| 191 | + | case <-time.After(brokerTestWait): | |
| 192 | + | t.Fatal("the drain never started") | |
| 193 | + | } | |
| 194 | + | select { | |
| 195 | + | case <-drained: | |
| 196 | + | case <-time.After(brokerTestWait): | |
| 197 | + | t.Fatal("the drain did not stop on a terminal accept failure") | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | select { | |
| 201 | + | case <-failed: | |
| 202 | + | require.True(t, tt.wantFailed, "an orderly listener close must not signal failure") | |
| 203 | + | default: | |
| 204 | + | require.False(t, tt.wantFailed, "an unexpected terminal failure must signal shutdown") | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | if tt.wantLogged { | |
| 208 | + | require.Contains(t, buffer.String(), "broker_offline_accept_failed") | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | require.Empty(t, strings.TrimSpace(buffer.String()), "an orderly listener close must not be logged") | |
| 212 | + | }) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestParseBrokerServeArgs(t *testing.T) { | |
| 217 | + | tests := []struct { | |
| 218 | + | name string | |
| 219 | + | args []string | |
| 220 | + | want brokerServeOptions | |
| 221 | + | wantErr string | |
| 222 | + | }{ | |
| 223 | + | {name: "root only", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerServeOptions{offlineRoot: "/srv/sandbox"}}, | |
| 224 | + | { | |
| 225 | + | name: "root and grace", | |
| 226 | + | args: []string{"--offline-root", "/srv/sandbox", "--idle-grace", "90s"}, | |
| 227 | + | want: brokerServeOptions{offlineRoot: "/srv/sandbox", idleGrace: 90 * time.Second}, | |
| 228 | + | }, | |
| 229 | + | {name: "missing root", args: nil, wantErr: "requires --offline-root"}, | |
| 230 | + | {name: "missing root value", args: []string{"--offline-root"}, wantErr: "requires a path"}, | |
| 231 | + | {name: "empty root value", args: []string{"--offline-root", ""}, wantErr: "requires a path"}, | |
| 232 | + | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |
| 233 | + | {name: "duplicate grace", args: []string{"--offline-root", "/a", "--idle-grace", "1s", "--idle-grace", "2s"}, wantErr: "duplicate --idle-grace"}, | |
| 234 | + | {name: "missing grace value", args: []string{"--offline-root", "/a", "--idle-grace"}, wantErr: "requires a duration"}, | |
| 235 | + | {name: "invalid duration", args: []string{"--offline-root", "/a", "--idle-grace", "soon"}, wantErr: "not a duration"}, | |
| 236 | + | {name: "zero duration", args: []string{"--offline-root", "/a", "--idle-grace", "0s"}, wantErr: "must be positive"}, | |
| 237 | + | {name: "negative duration", args: []string{"--offline-root", "/a", "--idle-grace", "-1s"}, wantErr: "must be positive"}, | |
| 238 | + | {name: "unknown flag", args: []string{"--offline-root", "/a", "--verbose"}, wantErr: "unknown flag"}, | |
| 239 | + | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 240 | + | {name: "equals form is refused", args: []string{"--offline-root=/a"}, wantErr: "unknown flag"}, | |
| 241 | + | } | |
| 242 | + | for _, tt := range tests { | |
| 243 | + | t.Run(tt.name, func(t *testing.T) { | |
| 244 | + | command, err := parseBrokerServeArgs(tt.args) | |
| 245 | + | if tt.wantErr != "" { | |
| 246 | + | require.Error(t, err) | |
| 247 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | require.NoError(t, err) | |
| 251 | + | require.Equal(t, kindBrokerServe, command.kind) | |
| 252 | + | require.Equal(t, tt.want, command.brokerServe) | |
| 253 | + | }) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestBrokerServeIsHiddenFromPublicHelp(t *testing.T) { | |
| 258 | + | require.NotContains(t, usageText, brokerServeCommand) | |
| 259 | + | _, err := parseArgs([]string{brokerServeCommand, "--offline-root", "/srv/sandbox"}) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | _, err = parseArgs([]string{"--help"}) | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestBrokerServeRejectsUnsafeRoot(t *testing.T) { | |
| 266 | + | root, _, _ := isolateSandboxEnv(t) | |
| 267 | + | ctx := context.Background() | |
| 268 | + | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 269 | + | ||
| 270 | + | t.Run("relative root", func(t *testing.T) { | |
| 271 | + | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: "relative"}, deps) | |
| 272 | + | require.Error(t, err) | |
| 273 | + | require.Contains(t, err.Error(), "not absolute") | |
| 274 | + | }) | |
| 275 | + | t.Run("unclean root", func(t *testing.T) { | |
| 276 | + | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: root + "/../sandbox"}, deps) | |
| 277 | + | require.Error(t, err) | |
| 278 | + | require.Contains(t, err.Error(), "not clean") | |
| 279 | + | }) | |
| 280 | + | t.Run("production overlap", func(t *testing.T) { | |
| 281 | + | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: ipc.SocketDir()}, deps) | |
| 282 | + | require.Error(t, err) | |
| 283 | + | require.Contains(t, err.Error(), "overlaps production path") | |
| 284 | + | }) | |
| 285 | + | t.Run("symlinked root", func(t *testing.T) { | |
| 286 | + | link := filepath.Join(t.TempDir(), "link") | |
| 287 | + | require.NoError(t, os.Symlink(t.TempDir(), link)) | |
| 288 | + | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: link}, deps) | |
| 289 | + | require.Error(t, err) | |
| 290 | + | require.Contains(t, err.Error(), "symlink") | |
| 291 | + | }) | |
| 292 | + | t.Run("missing config", func(t *testing.T) { | |
| 293 | + | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 294 | + | require.Error(t, err) | |
| 295 | + | require.Contains(t, err.Error(), brokerconfig.ConfigFileName) | |
| 296 | + | }) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func TestBrokerServeRoundTrip(t *testing.T) { | |
| 300 | + | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 301 | + | policy := brokerTestPolicy() | |
| 302 | + | fixture := newBrokerMuxFixture(t, policy) | |
| 303 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 304 | + | writeSandboxConfig(t, root, sandboxRegistrationDocument(fixture.route)) | |
| 305 | + | ||
| 306 | + | clk := newSandboxClock() | |
| 307 | + | deps, ready := testBrokerServeDeps(clk) | |
| 308 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 309 | + | defer cancel() | |
| 310 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 311 | + | ||
| 312 | + | socketPath := awaitSandboxReady(t, ready) | |
| 313 | + | ||
| 314 | + | clientCtx, clientCancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 315 | + | defer clientCancel() | |
| 316 | + | service, err := brokeripc.Dial(clientCtx, socketPath, brokeripc.Config{}) | |
| 317 | + | require.NoError(t, err) | |
| 318 | + | defer func() { _ = service.Close() }() | |
| 319 | + | ||
| 320 | + | stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{ | |
| 321 | + | Purpose: ports.BrokerStreamControl, | |
| 322 | + | Endpoint: brokerTestEndpoint, | |
| 323 | + | Registration: brokerTestRegistration(), | |
| 324 | + | Policy: policy, | |
| 325 | + | }) | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | defer func() { _ = stream.Close() }() | |
| 328 | + | ||
| 329 | + | type pingResult struct { | |
| 330 | + | message protocol.ServerMessage | |
| 331 | + | err error | |
| 332 | + | } | |
| 333 | + | result := make(chan pingResult, 1) | |
| 334 | + | go func() { | |
| 335 | + | if sendErr := stream.SendClient(protocol.Ping{}); sendErr != nil { | |
| 336 | + | result <- pingResult{err: sendErr} | |
| 337 | + | return | |
| 338 | + | } | |
| 339 | + | message, recvErr := stream.ReceiveServer() | |
| 340 | + | result <- pingResult{message: message, err: recvErr} | |
| 341 | + | }() | |
| 342 | + | select { | |
| 343 | + | case outcome := <-result: | |
| 344 | + | require.NoError(t, outcome.err) | |
| 345 | + | require.Equal(t, protocol.Pong{}, outcome.message) | |
| 346 | + | case <-time.After(brokerTestWait): | |
| 347 | + | t.Fatal("typed IPC -> pool -> mux round trip timed out") | |
| 348 | + | } | |
| 349 | + | require.Positive(t, fixture.streams.Load()) | |
| 350 | + | ||
| 351 | + | awaitSandboxCancel(t, cancel, done) | |
| 352 | + | requireSocketRemoved(t, socketPath) | |
| 353 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // awaitSandboxReady waits for the sandbox readiness hook and returns the | |
| 357 | + | // reported socket path. | |
| 358 | + | func awaitSandboxReady(t *testing.T, ready <-chan string) string { | |
| 359 | + | t.Helper() | |
| 360 | + | select { | |
| 361 | + | case socketPath := <-ready: | |
| 362 | + | require.NotEmpty(t, socketPath) | |
| 363 | + | return socketPath | |
| 364 | + | case <-time.After(brokerTestWait): | |
| 365 | + | t.Fatal("sandbox never reported readiness") | |
| 366 | + | return "" | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // requireSocketRemoved asserts the sandbox socket inode was unlinked. | |
| 371 | + | func requireSocketRemoved(t *testing.T, socketPath string) { | |
| 372 | + | t.Helper() | |
| 373 | + | _, err := os.Lstat(socketPath) | |
| 374 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // waitForSandboxShutdown advances the manual clock until the sandbox has shut | |
| 378 | + | // down, so a real idle grace is never awaited. | |
| 379 | + | func waitForSandboxShutdown(t *testing.T, clk *sandboxClock, done <-chan error) { | |
| 380 | + | t.Helper() | |
| 381 | + | deadline := time.After(brokerTestWait) | |
| 382 | + | for { | |
| 383 | + | select { | |
| 384 | + | case err := <-done: | |
| 385 | + | require.NoError(t, err) | |
| 386 | + | return | |
| 387 | + | case <-time.After(brokerTestPollTick): | |
| 388 | + | clk.Advance(brokerTestIdleAdvance) | |
| 389 | + | case <-deadline: | |
| 390 | + | t.Fatal("idle sandbox did not shut down") | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestBrokerServeIdleShutdownOnEmptyConfig(t *testing.T) { | |
| 396 | + | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 397 | + | clk := newSandboxClock() | |
| 398 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 399 | + | writeSandboxConfig(t, root, map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}}) | |
| 400 | + | ||
| 401 | + | deps, ready := testBrokerServeDeps(clk) | |
| 402 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 403 | + | defer cancel() | |
| 404 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 405 | + | ||
| 406 | + | socketPath := awaitSandboxReady(t, ready) | |
| 407 | + | waitForSandboxShutdown(t, clk, done) | |
| 408 | + | ||
| 409 | + | requireSocketRemoved(t, socketPath) | |
| 410 | + | requireOwnerLockReleased(t, root) | |
| 411 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // requireOwnerLockReleased proves the lifetime lock is no longer held. | |
| 415 | + | func requireOwnerLockReleased(t *testing.T, root string) { | |
| 416 | + | t.Helper() | |
| 417 | + | owner, err := lifecycle.TryAcquire(filepath.Join(root, brokerconfig.RuntimeDirName)) | |
| 418 | + | require.NoError(t, err) | |
| 419 | + | require.NoError(t, owner.Release()) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func TestBrokerServeCancelCleanup(t *testing.T) { | |
| 423 | + | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 424 | + | clk := newSandboxClock() | |
| 425 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 426 | + | writeSandboxConfig(t, root, map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}}) | |
| 427 | + | ||
| 428 | + | deps, ready := testBrokerServeDeps(clk) | |
| 429 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 430 | + | defer cancel() | |
| 431 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 432 | + | ||
| 433 | + | socketPath := awaitSandboxReady(t, ready) | |
| 434 | + | awaitSandboxCancel(t, cancel, done) | |
| 435 | + | ||
| 436 | + | requireSocketRemoved(t, socketPath) | |
| 437 | + | requireOwnerLockReleased(t, root) | |
| 438 | + | _, err := os.Lstat(filepath.Join(root, brokerconfig.StateDirName, "state.json")) | |
| 439 | + | require.NoError(t, err, "durable sandbox state must remain after shutdown") | |
| 440 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // wedgedListener is a ports.BrokerListener whose Accept always reports one | |
| 444 | + | // unexpected terminal failure and whose Close records that the sandbox tore it | |
| 445 | + | // down, so a test can prove an accept failure does not leave a bound broker. | |
| 446 | + | type wedgedListener struct { | |
| 447 | + | err error | |
| 448 | + | addr string | |
| 449 | + | closed atomic.Bool | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func (l *wedgedListener) Accept() (ports.BrokerService, error) { return nil, l.err } | |
| 453 | + | func (l *wedgedListener) Close() error { | |
| 454 | + | l.closed.Store(true) | |
| 455 | + | return nil | |
| 456 | + | } | |
| 457 | + | func (l *wedgedListener) Addr() string { return l.addr } | |
| 458 | + | ||
| 459 | + | // TestBrokerServeShutsDownOnUnexpectedAcceptFailure proves an unexpected | |
| 460 | + | // terminal accept failure commits shutdown and drains the listener, instead of | |
| 461 | + | // leaving a bound broker whose socket can no longer admit work. | |
| 462 | + | func TestBrokerServeShutsDownOnUnexpectedAcceptFailure(t *testing.T) { | |
| 463 | + | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 464 | + | clk := newSandboxClock() | |
| 465 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 466 | + | writeSandboxConfig(t, root, map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}}) | |
| 467 | + | ||
| 468 | + | listener := &wedgedListener{err: errors.New("carriage accept failed"), addr: "wedged"} | |
| 469 | + | deps, ready := testBrokerServeDeps(clk) | |
| 470 | + | deps.listen = func(string, ports.BrokerEpoch, ports.BrokerAuthority, brokeripc.Config) (ports.BrokerListener, error) { | |
| 471 | + | return listener, nil | |
| 472 | + | } | |
| 473 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 474 | + | defer cancel() | |
| 475 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 476 | + | ||
| 477 | + | awaitSandboxReady(t, ready) | |
| 478 | + | select { | |
| 479 | + | case err := <-done: | |
| 480 | + | require.NoError(t, err) | |
| 481 | + | case <-time.After(brokerTestWait): | |
| 482 | + | t.Fatal("the broker wedged on an unexpected accept failure") | |
| 483 | + | } | |
| 484 | + | require.True(t, listener.closed.Load(), "shutdown must close the wedged listener") | |
| 485 | + | requireOwnerLockReleased(t, root) | |
| 486 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | func TestBrokerServeRefusesDuplicateOwnership(t *testing.T) { | |
| 490 | + | root, _, _ := isolateSandboxEnv(t) | |
| 491 | + | clk := newSandboxClock() | |
| 492 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 493 | + | writeSandboxConfig(t, root, map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}}) | |
| 494 | + | ||
| 495 | + | deps, ready := testBrokerServeDeps(clk) | |
| 496 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 497 | + | defer cancel() | |
| 498 | + | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 499 | + | socketPath := awaitSandboxReady(t, ready) | |
| 500 | + | ||
| 501 | + | // A second foreground owner fails fast on the lifetime lock. | |
| 502 | + | secondDeps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 503 | + | err := runBrokerServe(context.Background(), brokerServeOptions{offlineRoot: root}, secondDeps) | |
| 504 | + | require.ErrorIs(t, err, lifecycle.ErrBusy) | |
| 505 | + | ||
| 506 | + | // A duplicate listener on the live socket is refused as well. | |
| 507 | + | duplicate, err := ipc.ListenMux(socketPath) | |
| 508 | + | if duplicate != nil { | |
| 509 | + | _ = duplicate.Close() | |
| 510 | + | } | |
| 511 | + | require.ErrorIs(t, err, ipc.ErrDaemonRunning) | |
| 512 | + | ||
| 513 | + | awaitSandboxCancel(t, cancel, done) | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | // serveTestPongs answers every typed Ping of one accepted daemonmux stream with | |
| 517 | + | // exactly one Pong until the stream ends. | |
| 518 | + | func serveTestPongs(connection ports.ServerConnection) { | |
| 519 | + | for { | |
| 520 | + | message, err := connection.ReceiveClient() | |
| 521 | + | if err != nil { | |
| 522 | + | return | |
| 523 | + | } | |
| 524 | + | if _, ok := message.(protocol.Ping); !ok { | |
| 525 | + | continue | |
| 526 | + | } | |
| 527 | + | if err := connection.SendServer(protocol.Pong{}); err != nil { | |
| 528 | + | return | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | // brokerMuxFixture is a real Unix daemonmux server: one private mux carriage | |
| 534 | + | // adopted by a supervisor into an aggregate listener that answers typed Pings. | |
| 535 | + | // streams counts admitted typed logical streams so a test can prove the | |
| 536 | + | // request actually reached the mux fixture. | |
| 537 | + | type brokerMuxFixture struct { | |
| 538 | + | route string | |
| 539 | + | streams atomic.Int64 | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | // newBrokerMuxFixture binds one real private Unix mux carriage and serves typed | |
| 543 | + | // Pings on every admitted logical stream. | |
| 544 | + | func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixture { | |
| 545 | + | t.Helper() | |
| 546 | + | routeDir := t.TempDir() | |
| 547 | + | require.NoError(t, os.Chmod(routeDir, 0o700)) | |
| 548 | + | route := filepath.Join(routeDir, "mux.sock") | |
| 549 | + | listener, err := ipc.ListenMux(route) | |
| 550 | + | require.NoError(t, err) | |
| 551 | + | ||
| 552 | + | aggregate := daemonmux.NewAggregateListener() | |
| 553 | + | var incarnation ports.BrokerDaemonIncarnation | |
| 554 | + | for i := range incarnation { | |
| 555 | + | incarnation[i] = byte(0x10 + i) | |
| 556 | + | } | |
| 557 | + | binding, err := daemonmux.NewServerBinding(ports.BrokerDaemonIdentity(brokerTestIdentity), incarnation, policy) | |
| 558 | + | require.NoError(t, err) | |
| 559 | + | supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 8) | |
| 560 | + | require.NoError(t, err) | |
| 561 | + | ||
| 562 | + | fixture := &brokerMuxFixture{route: route} | |
| 563 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 564 | + | t.Cleanup(func() { | |
| 565 | + | cancel() | |
| 566 | + | _ = supervisor.Close() | |
| 567 | + | _ = aggregate.Close() | |
| 568 | + | _ = listener.Close() | |
| 569 | + | }) | |
| 570 | + | ||
| 571 | + | go func() { | |
| 572 | + | for { | |
| 573 | + | raw, acceptErr := listener.Accept() | |
| 574 | + | if acceptErr != nil { | |
| 575 | + | return | |
| 576 | + | } | |
| 577 | + | go func() { _ = supervisor.Adopt(ctx, raw) }() | |
| 578 | + | } | |
| 579 | + | }() | |
| 580 | + | go func() { | |
| 581 | + | for { | |
| 582 | + | connection, acceptErr := aggregate.Accept() | |
| 583 | + | if acceptErr != nil { | |
| 584 | + | return | |
| 585 | + | } | |
| 586 | + | fixture.streams.Add(1) | |
| 587 | + | go serveTestPongs(connection) | |
| 588 | + | } | |
| 589 | + | }() | |
| 590 | + | return fixture | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | // sandboxClock is a deterministic ports.Clock/ports.Timer pair: time only moves | |
| 594 | + | // when Advance is called, so idle shutdown is exercised without wall time. | |
| 595 | + | type sandboxClock struct { | |
| 596 | + | mu sync.Mutex | |
| 597 | + | now time.Time | |
| 598 | + | order uint64 | |
| 599 | + | timers map[*sandboxTimer]struct{} | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func newSandboxClock() *sandboxClock { | |
| 603 | + | return &sandboxClock{now: time.Unix(0, 0), timers: make(map[*sandboxTimer]struct{})} | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | func (c *sandboxClock) Now() time.Time { | |
| 607 | + | c.mu.Lock() | |
| 608 | + | defer c.mu.Unlock() | |
| 609 | + | return c.now | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | func (c *sandboxClock) NewTimer(d time.Duration) ports.Timer { | |
| 613 | + | timer := &sandboxTimer{clock: c, ch: make(chan time.Time, 1)} | |
| 614 | + | c.mu.Lock() | |
| 615 | + | defer c.mu.Unlock() | |
| 616 | + | timer.arm(d) | |
| 617 | + | return timer | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | // Advance moves time forward and fires every timer that came due. | |
| 621 | + | func (c *sandboxClock) Advance(d time.Duration) { | |
| 622 | + | if d < 0 { | |
| 623 | + | panic("sandboxClock.Advance requires a non-negative duration") | |
| 624 | + | } | |
| 625 | + | c.mu.Lock() | |
| 626 | + | defer c.mu.Unlock() | |
| 627 | + | c.now = c.now.Add(d) | |
| 628 | + | due := make([]*sandboxTimer, 0, len(c.timers)) | |
| 629 | + | for timer := range c.timers { | |
| 630 | + | if !timer.when.After(c.now) { | |
| 631 | + | due = append(due, timer) | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | sort.Slice(due, func(i, j int) bool { | |
| 635 | + | if due[i].when.Equal(due[j].when) { | |
| 636 | + | return due[i].order < due[j].order | |
| 637 | + | } | |
| 638 | + | return due[i].when.Before(due[j].when) | |
| 639 | + | }) | |
| 640 | + | for _, timer := range due { | |
| 641 | + | timer.active = false | |
| 642 | + | delete(c.timers, timer) | |
| 643 | + | select { | |
| 644 | + | case timer.ch <- c.now: | |
| 645 | + | default: | |
| 646 | + | } | |
| 647 | + | } | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | type sandboxTimer struct { | |
| 651 | + | clock *sandboxClock | |
| 652 | + | ch chan time.Time | |
| 653 | + | when time.Time | |
| 654 | + | order uint64 | |
| 655 | + | active bool | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func (t *sandboxTimer) C() <-chan time.Time { return t.ch } | |
| 659 | + | ||
| 660 | + | func (t *sandboxTimer) Stop() bool { | |
| 661 | + | t.clock.mu.Lock() | |
| 662 | + | defer t.clock.mu.Unlock() | |
| 663 | + | if !t.active { | |
| 664 | + | return false | |
| 665 | + | } | |
| 666 | + | t.active = false | |
| 667 | + | delete(t.clock.timers, t) | |
| 668 | + | return true | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | func (t *sandboxTimer) Reset(d time.Duration) bool { | |
| 672 | + | t.clock.mu.Lock() | |
| 673 | + | defer t.clock.mu.Unlock() | |
| 674 | + | wasActive := t.active | |
| 675 | + | if t.active { | |
| 676 | + | t.active = false | |
| 677 | + | delete(t.clock.timers, t) | |
| 678 | + | } | |
| 679 | + | select { | |
| 680 | + | case <-t.ch: | |
| 681 | + | default: | |
| 682 | + | } | |
| 683 | + | t.arm(d) | |
| 684 | + | return wasActive | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | func (t *sandboxTimer) arm(d time.Duration) { | |
| 688 | + | t.order = t.clock.order | |
| 689 | + | t.clock.order++ | |
| 690 | + | t.when = t.clock.now.Add(d) | |
| 691 | + | t.active = true | |
| 692 | + | t.clock.timers[t] = struct{}{} | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | func TestSandboxClockFiresOnAdvance(t *testing.T) { | |
| 696 | + | clk := newSandboxClock() | |
| 697 | + | timer := clk.NewTimer(time.Minute) | |
| 698 | + | require.True(t, timer.Stop()) | |
| 699 | + | timer = clk.NewTimer(time.Minute) | |
| 700 | + | clk.Advance(time.Minute) | |
| 701 | + | select { | |
| 702 | + | case <-timer.C(): | |
| 703 | + | case <-time.After(brokerTestWait): | |
| 704 | + | t.Fatal("manual timer did not fire") | |
| 705 | + | } | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | // Ensure the fixture and registration helpers stay coherent. | |
| 709 | + | func TestBrokerSandboxFixtures(t *testing.T) { | |
| 710 | + | require.NoError(t, brokerTestRegistration().Validate()) | |
| 711 | + | require.NoError(t, brokerTestPolicy().Validate()) | |
| 712 | + | document := sandboxRegistrationDocument("/tmp/mux.sock") | |
| 713 | + | require.Equal(t, brokerconfig.Marker, document["marker"]) | |
| 714 | + | require.Len(t, document["registrations"], 1) | |
| 715 | + | } | |
M internal/app/connectivity_matrix.go +42 -0
| @@ -216,6 +216,48 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 216 | 216 | Owner: connectivityLocalOnly, | |
| 217 | 217 | Notes: "No connectivity.", | |
| 218 | 218 | }, | |
| 219 | + | { | |
| 220 | + | Kind: "kindBrokerServe", | |
| 221 | + | Summary: "hidden _broker-serve isolated offline broker sandbox", | |
| 222 | + | Owner: connectivityLocalOnly, | |
| 223 | + | Notes: "Foreground sandbox process over its own private root: no production runtime/state, no daemon dial, no RemoteHostStore, and no session persistence. It composes the broker under a temporary offline config and does not become a client façade or an ordinary command.", | |
| 224 | + | }, | |
| 225 | + | { | |
| 226 | + | Kind: "kindBrokerLauncher", | |
| 227 | + | Summary: "hidden _broker-launcher detached offline broker launcher", | |
| 228 | + | Owner: connectivityLocalOnly, | |
| 229 | + | Notes: "Process mechanics only, scoped to one operator-supplied offline root: it starts _broker-serve in a new session and exits, like the daemon launcher, and never dials a daemon, owns the host store, or mutates persistence.", | |
| 230 | + | }, | |
| 231 | + | { | |
| 232 | + | Kind: "kindBrokerStatus", | |
| 233 | + | Summary: "hidden _broker-status offline broker probe and connect-or-spawn", | |
| 234 | + | Owner: connectivityLocalOnly, | |
| 235 | + | Notes: "Reports the offline broker's local IPC endpoint and, with --ensure, elects one spawner under a descriptor-backed lock. It dials only the operator-supplied broker sandbox socket (never a daemon or RemoteHostStore) and never becomes a client façade or ordinary command.", | |
| 236 | + | }, | |
| 237 | + | { | |
| 238 | + | Kind: "kindBrokerMuxStdio", | |
| 239 | + | Summary: "hidden _broker-mux-stdio remote SSH stdio daemonmux bridge", | |
| 240 | + | Owner: connectivityTransportInfra, | |
| 241 | + | DirectDialDebt: []string{ | |
| 242 | + | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |
| 243 | + | }, | |
| 244 | + | Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or ordinary daemon, never dials the production daemon socket, and never fabricates a daemon incarnation. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.", | |
| 245 | + | }, | |
| 246 | + | { | |
| 247 | + | Kind: "kindBrokerMuxQUICBootstrap", | |
| 248 | + | Summary: "hidden _broker-mux-quic-bootstrap remote SSH-side QUIC bootstrap", | |
| 249 | + | Owner: connectivityTransportInfra, | |
| 250 | + | Notes: "Starts one detached _broker-mux-quic-proxy in a new session, forwards its single readiness line, and exits. Its only child is that proxy: it never starts a broker, observer, or ordinary daemon, and one-time QUIC credentials never reach an address, log, or error.", | |
| 251 | + | }, | |
| 252 | + | { | |
| 253 | + | Kind: "kindBrokerMuxQUICProxy", | |
| 254 | + | Summary: "hidden _broker-mux-quic-proxy detached remote QUIC daemonmux bridge", | |
| 255 | + | Owner: connectivityTransportInfra, | |
| 256 | + | DirectDialDebt: []string{ | |
| 257 | + | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |
| 258 | + | }, | |
| 259 | + | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never starts a daemon, and never fabricates a daemon incarnation.", | |
| 260 | + | }, | |
| 219 | 261 | } | |
| 220 | 262 | ||
| 221 | 263 | // connectivityOwnerFor returns the matrix owner for a cmdKind identifier. | |
M internal/app/run.go +34 -0
| @@ -74,6 +74,12 @@ const ( | |||
| 74 | 74 | kindWebDaemon | |
| 75 | 75 | kindWebRenew | |
| 76 | 76 | kindWebServe | |
| 77 | + | kindBrokerServe | |
| 78 | + | kindBrokerLauncher | |
| 79 | + | kindBrokerStatus | |
| 80 | + | kindBrokerMuxStdio | |
| 81 | + | kindBrokerMuxQUICBootstrap | |
| 82 | + | kindBrokerMuxQUICProxy | |
| 77 | 83 | kindHelp | |
| 78 | 84 | kindVersion | |
| 79 | 85 | ) | |
| @@ -92,6 +98,10 @@ type command struct { | |||
| 92 | 98 | killAll bool | |
| 93 | 99 | killDaemon bool | |
| 94 | 100 | cmd cmdInvocation | |
| 101 | + | brokerServe brokerServeOptions | |
| 102 | + | brokerLauncher brokerLauncherOptions | |
| 103 | + | brokerStatus brokerStatusOptions | |
| 104 | + | brokerMux brokerMuxOptions | |
| 95 | 105 | remotePreviewPayload string | |
| 96 | 106 | uiDriver uiDriverOptions | |
| 97 | 107 | uiObserve bool | |
| @@ -230,6 +240,18 @@ parsedUIFlags: | |||
| 230 | 240 | return command{kind: kindDaemon}, nil | |
| 231 | 241 | case "--daemon-launcher": | |
| 232 | 242 | return command{kind: kindDaemonLauncher}, nil | |
| 243 | + | case brokerServeCommand: | |
| 244 | + | return parseBrokerServeArgs(args[1:]) | |
| 245 | + | case brokerLauncherCommand: | |
| 246 | + | return parseBrokerLauncherArgs(args[1:]) | |
| 247 | + | case brokerStatusCommand: | |
| 248 | + | return parseBrokerStatusArgs(args[1:]) | |
| 249 | + | case brokerMuxStdioCommand: | |
| 250 | + | return parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, args[1:]) | |
| 251 | + | case brokerMuxQUICBootstrapCommand: | |
| 252 | + | return parseBrokerMuxArgs(brokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap, args[1:]) | |
| 253 | + | case brokerMuxQUICProxyCommand: | |
| 254 | + | return parseBrokerMuxArgs(brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy, args[1:]) | |
| 233 | 255 | case "_stdio": | |
| 234 | 256 | if len(args) != 1 { | |
| 235 | 257 | return command{}, usagef("`_stdio` does not accept a session name") | |
| @@ -354,6 +376,18 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 354 | 376 | return runDaemon() | |
| 355 | 377 | case kindDaemonLauncher: | |
| 356 | 378 | return runDaemonLauncher() | |
| 379 | + | case kindBrokerServe: | |
| 380 | + | return runBrokerServeCommand(ctx, cmd.brokerServe) | |
| 381 | + | case kindBrokerLauncher: | |
| 382 | + | return runBrokerLauncherCommand(ctx, cmd.brokerLauncher) | |
| 383 | + | case kindBrokerStatus: | |
| 384 | + | return runBrokerStatusCommand(ctx, cmd.brokerStatus) | |
| 385 | + | case kindBrokerMuxStdio: | |
| 386 | + | return runBrokerMuxStdioCommand(ctx, cmd.brokerMux) | |
| 387 | + | case kindBrokerMuxQUICBootstrap: | |
| 388 | + | return runBrokerMuxQUICBootstrapCommand(ctx, cmd.brokerMux) | |
| 389 | + | case kindBrokerMuxQUICProxy: | |
| 390 | + | return runBrokerMuxQUICProxyCommand(ctx, cmd.brokerMux) | |
| 357 | 391 | case kindStdio: | |
| 358 | 392 | return runStdio(ctx) | |
| 359 | 393 | case kindQUICBootstrap: | |
M internal/app/run_test.go +24 -0
| @@ -244,6 +244,30 @@ func TestCatalogueFailureDoesNotListen(t *testing.T) { | |||
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | // TestLifecycleOwnershipAcceptsOwnerOnlyVariantLock is the production-daemon | |
| 248 | + | // regression for the relaxed lock-permission rule: a pre-existing lock written | |
| 249 | + | // with an owner-only mode other than 0600 must still let the daemon start, while | |
| 250 | + | // group/other access stays refused (covered by the lifecycle unit tests). | |
| 251 | + | func TestLifecycleOwnershipAcceptsOwnerOnlyVariantLock(t *testing.T) { | |
| 252 | + | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 253 | + | stateDir := filepath.Join(t.TempDir(), "state") | |
| 254 | + | require.NoError(t, safedir.EnsurePrivate(runtimeDir)) | |
| 255 | + | require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600)) | |
| 256 | + | require.NoError(t, os.Chmod(lifecycle.Path(runtimeDir), 0o700)) | |
| 257 | + | ||
| 258 | + | started := false | |
| 259 | + | err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error { | |
| 260 | + | started = true | |
| 261 | + | return nil | |
| 262 | + | }) | |
| 263 | + | require.NoError(t, err, "an owner-only variant lock must not block production daemon startup") | |
| 264 | + | require.True(t, started) | |
| 265 | + | ||
| 266 | + | owner, err := lifecycle.TryAcquire(runtimeDir) | |
| 267 | + | require.NoError(t, err, "lifecycle ownership must be released after startup") | |
| 268 | + | require.NoError(t, owner.Release()) | |
| 269 | + | } | |
| 270 | + | ||
| 247 | 271 | func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) { | |
| 248 | 272 | var events []string | |
| 249 | 273 | _, _, err := constructDaemonBeforeSocketPublication( | |
M internal/app/spawn_test.go +42 -0
| @@ -31,6 +31,27 @@ const ( | |||
| 31 | 31 | ) | |
| 32 | 32 | ||
| 33 | 33 | func TestMain(m *testing.M) { | |
| 34 | + | // The hidden offline broker commands are re-executed through this test | |
| 35 | + | // binary, so a broker test can exercise the real dispatch path in a real | |
| 36 | + | // subprocess. The helper env var keeps that interception out of ordinary | |
| 37 | + | // runs. | |
| 38 | + | if len(os.Args) >= 2 && os.Getenv(brokerHelperEnv) == "1" { | |
| 39 | + | switch os.Args[1] { | |
| 40 | + | case brokerServeCommand, brokerLauncherCommand, brokerStatusCommand, | |
| 41 | + | brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand: | |
| 42 | + | recordBrokerHelperProcess(os.Args[1]) | |
| 43 | + | if os.Args[1] == brokerLauncherCommand { | |
| 44 | + | if record := os.Getenv(brokerLauncherBlockRecordEnv); record != "" { | |
| 45 | + | blockBrokerLauncherHelper(record, os.Getenv(brokerLauncherBlockReleaseEnv)) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if err := Run(os.Args[1:]); err != nil { | |
| 49 | + | fmt.Fprintln(os.Stderr, err) | |
| 50 | + | os.Exit(ExitCode(err)) | |
| 51 | + | } | |
| 52 | + | os.Exit(0) | |
| 53 | + | } | |
| 54 | + | } | |
| 34 | 55 | if len(os.Args) == 2 { | |
| 35 | 56 | switch os.Args[1] { | |
| 36 | 57 | case "--daemon-launcher": | |
| @@ -193,6 +214,27 @@ func writeProcessRecord(path string) error { | |||
| 193 | 214 | return os.Rename(tmp, path) | |
| 194 | 215 | } | |
| 195 | 216 | ||
| 217 | + | // blockBrokerLauncherHelper is a controlled, blocking launcher: it records its | |
| 218 | + | // PID and waits for the release marker (or a bounded fallback) so a test can | |
| 219 | + | // hold a launcher open and prove the spawn wait is bounded by its context. | |
| 220 | + | func blockBrokerLauncherHelper(record, release string) { | |
| 221 | + | if err := writeProcessRecord(record); err != nil { | |
| 222 | + | os.Exit(2) | |
| 223 | + | } | |
| 224 | + | deadline := time.Now().Add(30 * time.Second) | |
| 225 | + | for time.Now().Before(deadline) { | |
| 226 | + | _, statErr := os.Stat(release) | |
| 227 | + | if statErr == nil { | |
| 228 | + | break | |
| 229 | + | } | |
| 230 | + | if !errors.Is(statErr, os.ErrNotExist) { | |
| 231 | + | os.Exit(2) | |
| 232 | + | } | |
| 233 | + | time.Sleep(10 * time.Millisecond) | |
| 234 | + | } | |
| 235 | + | os.Exit(0) | |
| 236 | + | } | |
| 237 | + | ||
| 196 | 238 | func terminateProcessFromFile(path string) error { | |
| 197 | 239 | data, err := os.ReadFile(path) | |
| 198 | 240 | if errors.Is(err, os.ErrNotExist) { | |
M internal/ports/broker.go +18 -0
| @@ -680,6 +680,24 @@ type BrokerEndpointConnector interface { | |||
| 680 | 680 | Connect(ctx context.Context, endpoint BrokerResolvedEndpoint) (BrokerPhysicalConnection, error) | |
| 681 | 681 | } | |
| 682 | 682 | ||
| 683 | + | // BrokerAuthority admits one accepted client connection to the broker core | |
| 684 | + | // and returns the BrokerService scoped to exactly that connection. It is the | |
| 685 | + | // admission seam between the broker listener adapter and the broker use case: | |
| 686 | + | // the listener owns the carriage and calls AdmitClient once per accepted | |
| 687 | + | // connection, the implementation assigns the connection identity, and the | |
| 688 | + | // returned service owns that connection until Close. The adapter never | |
| 689 | + | // constructs a broker itself. | |
| 690 | + | // | |
| 691 | + | // ctx bounds admission only. It carries the listener's handshake deadline and | |
| 692 | + | // is canceled as soon as admission returns or when the listener closes, so an | |
| 693 | + | // implementation must not retain it or start work that outlives admission | |
| 694 | + | // from it; honoring that cancellation is what lets a listener drop a client | |
| 695 | + | // parked in admission immediately. The admitted service gets its own | |
| 696 | + | // connection-lived context and must not derive from this one. | |
| 697 | + | type BrokerAuthority interface { | |
| 698 | + | AdmitClient(ctx context.Context) (BrokerService, error) | |
| 699 | + | } | |
| 700 | + | ||
| 683 | 701 | // BrokerListener accepts client connections to the per-user broker | |
| 684 | 702 | // endpoint. Each Accept returns a BrokerService bound to exactly one | |
| 685 | 703 | // accepted client connection: the implementation assigns that | |
M internal/ports/broker_test.go +1 -0
| @@ -535,6 +535,7 @@ func TestBrokerInterfacesArePorts(t *testing.T) { | |||
| 535 | 535 | "BrokerHostProbe": reflect.TypeOf((*BrokerHostProbe)(nil)).Elem(), | |
| 536 | 536 | "BrokerPhysicalConnection": reflect.TypeOf((*BrokerPhysicalConnection)(nil)).Elem(), | |
| 537 | 537 | "BrokerEndpointConnector": reflect.TypeOf((*BrokerEndpointConnector)(nil)).Elem(), | |
| 538 | + | "BrokerAuthority": reflect.TypeOf((*BrokerAuthority)(nil)).Elem(), | |
| 538 | 539 | "BrokerListener": reflect.TypeOf((*BrokerListener)(nil)).Elem(), | |
| 539 | 540 | "BrokerSubscription": reflect.TypeOf((*BrokerSubscription)(nil)).Elem(), | |
| 540 | 541 | } { | |
M internal/ports/mocks/broker_mock.go +89 -0
| @@ -1348,6 +1348,95 @@ func (_c *MockBrokerEndpointConnector_Connect_Call) RunAndReturn(run func(ctx co | |||
| 1348 | 1348 | return _c | |
| 1349 | 1349 | } | |
| 1350 | 1350 | ||
| 1351 | + | // NewMockBrokerAuthority creates a new instance of MockBrokerAuthority. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1352 | + | // The first argument is typically a *testing.T value. | |
| 1353 | + | func NewMockBrokerAuthority(t interface { | |
| 1354 | + | mock.TestingT | |
| 1355 | + | Cleanup(func()) | |
| 1356 | + | }) *MockBrokerAuthority { | |
| 1357 | + | mock := &MockBrokerAuthority{} | |
| 1358 | + | mock.Mock.Test(t) | |
| 1359 | + | ||
| 1360 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1361 | + | ||
| 1362 | + | return mock | |
| 1363 | + | } | |
| 1364 | + | ||
| 1365 | + | // MockBrokerAuthority is an autogenerated mock type for the BrokerAuthority type | |
| 1366 | + | type MockBrokerAuthority struct { | |
| 1367 | + | mock.Mock | |
| 1368 | + | } | |
| 1369 | + | ||
| 1370 | + | type MockBrokerAuthority_Expecter struct { | |
| 1371 | + | mock *mock.Mock | |
| 1372 | + | } | |
| 1373 | + | ||
| 1374 | + | func (_m *MockBrokerAuthority) EXPECT() *MockBrokerAuthority_Expecter { | |
| 1375 | + | return &MockBrokerAuthority_Expecter{mock: &_m.Mock} | |
| 1376 | + | } | |
| 1377 | + | ||
| 1378 | + | // AdmitClient provides a mock function for the type MockBrokerAuthority | |
| 1379 | + | func (_mock *MockBrokerAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |
| 1380 | + | ret := _mock.Called(ctx) | |
| 1381 | + | ||
| 1382 | + | if len(ret) == 0 { | |
| 1383 | + | panic("no return value specified for AdmitClient") | |
| 1384 | + | } | |
| 1385 | + | ||
| 1386 | + | var r0 ports.BrokerService | |
| 1387 | + | var r1 error | |
| 1388 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BrokerService, error)); ok { | |
| 1389 | + | return returnFunc(ctx) | |
| 1390 | + | } | |
| 1391 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BrokerService); ok { | |
| 1392 | + | r0 = returnFunc(ctx) | |
| 1393 | + | } else { | |
| 1394 | + | if ret.Get(0) != nil { | |
| 1395 | + | r0 = ret.Get(0).(ports.BrokerService) | |
| 1396 | + | } | |
| 1397 | + | } | |
| 1398 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1399 | + | r1 = returnFunc(ctx) | |
| 1400 | + | } else { | |
| 1401 | + | r1 = ret.Error(1) | |
| 1402 | + | } | |
| 1403 | + | return r0, r1 | |
| 1404 | + | } | |
| 1405 | + | ||
| 1406 | + | // MockBrokerAuthority_AdmitClient_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AdmitClient' | |
| 1407 | + | type MockBrokerAuthority_AdmitClient_Call struct { | |
| 1408 | + | *mock.Call | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | // AdmitClient is a helper method to define mock.On call | |
| 1412 | + | // - ctx context.Context | |
| 1413 | + | func (_e *MockBrokerAuthority_Expecter) AdmitClient(ctx any) *MockBrokerAuthority_AdmitClient_Call { | |
| 1414 | + | return &MockBrokerAuthority_AdmitClient_Call{Call: _e.mock.On("AdmitClient", ctx)} | |
| 1415 | + | } | |
| 1416 | + | ||
| 1417 | + | func (_c *MockBrokerAuthority_AdmitClient_Call) Run(run func(ctx context.Context)) *MockBrokerAuthority_AdmitClient_Call { | |
| 1418 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1419 | + | var arg0 context.Context | |
| 1420 | + | if args[0] != nil { | |
| 1421 | + | arg0 = args[0].(context.Context) | |
| 1422 | + | } | |
| 1423 | + | run( | |
| 1424 | + | arg0, | |
| 1425 | + | ) | |
| 1426 | + | }) | |
| 1427 | + | return _c | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | func (_c *MockBrokerAuthority_AdmitClient_Call) Return(brokerService ports.BrokerService, err error) *MockBrokerAuthority_AdmitClient_Call { | |
| 1431 | + | _c.Call.Return(brokerService, err) | |
| 1432 | + | return _c | |
| 1433 | + | } | |
| 1434 | + | ||
| 1435 | + | func (_c *MockBrokerAuthority_AdmitClient_Call) RunAndReturn(run func(ctx context.Context) (ports.BrokerService, error)) *MockBrokerAuthority_AdmitClient_Call { | |
| 1436 | + | _c.Call.Return(run) | |
| 1437 | + | return _c | |
| 1438 | + | } | |
| 1439 | + | ||
| 1351 | 1440 | // NewMockBrokerListener creates a new instance of MockBrokerListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1352 | 1441 | // The first argument is typically a *testing.T value. | |
| 1353 | 1442 | func NewMockBrokerListener(t interface { | |
M internal/usecase/broker/registry.go +42 -2
| @@ -37,6 +37,17 @@ const ( | |||
| 37 | 37 | runStopped | |
| 38 | 38 | ) | |
| 39 | 39 | ||
| 40 | + | // RegistryConfig selects how a Registry runs. The zero value observes hosts. | |
| 41 | + | type RegistryConfig struct { | |
| 42 | + | // ObservationDisabled runs the registry as a read-only snapshot owner: it | |
| 43 | + | // restores and publishes durable state, owns and drains its writer, and | |
| 44 | + | // honors the Run lifecycle, but issues no probes, arms no timers, and never | |
| 45 | + | // reconciles. The probe dependency is unused and may be nil. Membership is | |
| 46 | + | // expected to be immutable for the run; a caller that needs observation | |
| 47 | + | // keeps the zero-value config. | |
| 48 | + | ObservationDisabled bool | |
| 49 | + | } | |
| 50 | + | ||
| 40 | 51 | // Registry owns configured hosts and their immutable observation projection. | |
| 41 | 52 | type Registry struct { | |
| 42 | 53 | epoch ports.BrokerEpoch | |
| @@ -44,6 +55,9 @@ type Registry struct { | |||
| 44 | 55 | clock ports.Clock | |
| 45 | 56 | log *slog.Logger | |
| 46 | 57 | store *snapshotWriter | |
| 58 | + | // observationDisabled is fixed at construction: a disabled registry never | |
| 59 | + | // observes, so it has no probe, timer, or reconcile work to do. | |
| 60 | + | observationDisabled bool | |
| 47 | 61 | ||
| 48 | 62 | // hostStore and authority are the synchronous membership owner. Only this | |
| 49 | 63 | // registry may mutate the store while it is live; the caller owns Close. | |
| @@ -94,7 +108,18 @@ type Registry struct { | |||
| 94 | 108 | // outside Registry.ReplaceHosts. The caller closes the store after the registry | |
| 95 | 109 | // has stopped and flushed its advisory writer. | |
| 96 | 110 | func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger) (*Registry, error) { | |
| 97 | - | if epoch == 0 || nilDependency(store) || nilDependency(probe) || nilDependency(clock) { | |
| 111 | + | return NewRegistryWithConfig(epoch, store, probe, clock, log, RegistryConfig{}) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // NewRegistryWithConfig restores a validated durable snapshot under a fresh | |
| 115 | + | // broker epoch and selects the run mode from cfg. Observation-disabled mode | |
| 116 | + | // tolerates a nil probe because such a registry never probes; every other | |
| 117 | + | // dependency is required exactly as NewRegistry requires it. | |
| 118 | + | func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) { | |
| 119 | + | if epoch == 0 || nilDependency(store) || nilDependency(clock) { | |
| 120 | + | return nil, errors.New("broker: invalid registry dependencies") | |
| 121 | + | } | |
| 122 | + | if !cfg.ObservationDisabled && nilDependency(probe) { | |
| 98 | 123 | return nil, errors.New("broker: invalid registry dependencies") | |
| 99 | 124 | } | |
| 100 | 125 | if log == nil { | |
| @@ -102,7 +127,8 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe por | |||
| 102 | 127 | } | |
| 103 | 128 | r := &Registry{ | |
| 104 | 129 | epoch: epoch, probe: probe, clock: clock, log: log, | |
| 105 | - | hosts: make(map[string]ports.RemoteHostSnapshot), inflight: make(map[string]*probeAttempt), | |
| 130 | + | observationDisabled: cfg.ObservationDisabled, | |
| 131 | + | hosts: make(map[string]ports.RemoteHostSnapshot), inflight: make(map[string]*probeAttempt), | |
| 106 | 132 | pending: make(map[string]bool), tombstones: make(map[string]ports.BrokerHostTombstone), | |
| 107 | 133 | subs: make(map[*subscription]struct{}), wake: make(chan struct{}, 1), | |
| 108 | 134 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | |
| @@ -350,7 +376,12 @@ func (r *Registry) cancelAllInflightLocked() { | |||
| 350 | 376 | } | |
| 351 | 377 | ||
| 352 | 378 | // RequestProbe is non-blocking and coalesces concurrent demand per endpoint. | |
| 379 | + | // A disabled registry never observes, so the demand is dropped without | |
| 380 | + | // scheduling anything. | |
| 353 | 381 | func (r *Registry) RequestProbe(endpoint string) { | |
| 382 | + | if r.observationDisabled { | |
| 383 | + | return | |
| 384 | + | } | |
| 354 | 385 | r.mu.Lock() | |
| 355 | 386 | if _, ok := r.hosts[endpoint]; ok { | |
| 356 | 387 | r.pending[endpoint] = true | |
| @@ -371,12 +402,21 @@ func (r *Registry) hint() { | |||
| 371 | 402 | // concurrent or restarted caller can never duplicate scheduling. On return it | |
| 372 | 403 | // clears in-flight observation state, publishes the settled projection, and | |
| 373 | 404 | // flushes durable persistence before any further write is refused. | |
| 405 | + | // | |
| 406 | + | // An observation-disabled registry runs the same lifecycle and drain but never | |
| 407 | + | // schedules observation: it arms no timer, starts no probe, and drops every | |
| 408 | + | // reconcile. It still owns and drains its durable writer, so a shutdown never | |
| 409 | + | // returns before the newest staged publication has reached the store. | |
| 374 | 410 | func (r *Registry) Run(ctx context.Context) { | |
| 375 | 411 | if !r.running.CompareAndSwap(runIdle, runActive) { | |
| 376 | 412 | r.log.Warn("broker: registry Run refused; single-run already started or finished") | |
| 377 | 413 | return | |
| 378 | 414 | } | |
| 379 | 415 | defer r.settle() | |
| 416 | + | if r.observationDisabled { | |
| 417 | + | <-ctx.Done() | |
| 418 | + | return | |
| 419 | + | } | |
| 380 | 420 | timer := r.clock.NewTimer(0) | |
| 381 | 421 | defer timer.Stop() | |
| 382 | 422 | results := make(chan probeResult, ports.BrokerMaxHosts) | |
A internal/usecase/broker/service.go +301 -0
| @@ -0,0 +1,301 @@ | |||
| 1 | + | package broker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Offline broker composition (Plan 001 P3.4 slice B). | |
| 12 | + | // | |
| 13 | + | // Authority admits one accepted client connection to the broker core and | |
| 14 | + | // returns the connection-scoped ports.BrokerService for exactly that | |
| 15 | + | // connection. It is the structural implementation of ports.BrokerAuthority, | |
| 16 | + | // the admission seam the broker IPC listener (internal/adapters/brokeripc) | |
| 17 | + | // consumes and which this use case must not import: admission obtains one | |
| 18 | + | // supervisor client lease and one pool connection | |
| 19 | + | // identity, and rolls both back on any failure so a refused admission never | |
| 20 | + | // pins the broker open or leaks a client slot. The admission context bounds | |
| 21 | + | // admission only and is never retained; the admitted service owns a | |
| 22 | + | // connection-lived context derived from the supervisor root. | |
| 23 | + | // | |
| 24 | + | // This composition is deliberately offline and immutable: its registry runs | |
| 25 | + | // observation-disabled, so the returned service publishes and subscribes to | |
| 26 | + | // durable snapshots but never probes or reconciles, and host membership is | |
| 27 | + | // refused rather than mutated. Live membership mutation is composed in a later | |
| 28 | + | // slice. | |
| 29 | + | ||
| 30 | + | // ErrOfflineMembership is the typed refusal returned by AddHost and RemoveHost | |
| 31 | + | // on an offline broker connection: membership is immutable for the | |
| 32 | + | // connection's lifetime and the refusal never touches durable authority. | |
| 33 | + | // Callers classify it with errors.Is instead of matching message text. | |
| 34 | + | var ErrOfflineMembership = errors.New("broker: offline membership is immutable") | |
| 35 | + | ||
| 36 | + | // offlineMembershipRefusal is the typed, presentation-safe refusal. It is a | |
| 37 | + | // ports.BrokerError so the wire layer classifies it without message matching, | |
| 38 | + | // and it carries ErrOfflineMembership for errors.Is. | |
| 39 | + | func offlineMembershipRefusal() error { | |
| 40 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: ErrOfflineMembership.Error(), Cause: ErrOfflineMembership} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Authority admits one accepted client connection to the broker core. It owns | |
| 44 | + | // no lifetime of its own: the supervisor owns the broker lifetime, the registry | |
| 45 | + | // owns snapshots, and the pool owns transports. An admitted Service is bound to | |
| 46 | + | // exactly one pool client identity and one broker epoch. | |
| 47 | + | // | |
| 48 | + | // It is the structural implementation of ports.BrokerAuthority, the admission | |
| 49 | + | // seam the broker IPC listener consumes; this use case never imports that | |
| 50 | + | // adapter. | |
| 51 | + | type Authority struct { | |
| 52 | + | epoch ports.BrokerEpoch | |
| 53 | + | registry *Registry | |
| 54 | + | pool *Pool | |
| 55 | + | supervisor *Supervisor | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var _ ports.BrokerAuthority = (*Authority)(nil) | |
| 59 | + | ||
| 60 | + | // NewAuthority composes one connection-scoped broker authority over an existing | |
| 61 | + | // registry, pool, and supervisor. All three must be live and share epoch. The | |
| 62 | + | // registry must be observation-disabled: this authority's sessions expose | |
| 63 | + | // immutable offline membership, so an observing registry would probe hosts the | |
| 64 | + | // service refuses to manage. | |
| 65 | + | // | |
| 66 | + | // The caller owns the dependencies' lifecycles. In particular it must start the | |
| 67 | + | // registry's single Registry.Run to own and drain the durable writer, and must | |
| 68 | + | // cancel that run context and join Run before the store is closed. NewAuthority | |
| 69 | + | // neither starts nor settles Run: serving connections without a live Run leaks | |
| 70 | + | // the writer and never flushes the newest staged publication, and a registry | |
| 71 | + | // that was never run leaves its durable writer undrained. | |
| 72 | + | func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, supervisor *Supervisor) (*Authority, error) { | |
| 73 | + | if epoch == 0 || nilDependency(registry) || nilDependency(pool) || nilDependency(supervisor) { | |
| 74 | + | return nil, errors.New("broker: invalid authority dependencies") | |
| 75 | + | } | |
| 76 | + | if registry.epoch != epoch || pool.epoch != epoch { | |
| 77 | + | return nil, errors.New("broker: authority epoch mismatch") | |
| 78 | + | } | |
| 79 | + | if !registry.observationDisabled { | |
| 80 | + | return nil, errors.New("broker: authority requires an observation-disabled registry") | |
| 81 | + | } | |
| 82 | + | return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // AdmitClient admits exactly one accepted client connection. The supervisor | |
| 86 | + | // client lease and the pool connection identity are acquired together; if | |
| 87 | + | // either fails, the other is released before returning, so admission either | |
| 88 | + | // commits both or neither. ctx bounds admission only and is never retained: the | |
| 89 | + | // returned service derives its own connection-lived context from the supervisor | |
| 90 | + | // root, so a caller's setup deadline cannot disturb an admitted connection. | |
| 91 | + | func (a *Authority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |
| 92 | + | if err := ctx.Err(); err != nil { | |
| 93 | + | return nil, err | |
| 94 | + | } | |
| 95 | + | lease, err := a.supervisor.AdmitClient() | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, err | |
| 98 | + | } | |
| 99 | + | id, err := a.pool.RegisterClient() | |
| 100 | + | if err != nil { | |
| 101 | + | lease.Release() | |
| 102 | + | return nil, err | |
| 103 | + | } | |
| 104 | + | if err := ctx.Err(); err != nil { | |
| 105 | + | a.pool.CloseClient(id) | |
| 106 | + | lease.Release() | |
| 107 | + | return nil, err | |
| 108 | + | } | |
| 109 | + | return newService(a.epoch, id, a.registry, a.pool, a.supervisor, lease), nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Service is one admitted client connection: the core's service for exactly one | |
| 113 | + | // pool connection identity and broker epoch. It implements ports.BrokerService. | |
| 114 | + | type Service struct { | |
| 115 | + | epoch ports.BrokerEpoch | |
| 116 | + | id ports.BrokerConnectionID | |
| 117 | + | registry *Registry | |
| 118 | + | pool *Pool | |
| 119 | + | supervisor *Supervisor | |
| 120 | + | lease *Lease | |
| 121 | + | ||
| 122 | + | // ctx owns everything this connection started: in-flight setup opens and | |
| 123 | + | // live streams. It is derived from the supervisor root, never from the | |
| 124 | + | // admission context, so broker shutdown and Close both abort owned work. | |
| 125 | + | ctx context.Context | |
| 126 | + | cancel context.CancelFunc | |
| 127 | + | ||
| 128 | + | mu sync.Mutex | |
| 129 | + | closed bool | |
| 130 | + | subs map[*serviceSubscription]struct{} | |
| 131 | + | // wg counts in-flight OpenStream calls so Close drains them, and their | |
| 132 | + | // operation leases, before it releases the connection resources. | |
| 133 | + | wg sync.WaitGroup | |
| 134 | + | ||
| 135 | + | closeOnce sync.Once | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | var _ ports.BrokerService = (*Service)(nil) | |
| 139 | + | ||
| 140 | + | func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease) *Service { | |
| 141 | + | ctx, cancel := context.WithCancel(supervisor.RootContext()) | |
| 142 | + | return &Service{ | |
| 143 | + | epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease, | |
| 144 | + | ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}), | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // ConnectionID returns the pool identity assigned at admission. Clients carry | |
| 149 | + | // it on stream operations so a stale request can be fenced. | |
| 150 | + | func (s *Service) ConnectionID() ports.BrokerConnectionID { return s.id } | |
| 151 | + | ||
| 152 | + | // Snapshot delegates to the registry's current immutable publication. | |
| 153 | + | func (s *Service) Snapshot() ports.BrokerSnapshot { return s.registry.Snapshot() } | |
| 154 | + | ||
| 155 | + | // Subscribe delegates to the registry and tracks the returned subscription so | |
| 156 | + | // Close releases every subscription this connection still owns. A closed | |
| 157 | + | // connection refuses new subscriptions. | |
| 158 | + | func (s *Service) Subscribe() (ports.BrokerSubscription, error) { | |
| 159 | + | s.mu.Lock() | |
| 160 | + | if s.closed { | |
| 161 | + | s.mu.Unlock() | |
| 162 | + | return nil, ports.BrokerAdmissionClosed | |
| 163 | + | } | |
| 164 | + | sub := &serviceSubscription{service: s, inner: s.registry.Subscribe()} | |
| 165 | + | s.subs[sub] = struct{}{} | |
| 166 | + | s.mu.Unlock() | |
| 167 | + | return sub, nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // OpenStream opens one logical stream through the pool on behalf of the user. | |
| 171 | + | // The request is fenced to this connection's exact epoch and identity before | |
| 172 | + | // the pool sees it, and one operation lease pins the broker only while setup is | |
| 173 | + | // in flight: a stream that opens successfully outlives its setup lease, and | |
| 174 | + | // Close or broker shutdown still aborts anything that has not finished. | |
| 175 | + | func (s *Service) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 176 | + | if err := s.scope(&request); err != nil { | |
| 177 | + | return nil, err | |
| 178 | + | } | |
| 179 | + | s.mu.Lock() | |
| 180 | + | if s.closed { | |
| 181 | + | s.mu.Unlock() | |
| 182 | + | return nil, ports.BrokerAdmissionClosed | |
| 183 | + | } | |
| 184 | + | s.wg.Add(1) | |
| 185 | + | s.mu.Unlock() | |
| 186 | + | defer s.wg.Done() | |
| 187 | + | ||
| 188 | + | operation, err := s.supervisor.AdmitOperation() | |
| 189 | + | if err != nil { | |
| 190 | + | return nil, err | |
| 191 | + | } | |
| 192 | + | defer operation.Release() | |
| 193 | + | ||
| 194 | + | openCtx, release := s.openContext(ctx) | |
| 195 | + | stream, err := s.pool.OpenStream(openCtx, request) | |
| 196 | + | if err != nil { | |
| 197 | + | release() | |
| 198 | + | return nil, err | |
| 199 | + | } | |
| 200 | + | // Detach the connection-scoped context link once the pool reports the | |
| 201 | + | // stream terminal, so a long-lived connection never accumulates one link | |
| 202 | + | // per opened stream. Nothing here ends the stream: setup completion and the | |
| 203 | + | // operation lease's release both happen while it keeps running. | |
| 204 | + | go func() { | |
| 205 | + | <-stream.Done() | |
| 206 | + | release() | |
| 207 | + | }() | |
| 208 | + | return stream, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // CloseStream retires one stream of this connection. A foreign connection | |
| 212 | + | // identity is refused as stale and never retires another connection's stream. | |
| 213 | + | func (s *Service) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error { | |
| 214 | + | if !connection.IsZero() && connection != s.id { | |
| 215 | + | return ports.BrokerAdmissionStale | |
| 216 | + | } | |
| 217 | + | return s.pool.CloseStream(s.id, stream) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // AddHost refuses host admission: this connection has immutable offline | |
| 221 | + | // membership, so no mutation is attempted. | |
| 222 | + | func (s *Service) AddHost(context.Context, string) error { return offlineMembershipRefusal() } | |
| 223 | + | ||
| 224 | + | // RemoveHost refuses host removal without mutating authority. The result is the | |
| 225 | + | // explicit false/refusal pair. | |
| 226 | + | func (s *Service) RemoveHost(context.Context, string) (bool, error) { | |
| 227 | + | return false, offlineMembershipRefusal() | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // RequestReconcile is deliberately a no-op: an offline connection has no | |
| 231 | + | // observation to reconcile, so the hint starts no probe, timer, or reconcile. | |
| 232 | + | func (s *Service) RequestReconcile(string) {} | |
| 233 | + | ||
| 234 | + | // Close cancels and drains every owned resource exactly once: in-flight setup | |
| 235 | + | // opens and live streams stop, subscriptions close, the pool client is retired, | |
| 236 | + | // and the supervisor client lease is released. It is idempotent and | |
| 237 | + | // concurrent-safe; every caller observes the same terminal state. | |
| 238 | + | func (s *Service) Close() error { | |
| 239 | + | s.closeOnce.Do(func() { | |
| 240 | + | s.mu.Lock() | |
| 241 | + | s.closed = true | |
| 242 | + | subs := make([]*serviceSubscription, 0, len(s.subs)) | |
| 243 | + | for sub := range s.subs { | |
| 244 | + | subs = append(subs, sub) | |
| 245 | + | } | |
| 246 | + | s.mu.Unlock() | |
| 247 | + | ||
| 248 | + | s.cancel() | |
| 249 | + | s.wg.Wait() | |
| 250 | + | for _, sub := range subs { | |
| 251 | + | sub.Close() | |
| 252 | + | } | |
| 253 | + | s.pool.CloseClient(s.id) | |
| 254 | + | s.lease.Release() | |
| 255 | + | }) | |
| 256 | + | return nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // scope fences one request to this connection's exact epoch and identity, | |
| 260 | + | // filling an absent identity with this connection's own. A foreign epoch or | |
| 261 | + | // connection is refused before it can steer another connection's work. | |
| 262 | + | func (s *Service) scope(request *ports.BrokerOpenStreamRequest) error { | |
| 263 | + | if request.Epoch != 0 && request.Epoch != s.epoch { | |
| 264 | + | return ports.BrokerError{Code: ports.BrokerErrorStaleEpoch} | |
| 265 | + | } | |
| 266 | + | if !request.Connection.IsZero() && request.Connection != s.id { | |
| 267 | + | return ports.BrokerAdmissionStale | |
| 268 | + | } | |
| 269 | + | request.Epoch = s.epoch | |
| 270 | + | request.Connection = s.id | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // openContext derives the context that owns one stream: the caller's context | |
| 275 | + | // bounds the request, and the connection context bounds the service, so the | |
| 276 | + | // stream and its setup end when either does. The returned release detaches the | |
| 277 | + | // connection link once the stream has settled. | |
| 278 | + | func (s *Service) openContext(ctx context.Context) (context.Context, func()) { | |
| 279 | + | openCtx, cancel := context.WithCancel(ctx) | |
| 280 | + | stop := context.AfterFunc(s.ctx, cancel) | |
| 281 | + | return openCtx, func() { stop(); cancel() } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // serviceSubscription is one connection-owned subscription. Closing it releases | |
| 285 | + | // the registry subscription and forgets it on the owning service. | |
| 286 | + | type serviceSubscription struct { | |
| 287 | + | service *Service | |
| 288 | + | inner ports.BrokerSubscription | |
| 289 | + | once sync.Once | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (s *serviceSubscription) Changed() <-chan struct{} { return s.inner.Changed() } | |
| 293 | + | ||
| 294 | + | func (s *serviceSubscription) Close() { | |
| 295 | + | s.once.Do(func() { | |
| 296 | + | s.inner.Close() | |
| 297 | + | s.service.mu.Lock() | |
| 298 | + | delete(s.service.subs, s) | |
| 299 | + | s.service.mu.Unlock() | |
| 300 | + | }) | |
| 301 | + | } | |
A internal/usecase/broker/service_test.go +508 -0
| @@ -0,0 +1,508 @@ | |||
| 1 | + | package broker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // newTestAuthority composes one offline authority over an observation-disabled | |
| 16 | + | // registry, a bounded pool, and a supervisor on a long grace. Every dependency | |
| 17 | + | // is drained at test end so no run goroutine leaks between tests: the registry | |
| 18 | + | // run is canceled and joined (which flushes and stops its durable writer), the | |
| 19 | + | // pool is closed, and the supervisor is closed. | |
| 20 | + | func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) { | |
| 21 | + | t.Helper() | |
| 22 | + | clock := newManualClock(time.Unix(0, 0)) | |
| 23 | + | store := newTestStore() | |
| 24 | + | registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 27 | + | return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil | |
| 28 | + | }) | |
| 29 | + | pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute}) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | t.Cleanup(func() { require.NoError(t, pool.Close()) }) | |
| 32 | + | supervisor, _ := newTestSupervisor(t, time.Hour) | |
| 33 | + | authority, err := NewAuthority(epoch, registry, pool, supervisor) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | // The caller owns Registry.Run (see NewAuthority): start the single run and | |
| 36 | + | // cancel it at cleanup, so the registry's durable writer is drained and its | |
| 37 | + | // goroutine never survives the test. | |
| 38 | + | registryCtx, stopRegistry := context.WithCancel(context.Background()) | |
| 39 | + | registryDone := make(chan struct{}) | |
| 40 | + | go func() { defer close(registryDone); registry.Run(registryCtx) }() | |
| 41 | + | t.Cleanup(func() { stopRegistry(); <-registryDone }) | |
| 42 | + | return authority, registry, pool, supervisor, clock, store | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // immediateConnector returns one healthy physical connection for any endpoint. | |
| 46 | + | func immediateConnector(_ context.Context, e ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 47 | + | return &fakePhysical{endpoint: e, done: make(chan struct{})}, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func poolClientCount(p *Pool) int { | |
| 51 | + | p.mu.Lock() | |
| 52 | + | defer p.mu.Unlock() | |
| 53 | + | return len(p.clients) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func registrySubscriptionCount(r *Registry) int { | |
| 57 | + | r.mu.Lock() | |
| 58 | + | defer r.mu.Unlock() | |
| 59 | + | return len(r.subs) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestAuthorityAdmissionRollback(t *testing.T) { | |
| 63 | + | authority, _, pool, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 64 | + | ||
| 65 | + | first, err := authority.AdmitClient(context.Background()) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | var epochFromID ports.BrokerEpoch | |
| 68 | + | for i := 0; i < 8; i++ { | |
| 69 | + | epochFromID = epochFromID<<8 | ports.BrokerEpoch(first.ConnectionID()[i]) | |
| 70 | + | } | |
| 71 | + | require.Equal(t, ports.BrokerEpoch(1), epochFromID, "the pool identity must carry the broker epoch") | |
| 72 | + | ||
| 73 | + | // Exhaust the pool's connection bound: the second admission must release the | |
| 74 | + | // supervisor client lease it already took. | |
| 75 | + | pool.limits.Clients = 1 | |
| 76 | + | _, err = authority.AdmitClient(context.Background()) | |
| 77 | + | require.ErrorIs(t, err, ports.BrokerAdmissionLimit) | |
| 78 | + | clients, _, _, _ := supervisorState(supervisor) | |
| 79 | + | require.Equal(t, 1, clients, "a refused admission must release its client lease") | |
| 80 | + | require.Equal(t, 1, poolClientCount(pool), "a refused admission must not leave a pool client") | |
| 81 | + | ||
| 82 | + | // A cancelled admission context takes no lease and registers no client. | |
| 83 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 84 | + | cancel() | |
| 85 | + | _, err = authority.AdmitClient(ctx) | |
| 86 | + | require.ErrorIs(t, err, context.Canceled) | |
| 87 | + | clients, _, _, _ = supervisorState(supervisor) | |
| 88 | + | require.Equal(t, 1, clients) | |
| 89 | + | ||
| 90 | + | require.NoError(t, first.Close()) | |
| 91 | + | clients, _, _, _ = supervisorState(supervisor) | |
| 92 | + | require.Zero(t, clients) | |
| 93 | + | require.Zero(t, poolClientCount(pool)) | |
| 94 | + | ||
| 95 | + | // A committed supervisor refuses every later admission without taking work. | |
| 96 | + | require.NoError(t, supervisor.Close()) | |
| 97 | + | _, err = authority.AdmitClient(context.Background()) | |
| 98 | + | requireBrokerClosed(t, err) | |
| 99 | + | clients, _, _, _ = supervisorState(supervisor) | |
| 100 | + | require.Zero(t, clients) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestAuthorityAdmissionContextNotRetained(t *testing.T) { | |
| 104 | + | authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 105 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 106 | + | service, err := authority.AdmitClient(ctx) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 109 | + | ||
| 110 | + | // The admission context is discarded: the connection keeps serving after the | |
| 111 | + | // caller's setup deadline expires. | |
| 112 | + | cancel() | |
| 113 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)})) | |
| 114 | + | require.Len(t, service.Snapshot().Hosts, 1) | |
| 115 | + | ||
| 116 | + | stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1)) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.NoError(t, stream.Close()) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestAuthorityValidation(t *testing.T) { | |
| 122 | + | _, _, pool, supervisor, clock, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 123 | + | ||
| 124 | + | observing, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | _, err = NewAuthority(1, observing, pool, supervisor) | |
| 127 | + | require.Error(t, err, "an observing registry must be refused by an offline authority") | |
| 128 | + | ||
| 129 | + | disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | _, err = NewAuthority(2, disabled, pool, supervisor) | |
| 132 | + | require.Error(t, err, "an epoch mismatch must be refused") | |
| 133 | + | ||
| 134 | + | _, err = NewAuthority(1, nil, pool, supervisor) | |
| 135 | + | require.Error(t, err, "a missing registry must be refused") | |
| 136 | + | _, err = NewAuthority(1, disabled, nil, supervisor) | |
| 137 | + | require.Error(t, err, "a missing pool must be refused") | |
| 138 | + | _, err = NewAuthority(1, disabled, pool, nil) | |
| 139 | + | require.Error(t, err, "a missing supervisor must be refused") | |
| 140 | + | ||
| 141 | + | _, err = NewAuthority(1, disabled, pool, supervisor) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestServiceOpenStreamScopeFencing(t *testing.T) { | |
| 146 | + | foreign := ports.BrokerConnectionID{0xff} | |
| 147 | + | tests := []struct { | |
| 148 | + | name string | |
| 149 | + | build func(id ports.BrokerConnectionID) ports.BrokerOpenStreamRequest | |
| 150 | + | wantStale bool | |
| 151 | + | wantEpoch bool | |
| 152 | + | }{ | |
| 153 | + | { | |
| 154 | + | name: "a foreign connection is fenced as stale", | |
| 155 | + | build: func(ports.BrokerConnectionID) ports.BrokerOpenStreamRequest { | |
| 156 | + | return poolRequest(foreign, 2) | |
| 157 | + | }, | |
| 158 | + | wantStale: true, | |
| 159 | + | }, | |
| 160 | + | { | |
| 161 | + | name: "a foreign epoch is fenced as a stale epoch", | |
| 162 | + | build: func(id ports.BrokerConnectionID) ports.BrokerOpenStreamRequest { | |
| 163 | + | request := poolRequest(id, 3) | |
| 164 | + | request.Epoch = 2 | |
| 165 | + | return request | |
| 166 | + | }, | |
| 167 | + | wantEpoch: true, | |
| 168 | + | }, | |
| 169 | + | { | |
| 170 | + | name: "an absent identity is filled from the connection", | |
| 171 | + | build: func(ports.BrokerConnectionID) ports.BrokerOpenStreamRequest { | |
| 172 | + | request := poolRequest(ports.BrokerConnectionID{}, 4) | |
| 173 | + | request.Epoch = 0 | |
| 174 | + | return request | |
| 175 | + | }, | |
| 176 | + | }, | |
| 177 | + | } | |
| 178 | + | for _, tc := range tests { | |
| 179 | + | t.Run(tc.name, func(t *testing.T) { | |
| 180 | + | authority, _, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 181 | + | service, err := authority.AdmitClient(context.Background()) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 184 | + | ||
| 185 | + | stream, err := service.OpenStream(context.Background(), tc.build(service.ConnectionID())) | |
| 186 | + | switch { | |
| 187 | + | case tc.wantStale: | |
| 188 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |
| 189 | + | case tc.wantEpoch: | |
| 190 | + | var typed ports.BrokerError | |
| 191 | + | require.ErrorAs(t, err, &typed) | |
| 192 | + | require.Equal(t, ports.BrokerErrorStaleEpoch, typed.Code) | |
| 193 | + | default: | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | require.NoError(t, stream.Close()) | |
| 196 | + | } | |
| 197 | + | }) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestServiceCloseStreamScopeFencing(t *testing.T) { | |
| 202 | + | authority, _, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 203 | + | service, err := authority.AdmitClient(context.Background()) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 206 | + | id := service.ConnectionID() | |
| 207 | + | ||
| 208 | + | stream, err := service.OpenStream(context.Background(), poolRequest(id, 1)) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | ||
| 211 | + | // A wrong-scope close must not retire this connection's stream. | |
| 212 | + | require.ErrorIs(t, service.CloseStream(ports.BrokerConnectionID{0xff}, 1), ports.BrokerAdmissionStale) | |
| 213 | + | select { | |
| 214 | + | case <-stream.Done(): | |
| 215 | + | t.Fatal("a wrong-scope close must not retire the stream") | |
| 216 | + | default: | |
| 217 | + | } | |
| 218 | + | require.NoError(t, service.CloseStream(id, 1)) | |
| 219 | + | await(t, stream.Done()) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestServiceSubscriptionCleanup(t *testing.T) { | |
| 223 | + | authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 224 | + | service, err := authority.AdmitClient(context.Background()) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 227 | + | ||
| 228 | + | sub, err := service.Subscribe() | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | require.Equal(t, 1, registrySubscriptionCount(registry)) | |
| 231 | + | sub.Close() | |
| 232 | + | require.Zero(t, registrySubscriptionCount(registry), "an explicit close must release the subscription") | |
| 233 | + | ||
| 234 | + | sub, err = service.Subscribe() | |
| 235 | + | require.NoError(t, err) | |
| 236 | + | require.Equal(t, 1, registrySubscriptionCount(registry)) | |
| 237 | + | ||
| 238 | + | require.NoError(t, service.Close()) | |
| 239 | + | require.Zero(t, registrySubscriptionCount(registry), "Close must release tracked subscriptions") | |
| 240 | + | _, err = service.Subscribe() | |
| 241 | + | requireBrokerClosed(t, err) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func TestServiceSnapshotDelegates(t *testing.T) { | |
| 245 | + | authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 246 | + | service, err := authority.AdmitClient(context.Background()) | |
| 247 | + | require.NoError(t, err) | |
| 248 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 249 | + | ||
| 250 | + | sub, err := service.Subscribe() | |
| 251 | + | require.NoError(t, err) | |
| 252 | + | defer sub.Close() | |
| 253 | + | ||
| 254 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)})) | |
| 255 | + | select { | |
| 256 | + | case <-sub.Changed(): | |
| 257 | + | case <-time.After(time.Second): | |
| 258 | + | t.Fatal("the connection subscription must observe a registry publication") | |
| 259 | + | } | |
| 260 | + | require.Equal(t, registry.Snapshot(), service.Snapshot()) | |
| 261 | + | require.Len(t, service.Snapshot().Hosts, 1) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestServiceOperationLeaseLifetime(t *testing.T) { | |
| 265 | + | entered := make(chan struct{}) | |
| 266 | + | proceed := make(chan struct{}) | |
| 267 | + | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, func(ctx context.Context, e ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 268 | + | close(entered) | |
| 269 | + | select { | |
| 270 | + | case <-ctx.Done(): | |
| 271 | + | return nil, ctx.Err() | |
| 272 | + | case <-proceed: | |
| 273 | + | return &fakePhysical{endpoint: e, done: make(chan struct{})}, nil | |
| 274 | + | } | |
| 275 | + | }) | |
| 276 | + | service, err := authority.AdmitClient(context.Background()) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 279 | + | ||
| 280 | + | type opened struct { | |
| 281 | + | stream ports.BrokerLogicalConnection | |
| 282 | + | err error | |
| 283 | + | } | |
| 284 | + | result := make(chan opened, 1) | |
| 285 | + | go func() { | |
| 286 | + | stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1)) | |
| 287 | + | result <- opened{stream: stream, err: err} | |
| 288 | + | }() | |
| 289 | + | <-entered | |
| 290 | + | _, operations, _, _ := supervisorState(supervisor) | |
| 291 | + | require.Equal(t, 1, operations, "open setup must hold one operation lease") | |
| 292 | + | ||
| 293 | + | close(proceed) | |
| 294 | + | got := <-result | |
| 295 | + | require.NoError(t, got.err) | |
| 296 | + | require.NotNil(t, got.stream) | |
| 297 | + | _, operations, _, _ = supervisorState(supervisor) | |
| 298 | + | require.Zero(t, operations, "the operation lease must be released once setup completes") | |
| 299 | + | select { | |
| 300 | + | case <-got.stream.Done(): | |
| 301 | + | t.Fatal("a stream that opened successfully must outlive its setup lease") | |
| 302 | + | default: | |
| 303 | + | } | |
| 304 | + | require.NoError(t, got.stream.Close()) | |
| 305 | + | await(t, got.stream.Done()) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestServiceCloseOpenRace(t *testing.T) { | |
| 309 | + | for round := 0; round < 25; round++ { | |
| 310 | + | t.Run(fmt.Sprintf("round-%d", round), func(t *testing.T) { | |
| 311 | + | entered := make(chan struct{}) | |
| 312 | + | proceed := make(chan struct{}) | |
| 313 | + | authority, _, pool, supervisor, _, _ := newTestAuthority(t, 1, nil, func(ctx context.Context, e ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 314 | + | close(entered) | |
| 315 | + | select { | |
| 316 | + | case <-ctx.Done(): | |
| 317 | + | return nil, ctx.Err() | |
| 318 | + | case <-proceed: | |
| 319 | + | return &fakePhysical{endpoint: e, done: make(chan struct{})}, nil | |
| 320 | + | } | |
| 321 | + | }) | |
| 322 | + | service, err := authority.AdmitClient(context.Background()) | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | ||
| 325 | + | result := make(chan error, 1) | |
| 326 | + | go func() { | |
| 327 | + | _, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1)) | |
| 328 | + | result <- err | |
| 329 | + | }() | |
| 330 | + | <-entered | |
| 331 | + | ||
| 332 | + | // Close races the parked setup: whichever wins, both must settle | |
| 333 | + | // without deadlock and leave no lease or client behind. | |
| 334 | + | closed := make(chan error, 1) | |
| 335 | + | go func() { closed <- service.Close() }() | |
| 336 | + | close(proceed) | |
| 337 | + | require.NoError(t, <-closed) | |
| 338 | + | <-result | |
| 339 | + | ||
| 340 | + | clients, operations, _, _ := supervisorState(supervisor) | |
| 341 | + | require.Zero(t, clients) | |
| 342 | + | require.Zero(t, operations) | |
| 343 | + | require.Zero(t, poolClientCount(pool)) | |
| 344 | + | }) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestServiceCloseIsIdempotentAndConcurrent(t *testing.T) { | |
| 349 | + | authority, registry, pool, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 350 | + | service, err := authority.AdmitClient(context.Background()) | |
| 351 | + | require.NoError(t, err) | |
| 352 | + | _, err = service.Subscribe() | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1)) | |
| 355 | + | require.NoError(t, err) | |
| 356 | + | ||
| 357 | + | const closers = 8 | |
| 358 | + | errs := make(chan error, closers) | |
| 359 | + | var wg sync.WaitGroup | |
| 360 | + | for i := 0; i < closers; i++ { | |
| 361 | + | wg.Add(1) | |
| 362 | + | go func() { | |
| 363 | + | defer wg.Done() | |
| 364 | + | errs <- service.Close() | |
| 365 | + | }() | |
| 366 | + | } | |
| 367 | + | wg.Wait() | |
| 368 | + | for i := 0; i < closers; i++ { | |
| 369 | + | require.NoError(t, <-errs) | |
| 370 | + | } | |
| 371 | + | require.NoError(t, service.Close()) | |
| 372 | + | ||
| 373 | + | require.Zero(t, registrySubscriptionCount(registry)) | |
| 374 | + | require.Zero(t, poolClientCount(pool)) | |
| 375 | + | clients, _, _, _ := supervisorState(supervisor) | |
| 376 | + | require.Zero(t, clients) | |
| 377 | + | await(t, stream.Done()) | |
| 378 | + | ||
| 379 | + | _, err = service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 2)) | |
| 380 | + | requireBrokerClosed(t, err) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestServiceShutdownTerminatesOwnedWork(t *testing.T) { | |
| 384 | + | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 385 | + | service, err := authority.AdmitClient(context.Background()) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | ||
| 388 | + | stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1)) | |
| 389 | + | require.NoError(t, err) | |
| 390 | + | ||
| 391 | + | // Broker shutdown cancels the connection root: owned streams end even before | |
| 392 | + | // the session gets to call Close. | |
| 393 | + | require.NoError(t, supervisor.Close()) | |
| 394 | + | await(t, stream.Done()) | |
| 395 | + | ||
| 396 | + | require.NoError(t, service.Close()) | |
| 397 | + | clients, _, _, _ := supervisorState(supervisor) | |
| 398 | + | require.Zero(t, clients) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func TestServiceOfflineMembershipRefusal(t *testing.T) { | |
| 402 | + | authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 403 | + | service, err := authority.AdmitClient(context.Background()) | |
| 404 | + | require.NoError(t, err) | |
| 405 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 406 | + | ||
| 407 | + | err = service.AddHost(context.Background(), "new@host:22") | |
| 408 | + | require.ErrorIs(t, err, ErrOfflineMembership) | |
| 409 | + | var typed ports.BrokerError | |
| 410 | + | require.ErrorAs(t, err, &typed) | |
| 411 | + | ||
| 412 | + | removed, err := service.RemoveHost(context.Background(), "new@host:22") | |
| 413 | + | require.False(t, removed) | |
| 414 | + | require.ErrorIs(t, err, ErrOfflineMembership) | |
| 415 | + | ||
| 416 | + | require.Empty(t, registry.Snapshot().Hosts, "a refused mutation must not touch authority") | |
| 417 | + | before := registry.Snapshot() | |
| 418 | + | service.RequestReconcile("new@host:22") | |
| 419 | + | require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op") | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) { | |
| 423 | + | clock := newManualClock(time.Unix(0, 0)) | |
| 424 | + | store := newTestStore() | |
| 425 | + | endpoint := "user@host:22" | |
| 426 | + | store.loaded = ports.BrokerSnapshot{ | |
| 427 | + | Epoch: 9, | |
| 428 | + | Revision: 4, | |
| 429 | + | Hosts: []ports.RemoteHostSnapshot{{ | |
| 430 | + | Endpoint: endpoint, | |
| 431 | + | Registration: registration(t, endpoint, 1), | |
| 432 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 433 | + | }}, | |
| 434 | + | } | |
| 435 | + | r, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 436 | + | require.NoError(t, err) | |
| 437 | + | ||
| 438 | + | // The disabled registry restores durable state under its fresh epoch and | |
| 439 | + | // publishes it without observing anything. | |
| 440 | + | snapshot := r.Snapshot() | |
| 441 | + | require.Equal(t, ports.BrokerEpoch(1), snapshot.Epoch) | |
| 442 | + | require.EqualValues(t, 1, snapshot.Revision) | |
| 443 | + | host, ok := snapshot.Find(endpoint) | |
| 444 | + | require.True(t, ok) | |
| 445 | + | require.Equal(t, domain.RemoteAvailabilityReachable, host.Availability) | |
| 446 | + | require.Zero(t, clock.activeTimers()) | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func TestRegistryObservationDisabledIssuesNoProbesOrTimers(t *testing.T) { | |
| 450 | + | clock := newManualClock(time.Unix(0, 0)) | |
| 451 | + | store := newTestStore() | |
| 452 | + | probe := newTestProbe(4) | |
| 453 | + | r, err := NewRegistryWithConfig(1, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 454 | + | require.NoError(t, err) | |
| 455 | + | require.NoError(t, r.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)})) | |
| 456 | + | ||
| 457 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 458 | + | done := make(chan struct{}) | |
| 459 | + | go func() { defer close(done); r.Run(ctx) }() | |
| 460 | + | ||
| 461 | + | // A reconcile/probe demand must schedule nothing: no probe starts and no | |
| 462 | + | // timer is armed, so advancing the clock cannot produce observation work. | |
| 463 | + | r.RequestProbe("user@host:22") | |
| 464 | + | clock.Advance(time.Hour) | |
| 465 | + | require.Zero(t, clock.activeTimers()) | |
| 466 | + | require.Empty(t, probe.calls) | |
| 467 | + | ||
| 468 | + | cancel() | |
| 469 | + | await(t, done) | |
| 470 | + | require.Zero(t, clock.activeTimers()) | |
| 471 | + | require.Empty(t, probe.calls) | |
| 472 | + | require.Equal(t, runStopped, r.running.Load()) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func TestRegistryObservationDisabledDrainsWriter(t *testing.T) { | |
| 476 | + | clock := newManualClock(time.Unix(0, 0)) | |
| 477 | + | store := newBlockingStore() | |
| 478 | + | r, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 479 | + | require.NoError(t, err) | |
| 480 | + | ||
| 481 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 482 | + | runDone := make(chan struct{}) | |
| 483 | + | go func() { defer close(runDone); r.Run(ctx) }() | |
| 484 | + | ||
| 485 | + | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{{ | |
| 486 | + | Registration: registration(t, "user@host:22", 1), | |
| 487 | + | Pinned: true, | |
| 488 | + | Policy: poolPolicy(), | |
| 489 | + | }})) | |
| 490 | + | select { | |
| 491 | + | case <-store.entered: | |
| 492 | + | case <-time.After(time.Second): | |
| 493 | + | t.Fatal("the durable writer never attempted a write") | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // Run owns the writer: it must not settle while a durable write is in | |
| 497 | + | // flight, and it must still stop once that write drains. | |
| 498 | + | cancel() | |
| 499 | + | select { | |
| 500 | + | case <-runDone: | |
| 501 | + | t.Fatal("Run must not return before the in-flight durable write drains") | |
| 502 | + | case <-time.After(probeGrace): | |
| 503 | + | } | |
| 504 | + | store.releaseFirst() | |
| 505 | + | await(t, runDone) | |
| 506 | + | require.NotEmpty(t, store.writtenRevisions()) | |
| 507 | + | require.Zero(t, clock.activeTimers()) | |
| 508 | + | } | |