Some checks failed
Go / build-and-release (push) Has been cancelled
- Spawn tor binary as subprocess instead of requiring external daemon - Auto-generate torrc in $ORLY_DATA_DIR/tor/ (userspace, no root) - Enable Tor by default; gracefully disable if tor binary not found - Add ORLY_TOR_BINARY and ORLY_TOR_SOCKS config options - Remove external Tor setup scripts and documentation Files modified: - app/config/config.go: New subprocess-based Tor config options - app/main.go: Updated Tor initialization for new config - pkg/tor/service.go: Rewritten for subprocess management - Removed: deploy/orly-tor.service, docs/TOR_SETUP.md, scripts/tor-*.sh 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
359 lines
8.9 KiB
Go
359 lines
8.9 KiB
Go
// Package tor provides Tor hidden service integration for the ORLY relay.
|
|
// It spawns a tor subprocess with automatic configuration and manages
|
|
// the hidden service lifecycle.
|
|
package tor
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"lol.mleku.dev/chk"
|
|
"lol.mleku.dev/log"
|
|
)
|
|
|
|
// Config holds Tor subprocess configuration.
|
|
type Config struct {
|
|
// Port is the internal port for the hidden service
|
|
Port int
|
|
// DataDir is the directory for Tor data (torrc, keys, hostname, etc.)
|
|
DataDir string
|
|
// Binary is the path to the tor executable
|
|
Binary string
|
|
// SOCKSPort is the port for outbound SOCKS connections (0 = disabled)
|
|
SOCKSPort int
|
|
// Handler is the HTTP handler to serve (typically the main relay handler)
|
|
Handler http.Handler
|
|
}
|
|
|
|
// Service manages the Tor subprocess and hidden service listener.
|
|
type Service struct {
|
|
cfg *Config
|
|
listener net.Listener
|
|
server *http.Server
|
|
|
|
// Tor subprocess
|
|
cmd *exec.Cmd
|
|
stdout io.ReadCloser
|
|
stderr io.ReadCloser
|
|
|
|
// onionAddress is the detected .onion address
|
|
onionAddress string
|
|
|
|
// hostname watcher
|
|
hostnameWatcher *HostnameWatcher
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// New creates a new Tor service with the given configuration.
|
|
// Returns an error if the tor binary is not found.
|
|
func New(cfg *Config) (*Service, error) {
|
|
if cfg.Port == 0 {
|
|
cfg.Port = 3336
|
|
}
|
|
|
|
// Find tor binary
|
|
binary := cfg.Binary
|
|
if binary == "" {
|
|
binary = "tor"
|
|
}
|
|
|
|
torPath, err := exec.LookPath(binary)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tor binary not found: %w (install tor or set ORLY_TOR_ENABLED=false)", err)
|
|
}
|
|
cfg.Binary = torPath
|
|
|
|
// Ensure data directory exists
|
|
if err := os.MkdirAll(cfg.DataDir, 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create Tor data directory: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
s := &Service{
|
|
cfg: cfg,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// generateTorrc creates the torrc configuration file.
|
|
func (s *Service) generateTorrc() (string, error) {
|
|
torrcPath := filepath.Join(s.cfg.DataDir, "torrc")
|
|
hsDir := filepath.Join(s.cfg.DataDir, "hidden_service")
|
|
|
|
// Ensure hidden service directory exists with correct permissions
|
|
if err := os.MkdirAll(hsDir, 0700); err != nil {
|
|
return "", fmt.Errorf("failed to create hidden service directory: %w", err)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("# ORLY Tor hidden service configuration\n")
|
|
sb.WriteString("# Auto-generated - do not edit\n\n")
|
|
|
|
// Data directory
|
|
sb.WriteString(fmt.Sprintf("DataDirectory %s/data\n", s.cfg.DataDir))
|
|
|
|
// Hidden service configuration
|
|
sb.WriteString(fmt.Sprintf("HiddenServiceDir %s\n", hsDir))
|
|
sb.WriteString(fmt.Sprintf("HiddenServicePort 80 127.0.0.1:%d\n", s.cfg.Port))
|
|
|
|
// Optional SOCKS port for outbound connections
|
|
if s.cfg.SOCKSPort > 0 {
|
|
sb.WriteString(fmt.Sprintf("SocksPort %d\n", s.cfg.SOCKSPort))
|
|
} else {
|
|
sb.WriteString("SocksPort 0\n")
|
|
}
|
|
|
|
// Disable unused features to reduce resource usage
|
|
sb.WriteString("ControlPort 0\n")
|
|
sb.WriteString("Log notice stdout\n")
|
|
|
|
// Write torrc
|
|
if err := os.WriteFile(torrcPath, []byte(sb.String()), 0600); err != nil {
|
|
return "", fmt.Errorf("failed to write torrc: %w", err)
|
|
}
|
|
|
|
// Create data subdirectory
|
|
if err := os.MkdirAll(filepath.Join(s.cfg.DataDir, "data"), 0700); err != nil {
|
|
return "", fmt.Errorf("failed to create Tor data subdirectory: %w", err)
|
|
}
|
|
|
|
return torrcPath, nil
|
|
}
|
|
|
|
// Start spawns the Tor subprocess and initializes the listener.
|
|
func (s *Service) Start() error {
|
|
// Generate torrc
|
|
torrcPath, err := s.generateTorrc()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.I.F("starting Tor subprocess with config: %s", torrcPath)
|
|
|
|
// Start tor subprocess
|
|
s.cmd = exec.CommandContext(s.ctx, s.cfg.Binary, "-f", torrcPath)
|
|
|
|
// Capture stdout/stderr for logging
|
|
s.stdout, err = s.cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get Tor stdout: %w", err)
|
|
}
|
|
s.stderr, err = s.cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get Tor stderr: %w", err)
|
|
}
|
|
|
|
if err := s.cmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start Tor: %w", err)
|
|
}
|
|
|
|
log.I.F("Tor subprocess started (PID %d)", s.cmd.Process.Pid)
|
|
|
|
// Log Tor output
|
|
s.wg.Add(2)
|
|
go s.logOutput("tor", s.stdout)
|
|
go s.logOutput("tor", s.stderr)
|
|
|
|
// Monitor subprocess
|
|
s.wg.Add(1)
|
|
go s.monitorProcess()
|
|
|
|
// Start hostname watcher
|
|
hsDir := filepath.Join(s.cfg.DataDir, "hidden_service")
|
|
s.hostnameWatcher = NewHostnameWatcher(hsDir)
|
|
s.hostnameWatcher.OnChange(func(addr string) {
|
|
s.mu.Lock()
|
|
s.onionAddress = addr
|
|
s.mu.Unlock()
|
|
log.I.F("Tor hidden service address: %s", addr)
|
|
})
|
|
if err := s.hostnameWatcher.Start(); err != nil {
|
|
log.W.F("failed to start hostname watcher: %v", err)
|
|
} else {
|
|
// Get initial address
|
|
if addr := s.hostnameWatcher.Address(); addr != "" {
|
|
s.mu.Lock()
|
|
s.onionAddress = addr
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Create listener for the hidden service port
|
|
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
|
|
s.listener, err = net.Listen("tcp", addr)
|
|
if chk.E(err) {
|
|
s.Stop()
|
|
return fmt.Errorf("failed to listen on %s: %w", addr, err)
|
|
}
|
|
|
|
// Create HTTP server with the provided handler
|
|
s.server = &http.Server{
|
|
Handler: s.cfg.Handler,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
// Start serving
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
log.I.F("Tor hidden service listener started on %s", addr)
|
|
if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed {
|
|
log.E.F("Tor server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// logOutput reads from a pipe and logs each line.
|
|
func (s *Service) logOutput(prefix string, r io.ReadCloser) {
|
|
defer s.wg.Done()
|
|
scanner := bufio.NewScanner(r)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
// Filter out common noise
|
|
if strings.Contains(line, "Bootstrapped") {
|
|
log.I.F("[%s] %s", prefix, line)
|
|
} else if strings.Contains(line, "[warn]") || strings.Contains(line, "[err]") {
|
|
log.W.F("[%s] %s", prefix, line)
|
|
} else {
|
|
log.D.F("[%s] %s", prefix, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// monitorProcess watches the Tor subprocess and logs when it exits.
|
|
func (s *Service) monitorProcess() {
|
|
defer s.wg.Done()
|
|
err := s.cmd.Wait()
|
|
if err != nil {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
// Expected shutdown
|
|
log.D.F("Tor subprocess exited (shutdown)")
|
|
default:
|
|
log.E.F("Tor subprocess exited unexpectedly: %v", err)
|
|
}
|
|
} else {
|
|
log.I.F("Tor subprocess exited cleanly")
|
|
}
|
|
}
|
|
|
|
// Stop gracefully shuts down the Tor service.
|
|
func (s *Service) Stop() error {
|
|
s.cancel()
|
|
|
|
// Stop hostname watcher
|
|
if s.hostnameWatcher != nil {
|
|
s.hostnameWatcher.Stop()
|
|
}
|
|
|
|
// Shutdown HTTP server
|
|
if s.server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.server.Shutdown(ctx); chk.E(err) {
|
|
// Continue shutdown anyway
|
|
}
|
|
}
|
|
|
|
// Close listener
|
|
if s.listener != nil {
|
|
s.listener.Close()
|
|
}
|
|
|
|
// Terminate Tor subprocess
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
log.D.F("sending SIGTERM to Tor subprocess (PID %d)", s.cmd.Process.Pid)
|
|
s.cmd.Process.Signal(os.Interrupt)
|
|
|
|
// Give it a few seconds to exit gracefully
|
|
done := make(chan struct{})
|
|
go func() {
|
|
s.cmd.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
log.D.F("Tor subprocess exited gracefully")
|
|
case <-time.After(5 * time.Second):
|
|
log.W.F("Tor subprocess did not exit, killing")
|
|
s.cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
s.wg.Wait()
|
|
log.I.F("Tor service stopped")
|
|
return nil
|
|
}
|
|
|
|
// OnionAddress returns the current .onion address.
|
|
func (s *Service) OnionAddress() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.onionAddress
|
|
}
|
|
|
|
// OnionWSAddress returns the full WebSocket URL for the hidden service.
|
|
// Format: ws://<address>.onion/
|
|
func (s *Service) OnionWSAddress() string {
|
|
addr := s.OnionAddress()
|
|
if addr == "" {
|
|
return ""
|
|
}
|
|
// Ensure address ends with .onion
|
|
if len(addr) >= 6 && addr[len(addr)-6:] != ".onion" {
|
|
addr = addr + ".onion"
|
|
}
|
|
return "ws://" + addr + "/"
|
|
}
|
|
|
|
// IsRunning returns whether the Tor service is currently running.
|
|
func (s *Service) IsRunning() bool {
|
|
return s.listener != nil && s.cmd != nil && s.cmd.Process != nil
|
|
}
|
|
|
|
// Upgrader returns a WebSocket upgrader configured for Tor connections.
|
|
// Tor connections don't send Origin headers, so we skip origin check.
|
|
func (s *Service) Upgrader() *websocket.Upgrader {
|
|
return &websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
return true // Allow all origins for Tor
|
|
},
|
|
}
|
|
}
|
|
|
|
// DataDir returns the Tor data directory path.
|
|
func (s *Service) DataDir() string {
|
|
return s.cfg.DataDir
|
|
}
|
|
|
|
// HiddenServiceDir returns the hidden service directory path.
|
|
func (s *Service) HiddenServiceDir() string {
|
|
return filepath.Join(s.cfg.DataDir, "hidden_service")
|
|
}
|