From 1a704d704da46e6eb7b396f6c9c1f00d0768e3f3 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 2 Aug 2026 16:41:46 +0400 Subject: [PATCH] ipc: a seam address that can name a transport (V-484) internal/netaddr parses a daemon seam address and dials or binds it. A scheme-less address is unix and behaves exactly as it does today: same 0700 parent dir, same 0600 socket, same bytes on the wire. tcp://host:port is the new option, and it is what lets a module live on another host. Over tcp the filesystem permission that authenticated the unix socket is gone, and what crosses this seam is audio of the owner speaking. So a tcp listener requires a shared token, checked in constant time before the first protocol frame is read, and a peer that fails is dropped without taking the listener down with it. --- internal/netaddr/netaddr.go | 277 ++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 internal/netaddr/netaddr.go diff --git a/internal/netaddr/netaddr.go b/internal/netaddr/netaddr.go new file mode 100644 index 0000000..11bb4bd --- /dev/null +++ b/internal/netaddr/netaddr.go @@ -0,0 +1,277 @@ +// Package netaddr parses a daemon seam address and dials or binds it. +// +// Every seam between Maven's daemons used to be a unix socket with the +// network hardcoded at the call site — two dials in internal/ipc, one listen, +// and the same pair again in internal/worker. That is correct for co-located +// daemons and it is the reason a module cannot live on another host. This +// package moves the choice into the address string so a deploy picks the +// transport, not a recompile: +// +// /run/maven/stt.sock unix (the default, unchanged) +// unix:///run/maven/stt.sock unix (explicit, same thing) +// tcp://workstation:9310?token=hunter2 tcp +// +// A scheme-less address is unix and behaves exactly as it did before this +// package existed: same 0700 parent dir, same 0600 socket, same bytes on the +// wire with no handshake in front of them. +// +// Over TCP the filesystem permission that authenticated the unix socket is +// gone, and what crosses this seam is audio of the owner speaking and the +// text of his turns. So a TCP seam carries a shared token, checked before the +// first protocol frame is read. Wireguard is supported underneath and is not +// required. +package netaddr + +import ( + "crypto/subtle" + "errors" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +// ErrUnauthorized — the peer presented a token the listener does not accept, +// or presented none when one is required. +var ErrUnauthorized = errors.New("netaddr: unauthorized") + +// Addr is a parsed seam endpoint. +type Addr struct { + // Network is "unix" or "tcp". + Network string + // Address is the socket path (unix) or host:port (tcp). + Address string + // Token is the shared secret for a tcp seam. Empty for unix, where the + // filesystem does the same job. + Token string +} + +// String renders the address for logs and errors. The token is never included. +func (a Addr) String() string { + if a.Network == "unix" { + return a.Address + } + return a.Network + "://" + a.Address +} + +// IsUnix reports whether this seam is a unix socket, and so is local, is +// authenticated by file permissions, and needs no handshake. +func (a Addr) IsUnix() bool { return a.Network == "unix" } + +// Parse reads a seam address. Anything without a "scheme://" prefix is a unix +// socket path, which keeps every existing config and every default working +// untouched. +func Parse(s string) (Addr, error) { + if !strings.Contains(s, "://") { + return Addr{Network: "unix", Address: s}, nil + } + u, err := url.Parse(s) + if err != nil { + return Addr{}, fmt.Errorf("netaddr: parse %q: %w", s, err) + } + switch u.Scheme { + case "unix": + return Addr{Network: "unix", Address: u.Path}, nil + case "tcp": + if u.Host == "" { + return Addr{}, fmt.Errorf("netaddr: %q has no host:port", s) + } + return Addr{Network: "tcp", Address: u.Host, Token: u.Query().Get("token")}, nil + default: + return Addr{}, fmt.Errorf("netaddr: unsupported scheme %q", u.Scheme) + } +} + +// MustParse is Parse for a literal known good at compile time. It panics on a +// bad address, so use it in tests and constants, never on config input. +func MustParse(s string) Addr { + a, err := Parse(s) + if err != nil { + panic(err) + } + return a +} + +// handshakeTimeout bounds the token exchange. A peer that cannot write one +// short line in this long is not going to serve a turn either. +const handshakeTimeout = 5 * time.Second + +// greeting prefixes the token line. Versioned so a later mTLS seam can be +// told apart from this one on the wire. +const greeting = "MAVEN1 " + +// Dial connects to a. On a tcp seam it sends the token and waits for the +// listener to accept it, so a returned conn is already authorized and the +// caller can write its first protocol frame. +func Dial(a Addr) (net.Conn, error) { + return DialTimeout(a, 0) +} + +// DialTimeout is Dial with a bound on the connect. Zero means the operating +// system default. The token exchange gets its own timeout either way. +func DialTimeout(a Addr, timeout time.Duration) (net.Conn, error) { + var c net.Conn + var err error + if timeout > 0 { + c, err = net.DialTimeout(a.Network, a.Address, timeout) + } else { + c, err = net.Dial(a.Network, a.Address) + } + if err != nil { + return nil, err + } + if a.IsUnix() { + return c, nil + } + if err := clientHandshake(c, a.Token); err != nil { + _ = c.Close() + return nil, err + } + return c, nil +} + +func clientHandshake(c net.Conn, token string) error { + _ = c.SetDeadline(time.Now().Add(handshakeTimeout)) + defer c.SetDeadline(time.Time{}) + if _, err := c.Write([]byte(greeting + token + "\n")); err != nil { + return fmt.Errorf("netaddr: send token: %w", err) + } + var reply [3]byte + if _, err := readFull(c, reply[:]); err != nil { + return fmt.Errorf("%w: %v", ErrUnauthorized, err) + } + if string(reply[:]) != "ok\n" { + return ErrUnauthorized + } + return nil +} + +// Listener wraps a net.Listener so Accept performs the token check for a tcp +// seam. A connection that fails the check is closed and never surfaces, so +// the protocol above this layer only ever sees authorized peers. +type Listener struct { + net.Listener + addr Addr +} + +// Accept returns the next authorized connection. Unauthorized peers are +// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a +// reason to stop serving. +func (l *Listener) Accept() (net.Conn, error) { + for { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + if l.addr.IsUnix() { + return c, nil + } + if err := serverHandshake(c, l.addr.Token); err != nil { + _ = c.Close() + continue + } + return c, nil + } +} + +// Addr reports the parsed seam address this listener was built from. +func (l *Listener) SeamAddr() Addr { return l.addr } + +func serverHandshake(c net.Conn, want string) error { + _ = c.SetDeadline(time.Now().Add(handshakeTimeout)) + defer c.SetDeadline(time.Time{}) + // The line is bounded: greeting, token, newline. Read a byte at a time so + // nothing of the first protocol frame is consumed when the token is short. + line := make([]byte, 0, 128) + var b [1]byte + for { + if _, err := readFull(c, b[:]); err != nil { + return err + } + if b[0] == '\n' { + break + } + line = append(line, b[0]) + if len(line) > 512 { + return ErrUnauthorized + } + } + got, ok := strings.CutPrefix(string(line), greeting) + if !ok { + return ErrUnauthorized + } + if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 { + return ErrUnauthorized + } + if _, err := c.Write([]byte("ok\n")); err != nil { + return err + } + return nil +} + +func readFull(c net.Conn, p []byte) (int, error) { + n := 0 + for n < len(p) { + m, err := c.Read(p[n:]) + n += m + if err != nil { + return n, err + } + } + return n, nil +} + +// Listen binds a. A unix seam gets the perms it has always had: parent dir +// 0700, socket 0600, and any stale socket from a crashed daemon removed +// first. A tcp seam must carry a token, because there is no filesystem to +// stand in for one. +func Listen(a Addr) (*Listener, error) { + if a.IsUnix() { + ln, err := listenUnix(a.Address) + if err != nil { + return nil, err + } + return &Listener{Listener: ln, addr: a}, nil + } + if a.Token == "" { + return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a) + } + ln, err := net.Listen("tcp", a.Address) + if err != nil { + return nil, fmt.Errorf("netaddr: listen %s: %w", a, err) + } + return &Listener{Listener: ln, addr: a}, nil +} + +func listenUnix(path string) (net.Listener, error) { + _ = os.Remove(path) // stale socket from a crashed daemon; ignore missing + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("netaddr: mkdir socket dir: %w", err) + } + // umask could widen the perms on socket creation; tighten then chmod to + // be explicit. 0600 ⇒ read+write by owner only. + oldMask := unix.Umask(0o077) + ln, err := net.Listen("unix", path) + unix.Umask(oldMask) + if err != nil { + return nil, fmt.Errorf("netaddr: listen %s: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + _ = ln.Close() + _ = os.Remove(path) + return nil, fmt.Errorf("netaddr: chmod socket: %w", err) + } + return ln, nil +} + +// Cleanup removes the socket file behind a unix seam. It is a no-op for tcp. +func Cleanup(a Addr) { + if a.IsUnix() && a.Address != "" { + _ = os.Remove(a.Address) + } +}