From 58c55c436fb4ec104dd8e2c0f62a55b129ff142d Mon Sep 17 00:00:00 2001 From: zgaetano Date: Tue, 31 Mar 2026 15:29:58 -0400 Subject: [PATCH] Add backend/internal/tailscale/tailscale.go --- backend/internal/tailscale/tailscale.go | 105 ++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 backend/internal/tailscale/tailscale.go diff --git a/backend/internal/tailscale/tailscale.go b/backend/internal/tailscale/tailscale.go new file mode 100644 index 0000000..bc495f2 --- /dev/null +++ b/backend/internal/tailscale/tailscale.go @@ -0,0 +1,105 @@ +// 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() +}