1// Package brokerconfig owns the strict, isolated offline broker sandbox
2// configuration.
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. A
19// local request carries no endpoint or registration and is instead fenced
20// against the optional broker-owned local binding (identity, policy, and Unix
21// daemonmux route), so it too can never supply authority of its own. The
22// route's pool address is an opaque digest, so no route detail is exposed to the
23// pooling layer.
24//
25// Because the pool keys one physical transport by the exact authenticated
26// identity plus policy pair and ignores that opaque address, two registrations
27// that share the pair must agree on the one route the pool dials: distinct
28// endpoints may alias an identical route, but a second route for the same pair
29// is refused rather than silently stranded.
30package brokerconfig
31
32import (
33 "bytes"
34 "encoding/hex"
35 "encoding/json"
36 "errors"
37 "fmt"
38 "io"
39 "io/fs"
40 "os"
41 "path/filepath"
42 "strings"
43 "syscall"
44 "time"
45 "unicode/utf8"
46
47 "github.com/bnema/vev/internal/domain"
48 "github.com/bnema/vev/internal/ports"
49 "github.com/bnema/vev/internal/protocol"
50)
51
52// Marker is the exact config.json marker that identifies a broker offline
53// sandbox configuration. A file without it is never treated as broker config,
54// so an accidental --offline-root pointing at unrelated JSON fails closed.
55const Marker = "vev.broker.offline/v1"
56
57// ConfigFileName is the fixed config file name inside one offline root.
58const ConfigFileName = "config.json"
59
60// MaxConfigBytes bounds the config file before any decoding.
61const MaxConfigBytes = 1 << 20
62
63// MaxIdleGrace bounds a provisioned idle grace so an accidental enormous value
64// cannot make an offline broker effectively immortal.
65const MaxIdleGrace = 24 * time.Hour
66
67// Warm transport retention (the broker-owned replacement for the removed
68// client remote.attachment-cache keys). DefaultWarmTransports matches that
69// cache's default capacity; MaxWarmTransports matches the pool's physical
70// bound. The default idle timeout is off (no age bound), also matching it;
71// a provisioned timeout is bounded by MaxIdleGrace.
72const (
73 DefaultWarmTransports = 8
74 MaxWarmTransports = 64
75)
76
77// Sandbox directory names derived from one offline root. SpawnDirName is the
78// private directory under Runtime that holds the descriptor-backed
79// spawn-election lock, deliberately separate from the broker lifetime lock in
80// Runtime itself so electing a spawner can never collide with lifetime
81// ownership.
82const (
83 RuntimeDirName = "runtime"
84 StateDirName = "state"
85 LogDirName = "log"
86 SpawnDirName = "spawn"
87)
88
89var (
90 // errUnknownEndpoint reports a request for an endpoint the immutable
91 // configuration never provisioned.
92 errUnknownEndpoint = errors.New("brokerconfig: unknown broker endpoint")
93 // errStaleRegistration reports a request whose registration no longer
94 // matches the provisioned immutable registration.
95 errStaleRegistration = errors.New("brokerconfig: stale broker registration")
96 // errNoLocalRoute reports a local request when the configuration provisions
97 // no broker-owned local binding, so the broker owns no local route.
98 errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route")
99 // errLocalPolicyConflict reports a local request whose policy is not exactly
100 // compatible with the provisioned local policy. The provisioned policy stays
101 // authoritative: the request can never widen, narrow, or replace it.
102 errLocalPolicyConflict = errors.New("brokerconfig: local request policy conflicts with the provisioned local policy")
103)
104
105// Layout names the sandbox-owned paths derived from one offline root. Every
106// path is beneath Root; no production runtime or state path is ever derived. It
107// also carries the reserved production directories it was validated against, so
108// Load can fence every provisioned route against them too.
109type Layout struct {
110 Root string
111 Runtime string
112 State string
113 Log string
114 // Spawn is the private spawn-election directory beneath Runtime.
115 Spawn string
116
117 // reserved holds the cleaned absolute production runtime and state paths
118 // supplied to ResolveLayout. Load refuses a provisioned route that overlaps
119 // them; it is unexported so only ResolveLayout can set it.
120 reserved []string
121}
122
123// VerifyCreated re-walks every sandbox-owned path and refuses any existing
124// component that is a symlink.
125//
126// ResolveLayout validates the root textually and stops at the first component
127// that does not exist yet, so the composing caller calls this once
128// pkg/safedir.EnsurePrivate has created the runtime, state, and log
129// directories. It closes the gap between that validation and the first use of
130// the created paths: a component swapped for a symlink in the window fails
131// closed instead of being followed.
132func (l Layout) VerifyCreated() error {
133 for _, path := range []string{l.Root, l.Runtime, l.State, l.Log, l.Spawn} {
134 if err := rejectSymlinkedComponents(path); err != nil {
135 return err
136 }
137 }
138 return nil
139}
140
141// rejectSymlinkedComponents refuses a path any of whose existing components is
142// a symlink not owned by root. A component that does not exist yet ends the
143// walk: the caller creates the remaining private directories itself.
144//
145// Root-owned symlinks are system layout, such as macOS /var -> private/var and
146// /tmp -> private/tmp; no other user can plant or retarget them.
147func rejectSymlinkedComponents(abs string) error {
148 volume := filepath.VolumeName(abs)
149 rest := strings.TrimPrefix(abs, volume)
150 rest = strings.TrimPrefix(rest, string(filepath.Separator))
151 current := volume + string(filepath.Separator)
152 if rest == "" {
153 return nil
154 }
155 for _, part := range strings.Split(rest, string(filepath.Separator)) {
156 if part == "" {
157 continue
158 }
159 current = filepath.Join(current, part)
160 info, err := os.Lstat(current)
161 if err != nil {
162 if errors.Is(err, fs.ErrNotExist) {
163 return nil
164 }
165 return fmt.Errorf("brokerconfig: inspect %q: %w", current, err)
166 }
167 if info.Mode()&os.ModeSymlink != 0 && !ownedByRoot(info) {
168 return fmt.Errorf("brokerconfig: %q is a symlink", current)
169 }
170 }
171 return nil
172}
173
174func ownedByRoot(info fs.FileInfo) bool {
175 stat, ok := info.Sys().(*syscall.Stat_t)
176 return ok && stat.Uid == 0
177}
178
179// overlaps reports whether two absolute cleaned paths are equal or one contains
180// the other.
181func overlaps(a, b string) bool {
182 if a == b {
183 return true
184 }
185 sep := string(filepath.Separator)
186 return strings.HasPrefix(a, b+sep) || strings.HasPrefix(b, a+sep)
187}
188
189// Registration is one immutable, provisioned endpoint: the exact daemon
190// registration a request must carry, the stable authenticated daemon identity,
191// the exact connection policy, and the bounded route the connector dials.
192type Registration struct {
193 Registration domain.RemoteRegistration
194 Identity ports.BrokerDaemonIdentity
195 Policy ports.BrokerPolicy
196 Route Route
197}
198
199// LocalBinding is the broker-owned binding for the broker's own machine
200// daemon. Unlike a remote Registration it carries no
201// registration: the local daemon is not a configured host, so a local stream
202// request is fenced only against this binding's identity, policy, and route.
203// The identity and policy are authoritative here and are the exact source of
204// the resolved endpoint and of the published local observation's configured
205// authority, so a local request can never select an identity or policy of its
206// own.
207type LocalBinding struct {
208 // Identity is the stable authenticated daemon identity the local
209 // daemonmux carriage must present during the physical preamble.
210 Identity ports.BrokerDaemonIdentity
211 // DisplayOrigin is the presentation hint published for the local
212 // observation. It is sanitized at load time and is never routing authority.
213 DisplayOrigin string
214 // Policy is the exact connection policy the local carriage negotiates.
215 Policy ports.BrokerPolicy
216 // Route is the broker-owned local daemonmux carriage. It is always a Unix
217 // route this process dials directly (Route.IsLocal() is true). The broker
218 // dials it itself, so it is never a remote-side mux helper route
219 // (Config.LocalMuxRoute).
220 Route Route
221}
222
223// pooledIdentity is the exact (authenticated identity, policy) pair the broker
224// pool keys one physical transport by the exact identity and policy pair. The pool deliberately ignores the opaque
225// route address, so two registrations that share a pair share one pooled
226// transport and must therefore agree on the single route that transport dials.
227type pooledIdentity struct {
228 identity ports.BrokerDaemonIdentity
229 policy ports.BrokerPolicy
230}
231
232// Config is the validated, immutable offline broker configuration.
233type Config struct {
234 byEndpoint map[string]Registration
235 byAddress map[string]Route
236 unixRoutes []Route
237 order []string
238 // local is the optional broker-owned local binding. It is nil when the
239 // configuration provisions no local route, in which case a local request is
240 // refused and no local observation is produced.
241 local *LocalBinding
242 // idleGrace is the provisioned effective idle grace; hasIdleGrace records
243 // whether the file provisioned one, so a caller can distinguish "no
244 // provisioning, use the built-in default" from "provisioned".
245 idleGrace time.Duration
246 hasIdleGrace bool
247 // warmTransports and warmIdleTimeout bound warm physical transport reuse;
248 // see WarmTransports and WarmIdleTimeout.
249 warmTransports int
250 warmIdleTimeout time.Duration
251}
252
253// LoadPath parses the broker configuration at an explicit application-owned
254// path while retaining the route and reserved-path validation from layout.
255func LoadPath(layout Layout, path string) (*Config, error) {
256 return loadPath(layout, path, nil)
257}
258
259// LoadProduction reads broker.json while deriving local daemon authority from
260// daemon-owned state. The production file must not carry a local identity.
261func LoadProduction(layout Layout, path string, identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, socketPath string) (*Config, error) {
262 // An absent identity is valid before the local daemon's first start. A
263 // present identity remains strict and becomes the expected handshake fence.
264 if identity != "" {
265 if err := identity.Validate(); err != nil {
266 return nil, err
267 }
268 }
269 if err := policy.Validate(); err != nil {
270 return nil, err
271 }
272 route, err := newUnixRoute(socketPath)
273 if err != nil {
274 return nil, err
275 }
276 local := &LocalBinding{Identity: identity, DisplayOrigin: LocalDisplayOrigin, Policy: policy, Route: route}
277 return loadPath(layout, path, local)
278}
279
280func loadPath(layout Layout, path string, authoritativeLocal *LocalBinding) (*Config, error) {
281 raw, err := readConfig(path)
282 if err != nil {
283 return nil, err
284 }
285 var document configDocument
286 if err := decodeStrict(raw, &document); err != nil {
287 return nil, fmt.Errorf("brokerconfig: %s: %w", ConfigFileName, err)
288 }
289 if document.Marker != Marker {
290 return nil, fmt.Errorf("brokerconfig: %s: missing or invalid marker", ConfigFileName)
291 }
292 config := &Config{
293 byEndpoint: make(map[string]Registration, len(document.Registrations)),
294 byAddress: make(map[string]Route, len(document.Registrations)),
295 order: make([]string, 0, len(document.Registrations)),
296 }
297 if document.IdleGrace != "" {
298 grace, err := time.ParseDuration(document.IdleGrace)
299 if err != nil {
300 return nil, fmt.Errorf("brokerconfig: %s: idleGrace %q is not a duration", ConfigFileName, document.IdleGrace)
301 }
302 if grace <= 0 || grace > MaxIdleGrace {
303 return nil, fmt.Errorf("brokerconfig: %s: idleGrace must be positive and at most %s", ConfigFileName, MaxIdleGrace)
304 }
305 config.idleGrace, config.hasIdleGrace = grace, true
306 }
307 if config.warmTransports, config.warmIdleTimeout, err = document.warm(); err != nil {
308 return nil, err
309 }
310 pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)+1)
311 for i, entry := range document.Registrations {
312 registration, err := entry.validate()
313 if err != nil {
314 return nil, fmt.Errorf("brokerconfig: registration %d: %w", i, err)
315 }
316 if registration.Route.IsLocal() {
317 if err := rejectRouteOverlap(registration.Route.Path(), layout.reserved); err != nil {
318 return nil, fmt.Errorf("brokerconfig: registration %d: %w", i, err)
319 }
320 }
321 if _, duplicate := config.byEndpoint[registration.Registration.Endpoint]; duplicate {
322 return nil, fmt.Errorf("brokerconfig: registration %d: duplicate endpoint %q", i, registration.Registration.Endpoint)
323 }
324 // The pool keys one physical transport by (identity, policy) and ignores
325 // the route address, so a second address for the same pair would be
326 // dialed for neither or both depending on scheduling. Refuse it and allow
327 // an alias only when the routes are identical.
328 key := pooledIdentity{identity: registration.Identity, policy: registration.Policy}
329 address := registration.Route.Address()
330 if existing, present := pooledRoutes[key]; present && existing != address {
331 return nil, fmt.Errorf("brokerconfig: registration %d: identity %q and policy already provisioned on a different route", i, registration.Identity)
332 }
333 pooledRoutes[key] = address
334 config.byEndpoint[registration.Registration.Endpoint] = registration
335 config.byAddress[address] = registration.Route
336 if registration.Route.IsLocal() {
337 config.unixRoutes = appendUnixRouteOnce(config.unixRoutes, registration.Route)
338 }
339 config.order = append(config.order, registration.Registration.Endpoint)
340 }
341 if authoritativeLocal != nil && document.Local != nil {
342 return nil, fmt.Errorf("brokerconfig: %s: production local authority must not be configured", ConfigFileName)
343 }
344 local := authoritativeLocal
345 if local == nil {
346 local, err = parseLocal(document.Local)
347 if err != nil {
348 return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
349 }
350 }
351 if local != nil {
352 if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil {
353 return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
354 }
355 // The pool keys one physical transport by (identity, policy) and ignores
356 // the route address, so a local binding that shares a remote (or another
357 // local) pair must agree on the one route that transport dials. Refuse a
358 // second route for the same pair, exactly as the registration loop does.
359 key := pooledIdentity{identity: local.Identity, policy: local.Policy}
360 address := local.Route.Address()
361 if existing, present := pooledRoutes[key]; present && existing != address {
362 return nil, fmt.Errorf("brokerconfig: %s: local identity %q and policy already provisioned on a different route", ConfigFileName, local.Identity)
363 }
364 pooledRoutes[key] = address
365 config.byAddress[address] = local.Route
366 config.local = local
367 }
368 return config, nil
369}
370
371// parseLocal strictly parses the optional local binding. A nil document
372// provisions no local route. The identity and policy are required and
373// validated; the route must be a local Unix daemonmux carriage, because the
374// local daemon is reached in-process over a private Unix socket and never
375// through an SSH or QUIC carriage. The display origin is sanitized and
376// defaults to the broker-owned value "local", so an absent or wholly
377// undisplayable origin never yields an empty publication.
378func parseLocal(document *localDocument) (*LocalBinding, error) {
379 if document == nil {
380 return nil, nil
381 }
382 identity := ports.BrokerDaemonIdentity(document.Identity)
383 if err := identity.Validate(); err != nil {
384 return nil, err
385 }
386 policy, err := document.Policy.validate()
387 if err != nil {
388 return nil, err
389 }
390 route, err := parseRoute(document.Route)
391 if err != nil {
392 return nil, err
393 }
394 if !route.IsLocal() {
395 return nil, errors.New("local route must be a Unix daemonmux carriage")
396 }
397 origin := ports.SanitizeBrokerDisplayText(document.DisplayOrigin)
398 if origin == "" {
399 origin = LocalDisplayOrigin
400 }
401 if len(origin) > ports.BrokerMaxDisplayOriginBytes {
402 return nil, fmt.Errorf("local display origin exceeds %d bytes", ports.BrokerMaxDisplayOriginBytes)
403 }
404 return &LocalBinding{Identity: identity, DisplayOrigin: origin, Policy: policy, Route: route}, nil
405}
406
407// appendUnixRouteOnce adds a local Unix route unless an identical route (the
408// same opaque address, hence the same bounded contents) is already present, so
409// endpoint aliases of one route never make LocalMuxRoute report more than one
410// provisioned route.
411func appendUnixRouteOnce(routes []Route, route Route) []Route {
412 for _, existing := range routes {
413 if existing.Address() == route.Address() {
414 return routes
415 }
416 }
417 return append(routes, route)
418}
419
420// LocalDisplayOrigin is the broker-owned default presentation hint for the
421// broker's own machine daemon when the configuration provisions none.
422const LocalDisplayOrigin = "local"
423
424// LocalBinding returns the provisioned broker-owned local binding and whether
425// one exists. A nil configuration or one without a local object provisions
426// nothing, and a local request is then refused.
427func (c *Config) LocalBinding() (LocalBinding, bool) {
428 if c == nil || c.local == nil {
429 return LocalBinding{}, false
430 }
431 return *c.local, true
432}
433
434// Endpoints returns the configured endpoints in file order. The result is a
435// copy; the configuration is immutable.
436func (c *Config) Endpoints() []string {
437 if c == nil {
438 return nil
439 }
440 return append([]string(nil), c.order...)
441}
442
443// Registration returns the immutable configured authority for one endpoint.
444func (c *Config) Registration(endpoint string) (Registration, bool) {
445 if c == nil {
446 return Registration{}, false
447 }
448 registration, ok := c.byEndpoint[endpoint]
449 return registration, ok
450}
451
452// IdleGrace returns the provisioned effective idle grace and whether the
453// configuration provisioned one. A nil configuration provisions nothing.
454func (c *Config) IdleGrace() (time.Duration, bool) {
455 if c == nil {
456 return 0, false
457 }
458 return c.idleGrace, c.hasIdleGrace
459}
460
461// WarmTransports returns how many idle physical transports the broker keeps
462// warm for reuse. Zero disables warm reuse. A nil configuration uses the
463// default.
464func (c *Config) WarmTransports() int {
465 if c == nil {
466 return DefaultWarmTransports
467 }
468 return c.warmTransports
469}
470
471// WarmIdleTimeout returns how long an idle physical transport stays warm.
472// Zero means no age bound.
473func (c *Config) WarmIdleTimeout() time.Duration {
474 if c == nil {
475 return 0
476 }
477 return c.warmIdleTimeout
478}
479
480// RouteByAddress resolves one opaque pool address back to the route it was
481// derived from. The address is produced only by this configuration's own
482// routes, so an unknown address is refused rather than dialed.
483func (c *Config) RouteByAddress(address string) (Route, bool) {
484 if c == nil {
485 return Route{}, false
486 }
487 route, ok := c.byAddress[address]
488 return route, ok
489}
490
491// Resolver returns the immutable endpoint resolver over this configuration.
492func (c *Config) Resolver() *Resolver {
493 if c == nil {
494 return &Resolver{byEndpoint: map[string]Registration{}}
495 }
496 byEndpoint := make(map[string]Registration, len(c.byEndpoint))
497 for endpoint, registration := range c.byEndpoint {
498 registration.Route.argv = append([]string(nil), registration.Route.argv...)
499 byEndpoint[endpoint] = registration
500 }
501 var local *LocalBinding
502 if c.local != nil {
503 copy := *c.local
504 copy.Route.argv = append([]string(nil), copy.Route.argv...)
505 local = ©
506 }
507 return &Resolver{byEndpoint: byEndpoint, local: local}
508}
509
510// configDocument is the strict on-disk shape. Unknown fields are refused by the
511// decoder, so the file cannot smuggle additional behavior.
512type configDocument struct {
513 Marker string `json:"marker"`
514 // Local optionally provisions the broker-owned local binding. It is the
515 // only source of the local identity, policy, and daemonmux route.
516 Local *localDocument `json:"local,omitempty"`
517 // IdleGrace optionally provisions the sandbox's effective idle grace as a Go
518 // duration string (for example "90s"). It is the single deterministic source
519 // for the idle grace, because a status probe cannot read a running broker's
520 // effective grace through the broker IPC protocol.
521 IdleGrace string `json:"idleGrace,omitempty"`
522 // WarmTransports optionally bounds idle physical transports kept warm for
523 // reuse (0 through MaxWarmTransports; 0 disables warm reuse).
524 WarmTransports *int `json:"warmTransports,omitempty"`
525 // WarmIdleTimeout optionally expires a warm transport: "off" (no age
526 // bound, the default) or a positive Go duration up to MaxIdleGrace.
527 WarmIdleTimeout string `json:"warmIdleTimeout,omitempty"`
528 Registrations []registrationRaw `json:"registrations"`
529}
530
531func (d configDocument) warm() (int, time.Duration, error) {
532 count := DefaultWarmTransports
533 if d.WarmTransports != nil {
534 count = *d.WarmTransports
535 if count < 0 || count > MaxWarmTransports {
536 return 0, 0, fmt.Errorf("brokerconfig: %s: warmTransports must be between 0 and %d", ConfigFileName, MaxWarmTransports)
537 }
538 }
539 if d.WarmIdleTimeout == "" || d.WarmIdleTimeout == "off" {
540 return count, 0, nil
541 }
542 timeout, err := time.ParseDuration(d.WarmIdleTimeout)
543 if err != nil || timeout <= 0 || timeout > MaxIdleGrace {
544 return 0, 0, fmt.Errorf("brokerconfig: %s: warmIdleTimeout %q must be off or a positive duration of at most %s", ConfigFileName, d.WarmIdleTimeout, MaxIdleGrace)
545 }
546 return count, timeout, nil
547}
548
549// localDocument is the strict on-disk shape of the broker-owned local binding.
550// The route must be a local Unix daemonmux carriage, the identity and policy
551// are required, and the display origin is optional (defaulting to "local").
552type localDocument struct {
553 Identity string `json:"identity"`
554 DisplayOrigin string `json:"displayOrigin,omitempty"`
555 Route json.RawMessage `json:"route"`
556 Policy policyRaw `json:"policy"`
557}
558
559// registrationRaw is the raw JSON registration before semantic validation.
560type registrationRaw struct {
561 Endpoint string `json:"endpoint"`
562 Incarnation string `json:"incarnation"`
563 Generation uint64 `json:"generation"`
564 Identity string `json:"identity"`
565 Route json.RawMessage `json:"route"`
566 Policy policyRaw `json:"policy"`
567}
568
569// policyRaw is the raw JSON policy before semantic validation.
570type policyRaw struct {
571 ProtocolVersion uint16 `json:"protocolVersion"`
572 CatalogSchemaVersion uint16 `json:"catalogSchemaVersion"`
573 EnvironmentPolicy string `json:"environmentPolicy"`
574 Transport string `json:"transport"`
575 Trust string `json:"trust"`
576 Launch string `json:"launch"`
577 Isolation string `json:"isolation"`
578}
579
580func (r registrationRaw) validate() (Registration, error) {
581 var registration domain.RemoteRegistration
582 registration.Endpoint = r.Endpoint
583 if err := domain.ValidateRemoteHostTarget(r.Endpoint); err != nil {
584 return Registration{}, err
585 }
586 if len(r.Incarnation) != 32 {
587 return Registration{}, errors.New("incarnation must be 32 hexadecimal characters")
588 }
589 decoded, err := hex.DecodeString(r.Incarnation)
590 if err != nil {
591 return Registration{}, fmt.Errorf("incarnation: %w", err)
592 }
593 copy(registration.Incarnation[:], decoded)
594 registration.Generation = domain.RemoteGeneration(r.Generation)
595 if err := registration.Validate(); err != nil {
596 return Registration{}, err
597 }
598 identity := ports.BrokerDaemonIdentity(r.Identity)
599 if err := identity.Validate(); err != nil {
600 return Registration{}, err
601 }
602 policy, err := r.Policy.validate()
603 if err != nil {
604 return Registration{}, err
605 }
606 route, err := parseRoute(r.Route)
607 if err != nil {
608 return Registration{}, err
609 }
610 return Registration{Registration: registration, Identity: identity, Policy: policy, Route: route}, nil
611}
612
613func (p policyRaw) validate() (ports.BrokerPolicy, error) {
614 var environment protocol.EnvironmentPolicy
615 switch p.EnvironmentPolicy {
616 case "client-owned":
617 environment = protocol.EnvironmentPolicyClientOwned
618 case "daemon-owned":
619 environment = protocol.EnvironmentPolicyDaemonOwned
620 default:
621 return ports.BrokerPolicy{}, fmt.Errorf("policy environmentPolicy %q is invalid", p.EnvironmentPolicy)
622 }
623 policy := ports.BrokerPolicy{
624 ProtocolVersion: p.ProtocolVersion,
625 CatalogSchemaVersion: p.CatalogSchemaVersion,
626 EnvironmentPolicy: environment,
627 Transport: p.Transport,
628 Trust: p.Trust,
629 Launch: p.Launch,
630 Isolation: p.Isolation,
631 }
632 if err := policy.Validate(); err != nil {
633 return ports.BrokerPolicy{}, err
634 }
635 return policy, nil
636}
637
638// rejectRouteOverlap refuses a provisioned Unix mux route that equals, contains,
639// or sits inside a reserved production runtime or state directory. A route is an
640// address the broker connector dials, so a route overlapping production would
641// let the isolated offline sandbox reach a production endpoint even though the
642// sandbox root itself is outside production. Reserved entries are cleaned
643// absolute paths; empty entries are ignored.
644func rejectRouteOverlap(route string, reserved []string) error {
645 for _, other := range reserved {
646 if other == "" {
647 continue
648 }
649 other = filepath.Clean(other)
650 if overlaps(route, other) {
651 return fmt.Errorf("route %q overlaps production path %q", route, other)
652 }
653 }
654 return nil
655}
656
657// validateRoute refuses anything but an absolute, cleaned Unix path without
658// symlinked components, so a provisioned route can never be reshaped by the
659// request path or escape through a link.
660func validateRoute(route string) error {
661 if route == "" {
662 return errors.New("route is empty")
663 }
664 if !filepath.IsAbs(route) {
665 return fmt.Errorf("route %q is not absolute", route)
666 }
667 if filepath.Clean(route) != route {
668 return fmt.Errorf("route %q is not clean", route)
669 }
670 return rejectSymlinkedComponents(route)
671}
672
673// readConfig reads one bounded regular owner-only config file without following
674// a symlink.
675func readConfig(path string) ([]byte, error) {
676 fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
677 if err != nil {
678 if errors.Is(err, syscall.ELOOP) {
679 return nil, fmt.Errorf("brokerconfig: %s is a symlink", path)
680 }
681 return nil, fmt.Errorf("brokerconfig: open %s: %w", path, err)
682 }
683 file := os.NewFile(uintptr(fd), path)
684 defer file.Close()
685 info, err := file.Stat()
686 if err != nil {
687 return nil, fmt.Errorf("brokerconfig: stat %s: %w", path, err)
688 }
689 if !info.Mode().IsRegular() {
690 return nil, fmt.Errorf("brokerconfig: %s is not a regular file", path)
691 }
692 if info.Mode().Perm()&0o077 != 0 {
693 return nil, fmt.Errorf("brokerconfig: %s has permissions %04o, want owner-only", path, info.Mode().Perm())
694 }
695 if info.Size() > MaxConfigBytes {
696 return nil, fmt.Errorf("brokerconfig: %s exceeds %d bytes", path, MaxConfigBytes)
697 }
698 raw, err := io.ReadAll(io.LimitReader(file, MaxConfigBytes+1))
699 if err != nil {
700 return nil, fmt.Errorf("brokerconfig: read %s: %w", path, err)
701 }
702 if len(raw) > MaxConfigBytes {
703 return nil, fmt.Errorf("brokerconfig: %s exceeds %d bytes", path, MaxConfigBytes)
704 }
705 return raw, nil
706}
707
708// decodeStrict rejects duplicate keys, unknown fields, trailing JSON, invalid
709// UTF-8, and excessive nesting, then decodes once. Byte bounds are applied
710// before decoding.
711func decodeStrict(raw []byte, dst any) error {
712 if len(raw) > MaxConfigBytes || !utf8.Valid(raw) {
713 return errors.New("invalid JSON size or encoding")
714 }
715 scan := json.NewDecoder(bytes.NewReader(raw))
716 if err := scanJSON(scan, 0); err != nil {
717 return err
718 }
719 if _, err := scan.Token(); err != io.EOF {
720 return errors.New("trailing JSON")
721 }
722 decoder := json.NewDecoder(bytes.NewReader(raw))
723 decoder.DisallowUnknownFields()
724 return decoder.Decode(dst)
725}
726
727// scanJSON walks one JSON value, rejecting duplicate keys and excessive depth.
728func scanJSON(decoder *json.Decoder, depth int) error {
729 if depth > 64 {
730 return errors.New("JSON nesting limit")
731 }
732 token, err := decoder.Token()
733 if err != nil {
734 return err
735 }
736 delimiter, ok := token.(json.Delim)
737 if !ok {
738 return nil
739 }
740 switch delimiter {
741 case '{':
742 seen := make(map[string]bool)
743 for decoder.More() {
744 key, err := decoder.Token()
745 if err != nil {
746 return err
747 }
748 name, ok := key.(string)
749 if !ok || seen[name] {
750 return errors.New("duplicate JSON key")
751 }
752 seen[name] = true
753 if err := scanJSON(decoder, depth+1); err != nil {
754 return err
755 }
756 }
757 _, err = decoder.Token()
758 return err
759 case '[':
760 for decoder.More() {
761 if err := scanJSON(decoder, depth+1); err != nil {
762 return err
763 }
764 }
765 _, err = decoder.Token()
766 return err
767 default:
768 return errors.New("unexpected JSON delimiter")
769 }
770}