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