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