Add Tor hidden service support and fallback relay profile fetching (v0.46.0)
Some checks failed
Go / build-and-release (push) Has been cancelled

- Add pkg/tor package for Tor hidden service integration
- Add Tor config options: ORLY_TOR_ENABLED, ORLY_TOR_PORT, ORLY_TOR_HS_DIR, ORLY_TOR_ONION_ADDRESS
- Extend NIP-11 relay info with addresses field for .onion URLs
- Add fallback relays (Damus, nos.lol, nostr.band, purplepag.es) for profile lookups
- Refactor profile fetching to try local relay first, then fallback relays
- Add Tor setup documentation and deployment scripts

Files modified:
- app/config/config.go: Add Tor configuration options
- app/handle-relayinfo.go: Add ExtendedRelayInfo with addresses field
- app/main.go: Initialize and manage Tor service lifecycle
- app/server.go: Add torService field to Server struct
- app/web/src/constants.js: Add FALLBACK_RELAYS
- app/web/src/nostr.js: Add fallback relay profile fetching
- pkg/tor/: New package for Tor hidden service management
- docs/TOR_SETUP.md: Documentation for Tor configuration
- deploy/orly-tor.service: Systemd service for Tor integration
- scripts/tor-*.sh: Setup scripts for Tor development and production

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
woikos
2026-01-03 05:50:03 +01:00
parent 6056446a73
commit 25d087697e
16 changed files with 1293 additions and 58 deletions

116
pkg/tor/hostname.go Normal file
View File

@@ -0,0 +1,116 @@
package tor
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// HostnameWatcher watches the Tor hidden service hostname file for changes.
// When Tor creates or updates a hidden service, it writes the .onion address
// to a file called "hostname" in the HiddenServiceDir.
type HostnameWatcher struct {
hsDir string
address string
onChange func(string)
stopCh chan struct{}
mu sync.RWMutex
}
// NewHostnameWatcher creates a new hostname watcher for the given HiddenServiceDir.
func NewHostnameWatcher(hsDir string) *HostnameWatcher {
return &HostnameWatcher{
hsDir: hsDir,
stopCh: make(chan struct{}),
}
}
// OnChange sets a callback function to be called when the hostname changes.
func (w *HostnameWatcher) OnChange(fn func(string)) {
w.mu.Lock()
w.onChange = fn
w.mu.Unlock()
}
// Start begins watching the hostname file.
func (w *HostnameWatcher) Start() error {
// Try to read immediately
if err := w.readHostname(); err != nil {
log.D.F("hostname file not yet available: %v", err)
}
// Start polling goroutine
go w.poll()
return nil
}
// Stop stops the hostname watcher.
func (w *HostnameWatcher) Stop() {
close(w.stopCh)
}
// Address returns the current .onion address.
func (w *HostnameWatcher) Address() string {
w.mu.RLock()
defer w.mu.RUnlock()
return w.address
}
// poll periodically checks the hostname file for changes.
func (w *HostnameWatcher) poll() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
if err := w.readHostname(); err != nil {
// Only log at trace level to avoid spam
log.T.F("hostname read: %v", err)
}
}
}
}
// readHostname reads the hostname file and updates the address if changed.
func (w *HostnameWatcher) readHostname() error {
path := filepath.Join(w.hsDir, "hostname")
data, err := os.ReadFile(path)
if chk.T(err) {
return err
}
// Parse the address (file contains "xyz.onion\n")
addr := strings.TrimSpace(string(data))
if addr == "" {
return nil
}
w.mu.Lock()
oldAddr := w.address
w.address = addr
onChange := w.onChange
w.mu.Unlock()
// Call callback if address changed
if addr != oldAddr && onChange != nil {
onChange(addr)
}
return nil
}
// HostnameFilePath returns the path to the hostname file.
func (w *HostnameWatcher) HostnameFilePath() string {
return filepath.Join(w.hsDir, "hostname")
}

188
pkg/tor/service.go Normal file
View File

@@ -0,0 +1,188 @@
// Package tor provides Tor hidden service integration for the ORLY relay.
// It manages a listener on a dedicated port that receives traffic forwarded
// from the Tor daemon, and exposes the .onion address for NIP-11 integration.
package tor
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// Config holds Tor hidden service configuration.
type Config struct {
// Port is the internal port that Tor forwards .onion traffic to
Port int
// HSDir is the Tor HiddenServiceDir path to read .onion hostname from
HSDir string
// OnionAddress is an optional manual override for the .onion address
OnionAddress string
// Handler is the HTTP handler to serve (typically the main relay handler)
Handler http.Handler
}
// Service manages the Tor hidden service listener.
type Service struct {
cfg *Config
listener net.Listener
server *http.Server
// onionAddress is the detected or configured .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.
func New(cfg *Config) (*Service, error) {
if cfg.Port == 0 {
cfg.Port = 3336
}
ctx, cancel := context.WithCancel(context.Background())
s := &Service{
cfg: cfg,
ctx: ctx,
cancel: cancel,
}
// If manual address provided, use it
if cfg.OnionAddress != "" {
s.onionAddress = cfg.OnionAddress
log.I.F("using configured .onion address: %s", s.onionAddress)
}
return s, nil
}
// Start initializes the Tor listener and hostname watcher.
func (s *Service) Start() error {
// Start hostname watcher if HSDir is configured
if s.cfg.HSDir != "" {
s.hostnameWatcher = NewHostnameWatcher(s.cfg.HSDir)
s.hostnameWatcher.OnChange(func(addr string) {
s.mu.Lock()
s.onionAddress = addr
s.mu.Unlock()
log.I.F("detected .onion 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
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
var err error
s.listener, err = net.Listen("tcp", addr)
if chk.E(err) {
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 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
}
// 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) {
return err
}
}
// Close listener
if s.listener != nil {
s.listener.Close()
}
s.wg.Wait()
log.I.F("Tor service stopped")
return nil
}
// OnionAddress returns the current .onion address (without .onion suffix).
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
}
// 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
},
}
}

View File

@@ -1 +1 @@
v0.45.0
v0.46.0