hostname.go raw
1 package tor
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "sync"
8 "time"
9
10 "next.orly.dev/pkg/lol/chk"
11 "next.orly.dev/pkg/lol/log"
12 )
13
14 // HostnameWatcher watches the Tor hidden service hostname file for changes.
15 // When Tor creates or updates a hidden service, it writes the .onion address
16 // to a file called "hostname" in the HiddenServiceDir.
17 type HostnameWatcher struct {
18 hsDir string
19 address string
20 onChange func(string)
21
22 stopCh chan struct{}
23 mu sync.RWMutex
24 }
25
26 // NewHostnameWatcher creates a new hostname watcher for the given HiddenServiceDir.
27 func NewHostnameWatcher(hsDir string) *HostnameWatcher {
28 return &HostnameWatcher{
29 hsDir: hsDir,
30 stopCh: make(chan struct{}),
31 }
32 }
33
34 // OnChange sets a callback function to be called when the hostname changes.
35 func (w *HostnameWatcher) OnChange(fn func(string)) {
36 w.mu.Lock()
37 w.onChange = fn
38 w.mu.Unlock()
39 }
40
41 // Start begins watching the hostname file.
42 func (w *HostnameWatcher) Start() error {
43 // Try to read immediately
44 if err := w.readHostname(); err != nil {
45 log.D.F("hostname file not yet available: %v", err)
46 }
47
48 // Start polling goroutine
49 go w.poll()
50
51 return nil
52 }
53
54 // Stop stops the hostname watcher.
55 func (w *HostnameWatcher) Stop() {
56 close(w.stopCh)
57 }
58
59 // Address returns the current .onion address.
60 func (w *HostnameWatcher) Address() string {
61 w.mu.RLock()
62 defer w.mu.RUnlock()
63 return w.address
64 }
65
66 // poll periodically checks the hostname file for changes.
67 func (w *HostnameWatcher) poll() {
68 ticker := time.NewTicker(5 * time.Second)
69 defer ticker.Stop()
70
71 for {
72 select {
73 case <-w.stopCh:
74 return
75 case <-ticker.C:
76 if err := w.readHostname(); err != nil {
77 // Only log at trace level to avoid spam
78 log.T.F("hostname read: %v", err)
79 }
80 }
81 }
82 }
83
84 // readHostname reads the hostname file and updates the address if changed.
85 func (w *HostnameWatcher) readHostname() error {
86 path := filepath.Join(w.hsDir, "hostname")
87
88 data, err := os.ReadFile(path)
89 if chk.T(err) {
90 return err
91 }
92
93 // Parse the address (file contains "xyz.onion\n")
94 addr := strings.TrimSpace(string(data))
95 if addr == "" {
96 return nil
97 }
98
99 w.mu.Lock()
100 oldAddr := w.address
101 w.address = addr
102 onChange := w.onChange
103 w.mu.Unlock()
104
105 // Call callback if address changed
106 if addr != oldAddr && onChange != nil {
107 onChange(addr)
108 }
109
110 return nil
111 }
112
113 // HostnameFilePath returns the path to the hostname file.
114 func (w *HostnameWatcher) HostnameFilePath() string {
115 return filepath.Join(w.hsDir, "hostname")
116 }
117