Add Tor hidden service support and fallback relay profile fetching (v0.46.0)
Some checks failed
Go / build-and-release (push) Has been cancelled
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:
116
pkg/tor/hostname.go
Normal file
116
pkg/tor/hostname.go
Normal 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
188
pkg/tor/service.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v0.45.0
|
||||
v0.46.0
|
||||
|
||||
Reference in New Issue
Block a user