// 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) } }