service.go raw

   1  // Package tor provides Tor hidden service integration for the ORLY relay.
   2  // It spawns a tor subprocess with automatic configuration and manages
   3  // the hidden service lifecycle.
   4  package tor
   5  
   6  import (
   7  	"bufio"
   8  	"context"
   9  	"fmt"
  10  	"io"
  11  	"net"
  12  	"net/http"
  13  	"os"
  14  	"os/exec"
  15  	"path/filepath"
  16  	"strings"
  17  	"sync"
  18  	"time"
  19  
  20  	"github.com/gorilla/websocket"
  21  	"next.orly.dev/pkg/lol/chk"
  22  	"next.orly.dev/pkg/lol/log"
  23  )
  24  
  25  // Config holds Tor subprocess configuration.
  26  type Config struct {
  27  	// Port is the internal port for the hidden service
  28  	Port int
  29  	// DataDir is the directory for Tor data (torrc, keys, hostname, etc.)
  30  	DataDir string
  31  	// Binary is the path to the tor executable
  32  	Binary string
  33  	// SOCKSPort is the port for outbound SOCKS connections (0 = disabled)
  34  	SOCKSPort int
  35  	// Handler is the HTTP handler to serve (typically the main relay handler)
  36  	Handler http.Handler
  37  }
  38  
  39  // Service manages the Tor subprocess and hidden service listener.
  40  type Service struct {
  41  	cfg      *Config
  42  	listener net.Listener
  43  	server   *http.Server
  44  
  45  	// Tor subprocess
  46  	cmd    *exec.Cmd
  47  	stdout io.ReadCloser
  48  	stderr io.ReadCloser
  49  
  50  	// onionAddress is the detected .onion address
  51  	onionAddress string
  52  
  53  	// hostname watcher
  54  	hostnameWatcher *HostnameWatcher
  55  
  56  	ctx    context.Context
  57  	cancel context.CancelFunc
  58  	wg     sync.WaitGroup
  59  	mu     sync.RWMutex
  60  }
  61  
  62  // New creates a new Tor service with the given configuration.
  63  // Returns an error if the tor binary is not found.
  64  func New(cfg *Config) (*Service, error) {
  65  	if cfg.Port == 0 {
  66  		cfg.Port = 3336
  67  	}
  68  
  69  	// Find tor binary
  70  	binary := cfg.Binary
  71  	if binary == "" {
  72  		binary = "tor"
  73  	}
  74  
  75  	torPath, err := exec.LookPath(binary)
  76  	if err != nil {
  77  		return nil, fmt.Errorf("tor binary not found: %w (install tor or set ORLY_TOR_ENABLED=false)", err)
  78  	}
  79  	cfg.Binary = torPath
  80  
  81  	// Ensure data directory exists
  82  	if err := os.MkdirAll(cfg.DataDir, 0700); err != nil {
  83  		return nil, fmt.Errorf("failed to create Tor data directory: %w", err)
  84  	}
  85  
  86  	ctx, cancel := context.WithCancel(context.Background())
  87  
  88  	s := &Service{
  89  		cfg:    cfg,
  90  		ctx:    ctx,
  91  		cancel: cancel,
  92  	}
  93  
  94  	return s, nil
  95  }
  96  
  97  // generateTorrc creates the torrc configuration file.
  98  func (s *Service) generateTorrc() (string, error) {
  99  	torrcPath := filepath.Join(s.cfg.DataDir, "torrc")
 100  	hsDir := filepath.Join(s.cfg.DataDir, "hidden_service")
 101  
 102  	// Ensure hidden service directory exists with correct permissions
 103  	if err := os.MkdirAll(hsDir, 0700); err != nil {
 104  		return "", fmt.Errorf("failed to create hidden service directory: %w", err)
 105  	}
 106  
 107  	var sb strings.Builder
 108  	sb.WriteString("# ORLY Tor hidden service configuration\n")
 109  	sb.WriteString("# Auto-generated - do not edit\n\n")
 110  
 111  	// Data directory
 112  	sb.WriteString(fmt.Sprintf("DataDirectory %s/data\n", s.cfg.DataDir))
 113  
 114  	// Hidden service configuration
 115  	sb.WriteString(fmt.Sprintf("HiddenServiceDir %s\n", hsDir))
 116  	sb.WriteString(fmt.Sprintf("HiddenServicePort 80 127.0.0.1:%d\n", s.cfg.Port))
 117  
 118  	// Optional SOCKS port for outbound connections
 119  	if s.cfg.SOCKSPort > 0 {
 120  		sb.WriteString(fmt.Sprintf("SocksPort %d\n", s.cfg.SOCKSPort))
 121  	} else {
 122  		sb.WriteString("SocksPort 0\n")
 123  	}
 124  
 125  	// Disable unused features to reduce resource usage
 126  	sb.WriteString("ControlPort 0\n")
 127  	sb.WriteString("Log notice stdout\n")
 128  
 129  	// Write torrc
 130  	if err := os.WriteFile(torrcPath, []byte(sb.String()), 0600); err != nil {
 131  		return "", fmt.Errorf("failed to write torrc: %w", err)
 132  	}
 133  
 134  	// Create data subdirectory
 135  	if err := os.MkdirAll(filepath.Join(s.cfg.DataDir, "data"), 0700); err != nil {
 136  		return "", fmt.Errorf("failed to create Tor data subdirectory: %w", err)
 137  	}
 138  
 139  	return torrcPath, nil
 140  }
 141  
 142  // killOrphanedTor finds and kills any Tor process using our torrc that was
 143  // orphaned from a previous run. This prevents "another Tor process is running
 144  // with the same data directory" errors on startup.
 145  func (s *Service) killOrphanedTor(torrcPath string) {
 146  	// Look for tor processes using our specific torrc
 147  	out, err := exec.Command("pgrep", "-f", s.cfg.Binary+" -f "+torrcPath).Output()
 148  	if err != nil {
 149  		// pgrep returns exit 1 when no processes found — that's fine
 150  		return
 151  	}
 152  
 153  	pids := strings.TrimSpace(string(out))
 154  	if pids == "" {
 155  		return
 156  	}
 157  
 158  	log.W.F("found orphaned Tor process(es) using %s: %s — killing", torrcPath, pids)
 159  	for _, pid := range strings.Split(pids, "\n") {
 160  		pid = strings.TrimSpace(pid)
 161  		if pid == "" {
 162  			continue
 163  		}
 164  		killCmd := exec.Command("kill", "-9", pid)
 165  		if err := killCmd.Run(); err != nil {
 166  			log.W.F("failed to kill orphaned Tor PID %s: %v", pid, err)
 167  		} else {
 168  			log.T.F("killed orphaned Tor PID %s", pid)
 169  		}
 170  	}
 171  
 172  	// Give the OS a moment to release the lock file
 173  	time.Sleep(500 * time.Millisecond)
 174  }
 175  
 176  // Start spawns the Tor subprocess and initializes the listener.
 177  func (s *Service) Start() error {
 178  	// Generate torrc
 179  	torrcPath, err := s.generateTorrc()
 180  	if err != nil {
 181  		return err
 182  	}
 183  
 184  	// Kill any orphaned Tor processes from a previous run before starting
 185  	s.killOrphanedTor(torrcPath)
 186  
 187  	log.I.F("starting Tor subprocess with config: %s", torrcPath)
 188  
 189  	// Use exec.Command (not CommandContext) so we control the shutdown
 190  	// sequence ourselves. CommandContext sends SIGKILL immediately on
 191  	// context cancel, which races with our graceful SIGTERM shutdown.
 192  	s.cmd = exec.Command(s.cfg.Binary, "-f", torrcPath)
 193  
 194  	// Set platform-specific process attributes. On Linux this sets
 195  	// Pdeathsig to SIGKILL, ensuring the kernel kills the Tor subprocess
 196  	// if the parent relay process dies unexpectedly.
 197  	if attr := sysProcAttr(); attr != nil {
 198  		s.cmd.SysProcAttr = attr
 199  	}
 200  
 201  	// Capture stdout/stderr for logging
 202  	s.stdout, err = s.cmd.StdoutPipe()
 203  	if err != nil {
 204  		return fmt.Errorf("failed to get Tor stdout: %w", err)
 205  	}
 206  	s.stderr, err = s.cmd.StderrPipe()
 207  	if err != nil {
 208  		return fmt.Errorf("failed to get Tor stderr: %w", err)
 209  	}
 210  
 211  	if err := s.cmd.Start(); err != nil {
 212  		return fmt.Errorf("failed to start Tor: %w", err)
 213  	}
 214  
 215  	log.I.F("Tor subprocess started (PID %d)", s.cmd.Process.Pid)
 216  
 217  	// Log Tor output
 218  	s.wg.Add(2)
 219  	go s.logOutput("tor", s.stdout)
 220  	go s.logOutput("tor", s.stderr)
 221  
 222  	// Monitor subprocess and context cancellation
 223  	s.wg.Add(1)
 224  	go s.monitorProcess()
 225  
 226  	// Start hostname watcher
 227  	hsDir := filepath.Join(s.cfg.DataDir, "hidden_service")
 228  	s.hostnameWatcher = NewHostnameWatcher(hsDir)
 229  	s.hostnameWatcher.OnChange(func(addr string) {
 230  		s.mu.Lock()
 231  		s.onionAddress = addr
 232  		s.mu.Unlock()
 233  		log.I.F("Tor hidden service address: %s", addr)
 234  	})
 235  	if err := s.hostnameWatcher.Start(); err != nil {
 236  		log.W.F("failed to start hostname watcher: %v", err)
 237  	} else {
 238  		// Get initial address
 239  		if addr := s.hostnameWatcher.Address(); addr != "" {
 240  			s.mu.Lock()
 241  			s.onionAddress = addr
 242  			s.mu.Unlock()
 243  		}
 244  	}
 245  
 246  	// Create listener for the hidden service port
 247  	addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
 248  	s.listener, err = net.Listen("tcp", addr)
 249  	if chk.E(err) {
 250  		s.Stop()
 251  		return fmt.Errorf("failed to listen on %s: %w", addr, err)
 252  	}
 253  
 254  	// Create HTTP server with the provided handler
 255  	s.server = &http.Server{
 256  		Handler:      s.cfg.Handler,
 257  		ReadTimeout:  30 * time.Second,
 258  		WriteTimeout: 30 * time.Second,
 259  		IdleTimeout:  120 * time.Second,
 260  	}
 261  
 262  	// Start serving
 263  	s.wg.Add(1)
 264  	go func() {
 265  		defer s.wg.Done()
 266  		log.I.F("Tor hidden service listener started on %s", addr)
 267  		if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed {
 268  			log.E.F("Tor server error: %v", err)
 269  		}
 270  	}()
 271  
 272  	return nil
 273  }
 274  
 275  // logOutput reads from a pipe and logs each line.
 276  func (s *Service) logOutput(prefix string, r io.ReadCloser) {
 277  	defer s.wg.Done()
 278  	scanner := bufio.NewScanner(r)
 279  	for scanner.Scan() {
 280  		line := scanner.Text()
 281  		// Filter out common noise
 282  		if strings.Contains(line, "compression bomb") ||
 283  			strings.Contains(line, "abandoning stream") {
 284  			log.T.F("[%s] %s", prefix, line)
 285  		} else if strings.Contains(line, "Bootstrapped") {
 286  			log.D.F("[%s] %s", prefix, line)
 287  		} else if strings.Contains(line, "[warn]") || strings.Contains(line, "[err]") {
 288  			log.W.F("[%s] %s", prefix, line)
 289  		} else {
 290  			log.D.F("[%s] %s", prefix, line)
 291  		}
 292  	}
 293  }
 294  
 295  // monitorProcess watches the Tor subprocess and logs when it exits.
 296  func (s *Service) monitorProcess() {
 297  	defer s.wg.Done()
 298  	err := s.cmd.Wait()
 299  	if err != nil {
 300  		select {
 301  		case <-s.ctx.Done():
 302  			// Expected shutdown
 303  			log.D.F("Tor subprocess exited (shutdown)")
 304  		default:
 305  			log.E.F("Tor subprocess exited unexpectedly: %v", err)
 306  		}
 307  	} else {
 308  		log.I.F("Tor subprocess exited cleanly")
 309  	}
 310  }
 311  
 312  // Stop gracefully shuts down the Tor service.
 313  func (s *Service) Stop() error {
 314  	// Terminate Tor subprocess FIRST, before cancelling context.
 315  	// This avoids a race where context cancellation closes pipes/waitgroups
 316  	// while we're still trying to signal the process.
 317  	if s.cmd != nil && s.cmd.Process != nil {
 318  		pid := s.cmd.Process.Pid
 319  		log.D.F("sending SIGTERM to Tor subprocess (PID %d)", pid)
 320  		s.cmd.Process.Signal(os.Interrupt)
 321  
 322  		// Give it a few seconds to exit gracefully
 323  		done := make(chan struct{})
 324  		go func() {
 325  			s.cmd.Wait()
 326  			close(done)
 327  		}()
 328  
 329  		select {
 330  		case <-done:
 331  			log.D.F("Tor subprocess (PID %d) exited gracefully", pid)
 332  		case <-time.After(5 * time.Second):
 333  			log.W.F("Tor subprocess (PID %d) did not exit, sending SIGKILL", pid)
 334  			s.cmd.Process.Kill()
 335  		}
 336  	}
 337  
 338  	// Now cancel context to stop all goroutines
 339  	s.cancel()
 340  
 341  	// Stop hostname watcher
 342  	if s.hostnameWatcher != nil {
 343  		s.hostnameWatcher.Stop()
 344  	}
 345  
 346  	// Shutdown HTTP server
 347  	if s.server != nil {
 348  		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 349  		defer cancel()
 350  		if err := s.server.Shutdown(ctx); chk.E(err) {
 351  			// Continue shutdown anyway
 352  		}
 353  	}
 354  
 355  	// Close listener
 356  	if s.listener != nil {
 357  		s.listener.Close()
 358  	}
 359  
 360  	s.wg.Wait()
 361  	log.I.F("Tor service stopped")
 362  	return nil
 363  }
 364  
 365  // OnionAddress returns the current .onion address.
 366  func (s *Service) OnionAddress() string {
 367  	s.mu.RLock()
 368  	defer s.mu.RUnlock()
 369  	return s.onionAddress
 370  }
 371  
 372  // OnionWSAddress returns the full WebSocket URL for the hidden service.
 373  // Format: ws://<address>.onion/
 374  func (s *Service) OnionWSAddress() string {
 375  	addr := s.OnionAddress()
 376  	if addr == "" {
 377  		return ""
 378  	}
 379  	// Ensure address ends with .onion
 380  	if len(addr) >= 6 && addr[len(addr)-6:] != ".onion" {
 381  		addr = addr + ".onion"
 382  	}
 383  	return "ws://" + addr + "/"
 384  }
 385  
 386  // IsRunning returns whether the Tor service is currently running.
 387  func (s *Service) IsRunning() bool {
 388  	return s.listener != nil && s.cmd != nil && s.cmd.Process != nil
 389  }
 390  
 391  // Upgrader returns a WebSocket upgrader configured for Tor connections.
 392  // Tor connections don't send Origin headers, so we skip origin check.
 393  func (s *Service) Upgrader() *websocket.Upgrader {
 394  	return &websocket.Upgrader{
 395  		ReadBufferSize:  1024,
 396  		WriteBufferSize: 1024,
 397  		CheckOrigin: func(r *http.Request) bool {
 398  			return true // Allow all origins for Tor
 399  		},
 400  	}
 401  }
 402  
 403  // DataDir returns the Tor data directory path.
 404  func (s *Service) DataDir() string {
 405  	return s.cfg.DataDir
 406  }
 407  
 408  // HiddenServiceDir returns the hidden service directory path.
 409  func (s *Service) HiddenServiceDir() string {
 410  	return filepath.Join(s.cfg.DataDir, "hidden_service")
 411  }
 412