// Package tailscale manages an embedded Tailscale node via tsnet. // The node connects to a tailnet using an auth key and exposes a // net.Listener that the rest of the app can use for local-only HTTP. package tailscale import ( "context" "fmt" "log" "net" "net/netip" "os" "path/filepath" "tailscale.com/tsnet" ) // Node wraps a tsnet.Server and provides helpers. type Node struct { srv *tsnet.Server hostname string } // Config holds settings for the embedded Tailscale node. type Config struct { // Hostname is the name this device will appear as on the tailnet. Hostname string // AuthKey is a Tailscale auth key (reusable or ephemeral). // If empty the node will print a login URL to stderr. AuthKey string // StateDir is where tsnet persists its state. // Defaults to /moonrelay/tsnet. StateDir string } // New creates and starts a new embedded Tailscale node. func New(ctx context.Context, cfg Config) (*Node, error) { stateDir := cfg.StateDir if stateDir == "" { // In Docker the TS_STATE_DIR env var points to the mounted volume. // Fall back to the user config dir for native / dev installs. if envDir := os.Getenv("TS_STATE_DIR"); envDir != "" { stateDir = envDir } else { cfgDir, err := os.UserConfigDir() if err != nil { return nil, fmt.Errorf("finding config dir: %w", err) } stateDir = filepath.Join(cfgDir, "moonrelay", "tsnet") } } if err := os.MkdirAll(stateDir, 0700); err != nil { return nil, fmt.Errorf("creating state dir: %w", err) } s := &tsnet.Server{ Hostname: cfg.Hostname, AuthKey: cfg.AuthKey, Dir: stateDir, Logf: func(format string, args ...any) { log.Printf("[tsnet] "+format, args...) }, } // Bring the node up — this blocks until connected (or auth required). lc, err := s.LocalClient() if err != nil { return nil, fmt.Errorf("getting local client: %w", err) } // Wait for the node to be ready. if _, err := lc.Status(ctx); err != nil { // Not fatal — node may need interactive login. log.Printf("tailscale status: %v", err) } return &Node{srv: s, hostname: cfg.Hostname}, nil } // Listen returns a net.Listener bound on the Tailscale interface. func (n *Node) Listen(network, addr string) (net.Listener, error) { return n.srv.Listen(network, addr) } // LocalAddr returns the Tailscale IPv4 address of this node, or empty string. func (n *Node) LocalAddr() string { lc, err := n.srv.LocalClient() if err != nil { return "" } st, err := lc.Status(context.Background()) if err != nil { return "" } for _, pfx := range st.TailscaleIPs { if pfx.Is4() { addr := netip.AddrFrom4(pfx.As4()) return addr.String() } } return "" } // Close shuts down the node. func (n *Node) Close() error { return n.srv.Close() }