1package brokerconfig
2
3import (
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 "github.com/bnema/vev/internal/ports"
19)
20
21// Bounded route variants.
22//
23// A registration's route is the transport the offline broker dials for that
24// endpoint. It is an explicit, bounded tagged union, never a caller-supplied
25// address: the immutable configuration alone decides whether the broker reaches
26// the daemon-mux carriage over a local Unix socket, an SSH stdio child, or an
27// SSH-authenticated QUIC bootstrap. The route carries no identity, policy, or
28// secret; identity and policy stay authoritative on the registration, and the
29// one-time QUIC credentials are minted fresh per physical connection and never
30// enter an address, a log, or the configuration.
31//
32// The route's opaque pool address is a digest of the route's own bounded
33// contents, so two distinct routes can never collide and no route detail (an
34// SSH target, a path, or an argv word) is exposed through the address the pool
35// carries.
36type RouteKind string
37
38const (
39 // RouteUnix dials one absolute private daemon-mux Unix socket directly.
40 RouteUnix RouteKind = "unix"
41 // RouteSSHStdio starts one explicit remote mux helper over a local ssh
42 // child and carries the daemonmux conversation over its stdio.
43 RouteSSHStdio RouteKind = "ssh-stdio"
44 // RouteSSHQUIC runs one SSH-authenticated QUIC bootstrap on the remote
45 // host and pins the freshly minted ephemeral certificate before dialing.
46 RouteSSHQUIC RouteKind = "ssh-quic"
47)
48
49// Bounded route limits. They are explicit so a hostile or accidental
50// configuration can never grow a route, an argv, or a trust input without
51// limit, and so two distinct routes are always distinguishable inside the
52// address digest.
53const (
54 // MaxRouteTargetBytes bounds one ssh target.
55 MaxRouteTargetBytes = 256
56 // MaxRouteArgvWords bounds the remote argv word count.
57 MaxRouteArgvWords = 32
58 // MaxRouteArgvBytes bounds the total serialized remote argv.
59 MaxRouteArgvBytes = 4096
60 // MaxRouteWordBytes bounds one remote argv word.
61 MaxRouteWordBytes = 1024
62 // MaxTrustPathBytes bounds one trust-input path.
63 MaxTrustPathBytes = 1024
64 // MaxConnectTimeout bounds a provisioned ssh connect timeout.
65 MaxConnectTimeout = 5 * time.Minute
66
67 // MaxRouteAddressBytes bounds the opaque pool address a route exposes.
68 MaxRouteAddressBytes = 64
69)
70
71// TrustInputs carries the explicit OpenSSH inputs a provisioned ssh route may
72// forward. They can only narrow or redirect verification; host-key checking is
73// never disabled, authentication is never bypassed, and no PTY is ever
74// requested. Every field is optional.
75type TrustInputs struct {
76 // knownHostsFile, when set, names the absolute known-hosts file ssh verifies
77 // against. Verification still happens: an unknown or mismatched key fails.
78 knownHostsFile string
79 // connectTimeout, when positive, bounds ssh's own connection attempt.
80 connectTimeout time.Duration
81 // set records whether the trust object was present, so an empty object and
82 // an absent object are distinguishable in the route digest.
83 set bool
84}
85
86// Route is one immutable, validated daemonmux route.
87type Route struct {
88 kind RouteKind
89 path string
90 target string
91 argv []string
92 trust TrustInputs
93 // address is the bounded opaque token the pool carries for this route. It
94 // is a digest of the route contents, so it leaks no route detail and can
95 // never collide with a distinct route.
96 address string
97 // local is true for a Unix route that this process can dial directly. Such a
98 // route is the carriage a remote-side mux helper bridges to when a
99 // registration provisions it; a local binding's own carriage is dialled by
100 // the broker itself and is never a helper route.
101 local bool
102}
103
104// Kind returns the route variant. The zero Route reports an empty kind.
105func (r Route) Kind() RouteKind { return r.kind }
106
107// Path returns the absolute private Unix mux socket for a Unix route.
108func (r Route) Path() string { return r.path }
109
110// Target returns the ssh target for an ssh route.
111func (r Route) Target() string { return r.target }
112
113// Argv returns a copy of the explicit remote argv for an ssh route.
114func (r Route) Argv() []string { return append([]string(nil), r.argv...) }
115
116// KnownHostsFile returns the provisioned known-hosts path, if any.
117func (r Route) KnownHostsFile() string { return r.trust.knownHostsFile }
118
119// ConnectTimeout returns the provisioned ssh connect timeout, if any.
120func (r Route) ConnectTimeout() time.Duration { return r.trust.connectTimeout }
121
122// Address returns the bounded opaque pool address for this route. It never
123// contains a secret, an argv word, or a trust path.
124func (r Route) Address() string { return r.address }
125
126// IsLocal reports whether the route dials a Unix socket this process can reach
127// directly. A local binding's carriage and a registration's Unix carriage are
128// both local; only the latter is a route a remote-side mux helper bridges to.
129func (r Route) IsLocal() bool { return r.local }
130
131// Validate re-checks one already-parsed route. Parsed routes are validated
132// once, so this is the seam a caller uses to fence a zero or hand-built value.
133func (r Route) Validate() error {
134 if err := r.validateFields(); err != nil {
135 return err
136 }
137 if r.address != r.digest() {
138 return errors.New("route address does not match its contents")
139 }
140 if r.local != (r.kind == RouteUnix) {
141 return errors.New("route locality does not match its kind")
142 }
143 return nil
144}
145
146// validateFields runs before constructors derive the opaque address.
147func (r Route) validateFields() error {
148 switch r.kind {
149 case RouteUnix:
150 if err := validateRoute(r.path); err != nil {
151 return err
152 }
153 return nil
154 case RouteSSHStdio, RouteSSHQUIC:
155 return r.validateSSH()
156 default:
157 return fmt.Errorf("route kind %q is invalid", r.kind)
158 }
159}
160
161func (r Route) validateSSH() error {
162 if err := validateSSHTarget(r.target); err != nil {
163 return err
164 }
165 if err := validateArgv(r.argv); err != nil {
166 return err
167 }
168 if r.trust.knownHostsFile != "" {
169 if err := validateTrustPath(r.trust.knownHostsFile); err != nil {
170 return err
171 }
172 }
173 if r.trust.connectTimeout < 0 || r.trust.connectTimeout > MaxConnectTimeout {
174 return fmt.Errorf("trust connectTimeout must be between 0 and %s", MaxConnectTimeout)
175 }
176 return nil
177}
178
179// validateSSHTarget bounds and sanitizes one ssh target. It reuses the domain
180// host-target rule (no whitespace or control characters) and additionally
181// bounds the length and refuses a leading dash, so a target can never be
182// mistaken for an ssh option even before the option terminator.
183func validateSSHTarget(target string) error {
184 if err := domain.ValidateRemoteHostTarget(target); err != nil {
185 return err
186 }
187 if len(target) > MaxRouteTargetBytes {
188 return fmt.Errorf("ssh target exceeds %d bytes", MaxRouteTargetBytes)
189 }
190 if strings.HasPrefix(target, "-") {
191 return errors.New("ssh target must not start with a dash")
192 }
193 return nil
194}
195
196// validateArgv bounds the explicit remote argv. Every word must be non-empty,
197// valid UTF-8, and free of NUL and control characters; the executable is the
198// first word. The remote argv is passed to ssh as one POSIX-quoted string, so
199// spaces inside a word are safe and are not treated as separators.
200func validateArgv(argv []string) error {
201 if len(argv) == 0 {
202 return errors.New("ssh route requires a non-empty argv")
203 }
204 if len(argv) > MaxRouteArgvWords {
205 return fmt.Errorf("ssh route argv exceeds %d words", MaxRouteArgvWords)
206 }
207 total := 0
208 for i, word := range argv {
209 if word == "" {
210 return fmt.Errorf("ssh route argv[%d] is empty", i)
211 }
212 if len(word) > MaxRouteWordBytes {
213 return fmt.Errorf("ssh route argv[%d] exceeds %d bytes", i, MaxRouteWordBytes)
214 }
215 if !utf8.ValidString(word) {
216 return fmt.Errorf("ssh route argv[%d] is not valid UTF-8", i)
217 }
218 for _, r := range word {
219 if r == 0 || unicode.IsControl(r) {
220 return fmt.Errorf("ssh route argv[%d] contains a control character", i)
221 }
222 }
223 total += len(word)
224 }
225 if total > MaxRouteArgvBytes {
226 return fmt.Errorf("ssh route argv exceeds %d bytes", MaxRouteArgvBytes)
227 }
228 return nil
229}
230
231// validateTrustPath bounds one trust-input path. It must be absolute and clean
232// and free of control characters, so it can never be reshaped by a later step
233// or inject an ssh option.
234func validateTrustPath(path string) error {
235 if len(path) > MaxTrustPathBytes {
236 return fmt.Errorf("trust path exceeds %d bytes", MaxTrustPathBytes)
237 }
238 if !utf8.ValidString(path) {
239 return errors.New("trust path is not valid UTF-8")
240 }
241 if !filepath.IsAbs(path) {
242 return fmt.Errorf("trust path %q is not absolute", path)
243 }
244 if filepath.Clean(path) != path {
245 return fmt.Errorf("trust path %q is not clean", path)
246 }
247 for _, r := range path {
248 if r == 0 || unicode.IsControl(r) || unicode.IsSpace(r) {
249 return fmt.Errorf("trust path %q contains whitespace or control characters", path)
250 }
251 }
252 return nil
253}
254
255// routeDocument is the strict on-disk shape of a route object. Unknown fields
256// are refused by the decoder, so a route cannot smuggle additional behavior.
257type routeDocument struct {
258 Kind string `json:"kind"`
259 Path string `json:"path,omitempty"`
260 Target string `json:"target,omitempty"`
261 Argv []string `json:"argv,omitempty"`
262 Trust *trustDocument `json:"trust,omitempty"`
263}
264
265type trustDocument struct {
266 KnownHostsFile string `json:"knownHostsFile,omitempty"`
267 ConnectTimeout string `json:"connectTimeout,omitempty"`
268}
269
270// parseRoute strictly parses one route. A JSON string is the original Unix
271// route form (an absolute private mux path); an object carries an explicit
272// kind. Unknown kinds, unknown fields, trailing JSON, and a kind/field mismatch
273// are refused.
274func parseRoute(raw json.RawMessage) (Route, error) {
275 trimmed := bytes.TrimSpace(raw)
276 if len(trimmed) == 0 {
277 return Route{}, errors.New("route is empty")
278 }
279 if trimmed[0] == '"' {
280 var path string
281 if err := json.Unmarshal(trimmed, &path); err != nil {
282 return Route{}, fmt.Errorf("route: %w", err)
283 }
284 return newUnixRoute(path)
285 }
286 if trimmed[0] != '{' {
287 return Route{}, errors.New("route must be a path string or an object")
288 }
289 var document routeDocument
290 decoder := json.NewDecoder(bytes.NewReader(trimmed))
291 decoder.DisallowUnknownFields()
292 if err := decoder.Decode(&document); err != nil {
293 return Route{}, fmt.Errorf("route: %w", err)
294 }
295 if _, err := decoder.Token(); err != io.EOF {
296 return Route{}, errors.New("route: trailing JSON")
297 }
298 switch RouteKind(document.Kind) {
299 case RouteUnix:
300 if document.Target != "" || len(document.Argv) != 0 || document.Trust != nil {
301 return Route{}, errors.New("unix route must not carry ssh fields")
302 }
303 return newUnixRoute(document.Path)
304 case RouteSSHStdio, RouteSSHQUIC:
305 trust, err := parseTrust(document.Trust)
306 if err != nil {
307 return Route{}, err
308 }
309 return newSSHRoute(RouteKind(document.Kind), document.Target, document.Argv, trust)
310 case "":
311 return Route{}, errors.New("route object requires a kind")
312 default:
313 return Route{}, fmt.Errorf("route kind %q is invalid", document.Kind)
314 }
315}
316
317func parseTrust(document *trustDocument) (TrustInputs, error) {
318 if document == nil {
319 return TrustInputs{}, nil
320 }
321 trust := TrustInputs{knownHostsFile: document.KnownHostsFile, set: true}
322 if document.ConnectTimeout != "" {
323 timeout, err := time.ParseDuration(document.ConnectTimeout)
324 if err != nil {
325 return TrustInputs{}, fmt.Errorf("trust connectTimeout %q is not a duration", document.ConnectTimeout)
326 }
327 if timeout <= 0 {
328 return TrustInputs{}, errors.New("trust connectTimeout must be positive")
329 }
330 trust.connectTimeout = timeout
331 }
332 return trust, nil
333}
334
335// RouteSpecAddress returns the opaque pool address RouteFromSpec would give
336// the durable route.
337func RouteSpecAddress(spec ports.BrokerRouteSpec) (string, error) {
338 route, err := RouteFromSpec(spec)
339 if err != nil {
340 return "", err
341 }
342 return route.Address(), nil
343}
344
345// RouteFromSpec materializes canonical durable routing authority. Runtime SSH
346// trust is OpenSSH-owned; no startup configuration lookup participates.
347func RouteFromSpec(spec ports.BrokerRouteSpec) (Route, error) {
348 if err := spec.Validate(); err != nil {
349 return Route{}, err
350 }
351 if spec.Kind == ports.BrokerRouteUnix {
352 return newUnixRoute(spec.Path)
353 }
354 return newSSHRoute(RouteKind(spec.Kind), spec.Target, spec.Argv, TrustInputs{})
355}
356
357func newUnixRoute(path string) (Route, error) {
358 route := Route{kind: RouteUnix, path: path, local: true}
359 if err := route.validateFields(); err != nil {
360 return Route{}, err
361 }
362 route.address = route.digest()
363 return route, nil
364}
365
366func newSSHRoute(kind RouteKind, target string, argv []string, trust TrustInputs) (Route, error) {
367 route := Route{
368 kind: kind,
369 target: target,
370 argv: append([]string(nil), argv...),
371 trust: trust,
372 }
373 if err := route.validateFields(); err != nil {
374 return Route{}, err
375 }
376 route.address = route.digest()
377 return route, nil
378}
379
380// digest computes the bounded opaque pool address. The canonical encoding
381// separates every field with a NUL and carries the kind first, so distinct
382// routes always produce distinct digests.
383func (r Route) digest() string {
384 var builder strings.Builder
385 builder.WriteString(string(r.kind))
386 builder.WriteByte(0)
387 builder.WriteString(r.path)
388 builder.WriteByte(0)
389 builder.WriteString(r.target)
390 builder.WriteByte(0)
391 for _, word := range r.argv {
392 builder.WriteString(word)
393 builder.WriteByte(0)
394 }
395 builder.WriteByte(0)
396 builder.WriteString(r.trust.knownHostsFile)
397 builder.WriteByte(0)
398 fmt.Fprintf(&builder, "%d", r.trust.connectTimeout)
399 if r.trust.set {
400 builder.WriteByte(1)
401 }
402 sum := sha256.Sum256([]byte(builder.String()))
403 return "offline-route-" + hex.EncodeToString(sum[:16])
404}