moonlight-relay-server/backend/internal/tailscale/tailscale.go

106 lines
2.7 KiB
Go
Raw Normal View History

// 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 <user config dir>/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()
}