105 lines
2.7 KiB
Go
105 lines
2.7 KiB
Go
// 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()
|
|
}
|