1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "syscall"
10
11 "github.com/bnema/vev/internal/adapters/daemonmux"
12 "github.com/bnema/vev/internal/adapters/ipc"
13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/broker"
15)
16
17// Broker-owned daemon start.
18//
19// The broker is the only component that decides whether a target daemon may be
20// started. Its resolved dial target already carries the closed start
21// authorization (ports.BrokerDaemonStartMode) together with the policy and the
22// endpoint fence, and this file is the single place where that authorization
23// reaches transport mechanics:
24//
25// - BrokerDaemonExistingOnly dials the private daemonmux carriage and nothing
26// else. It never probes the lifecycle lock, never takes the spawn lock,
27// never launches a process, and never dials a session: an existing-only
28// acquisition is exactly one carriage dial.
29// - BrokerDaemonStartIfNeeded dials the same carriage first. A successful dial
30// is the whole result — an already running daemon is never restarted. Only
31// when the dial proves the daemon is absent does the start proceed, and only
32// when the resolved policy explicitly authorizes launching. The start itself
33// is the existing election in spawn.go: lifecycle availability, one elected
34// spawner under the shared spawn lock, then a bounded redial of the same
35// carriage under the same budget.
36//
37// The result handed to the pool is always daemonmux: no session transport is
38// ever produced here, so no caller can fall back to the direct session dial the
39// broker replaced. Readiness is proven by the carriage the pool then
40// authenticates with the daemonmux physical preamble, never by a session dial,
41// a session control stream, or a Hello.
42//
43// Refusals are explicit and typed. A daemon that is absent may only be started
44// under an authorizing policy; a carriage that exists but refuses the peer, the
45// path, or the credentials is never "fixed" by spawning a second daemon, and a
46// start that leaves the carriage still undialable is reported rather than
47// retried with a session dial. Identity, incarnation, policy, and version are
48// verified by the daemonmux preamble after this function returns, so those
49// failures can never trigger a spawn either.
50
51// The start decision itself (mode, launch authority, local-only target) is
52// owned by broker.ClassifyDaemonDialFailure, broker.AuthorizeDaemonLaunch, and
53// broker.RequireLocalDaemonTarget.
54// This file keeps only the mechanics: carriage path validation, absence
55// classification of a dial error, and the spawn election.
56
57// brokerDaemonStarter owns the three facts a daemon start needs: the private
58// daemonmux carriage dial, the detached daemon launcher, and the exact carriage
59// the launched daemon will publish. All three are seams so tests exercise the
60// real election, policy, and refusal rules without binding a socket or
61// re-executing a daemon.
62type brokerDaemonStarter struct {
63 dial func(ctx context.Context, carriage string) (daemonmux.RawFramedTransport, error)
64 spawn spawnFunc
65 backoff backoffConfig
66 // published returns the private daemonmux carriage the spawned daemon binds.
67 // Nil means the production endpoint of this process.
68 published func() string
69}
70
71// localDaemonStarter is the production starter: the real private Unix daemonmux
72// carriage, the real double-fork daemon launcher, and the exact endpoint that
73// launcher publishes. It is the broker process' starter and a remote mux
74// helper's starter alike, because both start the daemon published on their own
75// machine by re-executing their own binary. A test replaces it with scripted
76// mechanics and restores it immediately; it is package state for the same reason
77// defaultBackoff is, and no test mutates it in parallel.
78var localDaemonStarter = brokerDaemonStarter{dial: dialLocalDaemonCarriage, spawn: realSpawn}
79
80// effectiveBackoff returns the starter's explicit backoff, or the production
81// default read at call time, so a test that narrows defaultBackoff still governs
82// the daemon start.
83func (s brokerDaemonStarter) effectiveBackoff() backoffConfig {
84 if s.backoff.total > 0 {
85 return s.backoff
86 }
87 return defaultBackoff
88}
89
90// effectivePublished returns the private daemonmux carriage the spawned daemon
91// binds: the starter's explicit endpoint, or this process' own production
92// daemon endpoint. The daemon launcher re-executes this binary, which always
93// binds daemonmux.SocketPath(ipc.SocketDir()), so that is the only carriage a
94// start can ever produce.
95func (s brokerDaemonStarter) effectivePublished() string {
96 if s.published != nil {
97 return s.published()
98 }
99 return daemonmux.SocketPath(ipc.SocketDir())
100}
101
102// dialLocalDaemonCarriage dials exactly the private daemonmux carriage at
103// carriage, verifying that the connected peer is the current effective user. It
104// is deliberately the mux carriage only: the daemon's public session socket is
105// never dialed here.
106func dialLocalDaemonCarriage(ctx context.Context, carriage string) (daemonmux.RawFramedTransport, error) {
107 return ipc.DialMuxContext(ctx, carriage)
108}
109
110// dialBrokerLocalDaemon dials the daemon published on this machine for one
111// resolved local target. carriage is the private daemonmux carriage of the
112// broker-owned local route: that route is its only source, and this function
113// never derives, guesses, or accepts a carriage of its own.
114//
115// A registered endpoint is refused even when it is provisioned on a local Unix
116// carriage: only the broker-owned local authority names the daemon this
117// process can start, so a configured host can never be spawned by mistake.
118func dialBrokerLocalDaemon(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
119 if err := broker.RequireLocalDaemonTarget(target); err != nil {
120 return nil, err
121 }
122 return dialBrokerDaemonCarriage(ctx, carriage, target)
123}
124
125// dialBrokerDaemonCarriage is the shared carriage dial and start. The target's
126// start authorization decides the whole behavior. ExistingOnly performs exactly
127// one carriage dial. StartIfNeeded performs the same dial first, returns the
128// daemon it found, and only starts a daemon when the dial proves absence and the
129// resolved policy authorizes launching; otherwise it reports the refusal without
130// touching any process.
131//
132// A start is only ever possible for the one daemon this process can actually
133// produce: the launcher re-executes this binary, which binds its private
134// daemonmux carriage at daemonmux.SocketPath(ipc.SocketDir()) and holds its
135// lifecycle and spawn locks in that same runtime directory. A carriage that is
136// anything else is dialed and, if absent, reported — never replaced by a daemon
137// this process did not start.
138func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
139 if err := target.Validate(); err != nil {
140 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
141 }
142 if err := validateDaemonCarriage(carriage); err != nil {
143 return nil, err
144 }
145 starter := localDaemonStarter
146 if starter.dial == nil || starter.spawn == nil {
147 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("daemon starter is incomplete")}
148 }
149 raw, err := starter.dial(ctx, carriage)
150 if err == nil {
151 return raw, nil
152 }
153 // The broker decides; nothing below runs unless it authorizes a start.
154 // The carriage check sits between its two steps, so a foreign carriage is
155 // reported as incompatible before any policy refusal.
156 if err := broker.ClassifyDaemonDialFailure(target, err, daemonCarriageAbsence(err)); err != nil {
157 return nil, err
158 }
159 if err := validateDaemonCarriageStart(carriage, starter); err != nil {
160 return nil, err
161 }
162 if err := broker.AuthorizeDaemonLaunch(target); err != nil {
163 return nil, err
164 }
165 published := starter.effectivePublished()
166 return ensureMuxDaemon(ctx, filepath.Dir(published), carriage, starter.dial, starter.spawn, starter.effectiveBackoff())
167}
168
169// validateDaemonCarriage refuses a carriage that cannot be a provisioned private
170// daemonmux endpoint: it must be an absolute, cleaned path. The daemon launcher
171// re-executes this binary to start one specific daemon, so a relative, unclean,
172// or empty path can never trigger a spawn.
173func validateDaemonCarriage(carriage string) error {
174 if carriage == "" {
175 return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("daemon carriage path is empty")}
176 }
177 if !filepath.IsAbs(carriage) || filepath.Clean(carriage) != carriage {
178 return ports.BrokerError{
179 Code: ports.BrokerErrorIncompatible,
180 Cause: fmt.Errorf("daemon carriage %q is not an absolute cleaned path", carriage),
181 }
182 }
183 return nil
184}
185
186// validateDaemonCarriageStart refuses starting a daemon for any carriage other
187// than the one the launched daemon will actually publish. The launcher can only
188// ever produce the daemon bound to that exact private endpoint, so starting one
189// for a foreign carriage would spawn a process the caller could never reach.
190func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) error {
191 if want := starter.effectivePublished(); carriage != want {
192 return ports.BrokerError{
193 Code: ports.BrokerErrorIncompatible,
194 Cause: fmt.Errorf("refusing to start a daemon for carriage %q: this process publishes %q", carriage, want),
195 }
196 }
197 return nil
198}
199
200// daemonCarriageAbsence reports whether a failed carriage dial means the daemon
201// is simply not there, so starting one is the only remaining option. It is
202// deliberately fail-closed: a cancelled attempt, an invalid or foreign path, a
203// rejected peer, a permission failure, and every unclassified error report
204// false, so only proven absence can authorize a spawn.
205func daemonCarriageAbsence(err error) bool {
206 switch {
207 case err == nil:
208 return false
209 case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
210 return false
211 case errors.Is(err, ipc.ErrMuxPath), errors.Is(err, ipc.ErrMuxForeignPath),
212 errors.Is(err, ipc.ErrMuxPeerRejected), errors.Is(err, ipc.ErrMuxUnsupported):
213 return false
214 case errors.Is(err, os.ErrPermission):
215 return false
216 case errors.Is(err, os.ErrNotExist), errors.Is(err, syscall.ENOENT),
217 errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, syscall.EWOULDBLOCK):
218 return true
219 default:
220 return false
221 }
222}
223
224// brokerDaemonStartArgvFlag renders the closed start authorization as the exact
225// helper argument value. An unknown mode is refused, never defaulted: a helper
226// that cannot read its authorization must not run with a permissive one.
227func brokerDaemonStartArgvFlag(mode ports.BrokerDaemonStartMode) (string, error) {
228 switch mode {
229 case ports.BrokerDaemonExistingOnly:
230 return "existing-only", nil
231 case ports.BrokerDaemonStartIfNeeded:
232 return "if-needed", nil
233 default:
234 return "", fmt.Errorf("invalid broker daemon start mode %s", mode)
235 }
236}
237
238// parseBrokerDaemonStartArgvFlag is the strict inverse of
239// brokerDaemonStartArgvFlag. An unknown, empty, or differently spelled value is
240// refused; a helper never maps an unrecognized word onto a start authorization.
241func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode, error) {
242 switch value {
243 case "existing-only":
244 return ports.BrokerDaemonExistingOnly, nil
245 case "if-needed":
246 return ports.BrokerDaemonStartIfNeeded, nil
247 default:
248 return 0, fmt.Errorf("unknown daemon start mode %q (want existing-only or if-needed)", value)
249 }
250}