main ece7682

route.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close