Browse Source

Add Tor hidden service support and fallback relay profile fetching (v0.46.0)

- 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>
main
woikos 1 week ago
parent
commit
25d087697e
No known key found for this signature in database
  1. 21
      app/config/config.go
  2. 35
      app/handle-relayinfo.go
  3. 33
      app/main.go
  4. 4
      app/server.go
  5. 1
      app/web/bun.lock
  6. 4
      app/web/dist/bundle.js
  7. 2
      app/web/dist/bundle.js.map
  8. 8
      app/web/src/constants.js
  9. 152
      app/web/src/nostr.js
  10. 73
      deploy/orly-tor.service
  11. 294
      docs/TOR_SETUP.md
  12. 116
      pkg/tor/hostname.go
  13. 188
      pkg/tor/service.go
  14. 2
      pkg/version/version
  15. 217
      scripts/tor-dev-setup.sh
  16. 197
      scripts/tor-setup.sh

21
app/config/config.go

@ -149,6 +149,12 @@ type C struct { @@ -149,6 +149,12 @@ type C struct {
BunkerEnabled bool `env:"ORLY_BUNKER_ENABLED" default:"false" usage:"enable NIP-46 bunker signing service (requires WireGuard)"`
BunkerPort int `env:"ORLY_BUNKER_PORT" default:"3335" usage:"internal port for bunker WebSocket (only accessible via WireGuard)"`
// Tor hidden service configuration
TorEnabled bool `env:"ORLY_TOR_ENABLED" default:"false" usage:"enable Tor hidden service integration (requires external Tor daemon)"`
TorPort int `env:"ORLY_TOR_PORT" default:"3336" usage:"internal port that Tor forwards .onion traffic to"`
TorHSDir string `env:"ORLY_TOR_HS_DIR" usage:"Tor HiddenServiceDir path to read .onion hostname (e.g., /var/lib/tor/orly-relay)"`
TorOnionAddress string `env:"ORLY_TOR_ONION_ADDRESS" usage:"manual .onion address override (optional, auto-detected from TorHSDir if empty)"`
// Cashu access token configuration (NIP-XX)
CashuEnabled bool `env:"ORLY_CASHU_ENABLED" default:"false" usage:"enable Cashu blind signature tokens for access control"`
CashuTokenTTL string `env:"ORLY_CASHU_TOKEN_TTL" default:"168h" usage:"token validity duration (default: 1 week)"`
@ -632,3 +638,18 @@ func (cfg *C) GetStorageConfigValues() ( @@ -632,3 +638,18 @@ func (cfg *C) GetStorageConfigValues() (
cfg.GCIntervalSec,
cfg.GCBatchSize
}
// GetTorConfigValues returns the Tor hidden service configuration values.
// This avoids circular imports with pkg/tor while allowing main.go to construct
// the Tor service configuration.
func (cfg *C) GetTorConfigValues() (
enabled bool,
port int,
hsDir string,
onionAddress string,
) {
return cfg.TorEnabled,
cfg.TorPort,
cfg.TorHSDir,
cfg.TorOnionAddress
}

35
app/handle-relayinfo.go

@ -15,6 +15,13 @@ import ( @@ -15,6 +15,13 @@ import (
"next.orly.dev/pkg/version"
)
// ExtendedRelayInfo extends the standard NIP-11 relay info with additional fields.
// The Addresses field contains alternative WebSocket URLs for the relay (e.g., .onion).
type ExtendedRelayInfo struct {
*relayinfo.T
Addresses []string `json:"addresses,omitempty"`
}
// HandleRelayInfo generates and returns a relay information document in JSON
// format based on the server's configuration and supported NIPs.
//
@ -138,6 +145,32 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) { @@ -138,6 +145,32 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
},
Icon: icon,
}
if err := json.NewEncoder(w).Encode(info); chk.E(err) {
// Build addresses list from config and Tor service
var addresses []string
// Add configured relay addresses
if len(s.Config.RelayAddresses) > 0 {
addresses = append(addresses, s.Config.RelayAddresses...)
}
// Add Tor hidden service address if available
if s.torService != nil {
if onionAddr := s.torService.OnionWSAddress(); onionAddr != "" {
addresses = append(addresses, onionAddr)
}
}
// Return extended info if we have addresses, otherwise standard info
if len(addresses) > 0 {
extInfo := &ExtendedRelayInfo{
T: info,
Addresses: addresses,
}
if err := json.NewEncoder(w).Encode(extInfo); chk.E(err) {
}
} else {
if err := json.NewEncoder(w).Encode(info); chk.E(err) {
}
}
}

33
app/main.go

@ -33,6 +33,7 @@ import ( @@ -33,6 +33,7 @@ import (
dsync "next.orly.dev/pkg/sync"
"next.orly.dev/pkg/wireguard"
"next.orly.dev/pkg/archive"
"next.orly.dev/pkg/tor"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
@ -548,6 +549,32 @@ func Run( @@ -548,6 +549,32 @@ func Run(
log.I.F("archive relay manager initialized with %d relays", len(archiveRelays))
}
// Initialize Tor hidden service if enabled
torEnabled, torPort, torHSDir, torOnionAddr := cfg.GetTorConfigValues()
if torEnabled {
torCfg := &tor.Config{
Port: torPort,
HSDir: torHSDir,
OnionAddress: torOnionAddr,
Handler: l,
}
var err error
l.torService, err = tor.New(torCfg)
if err != nil {
log.W.F("failed to create Tor service: %v", err)
} else {
if err = l.torService.Start(); err != nil {
log.W.F("failed to start Tor service: %v", err)
} else {
if addr := l.torService.OnionWSAddress(); addr != "" {
log.I.F("Tor hidden service listening on port %d, address: %s", torPort, addr)
} else {
log.I.F("Tor hidden service listening on port %d (waiting for .onion address)", torPort)
}
}
}
}
// Start rate limiter if enabled
if limiter != nil && limiter.IsEnabled() {
limiter.Start()
@ -663,6 +690,12 @@ func Run( @@ -663,6 +690,12 @@ func Run(
log.I.F("archive manager stopped")
}
// Stop Tor service if running
if l.torService != nil {
l.torService.Stop()
log.I.F("Tor service stopped")
}
// Stop garbage collector if running
if l.garbageCollector != nil {
l.garbageCollector.Stop()

4
app/server.go

@ -42,6 +42,7 @@ import ( @@ -42,6 +42,7 @@ import (
dsync "next.orly.dev/pkg/sync"
"next.orly.dev/pkg/wireguard"
"next.orly.dev/pkg/archive"
"next.orly.dev/pkg/tor"
)
type Server struct {
@ -98,6 +99,9 @@ type Server struct { @@ -98,6 +99,9 @@ type Server struct {
archiveManager *archive.Manager
accessTracker *storage.AccessTracker
garbageCollector *storage.GarbageCollector
// Tor hidden service
torService *tor.Service
}
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system

1
app/web/bun.lock

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "svelte-app",

4
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

8
app/web/src/constants.js

@ -7,6 +7,14 @@ export const DEFAULT_RELAYS = [ @@ -7,6 +7,14 @@ export const DEFAULT_RELAYS = [
`${wsProtocol}//${window.location.host}/`,
];
// Fallback relays for profile lookups when local relay doesn't have the data
export const FALLBACK_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://purplepag.es',
];
// Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)

152
app/web/src/nostr.js

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { SimplePool } from 'nostr-tools/pool';
import { EventStore } from 'applesauce-core';
import { PrivateKeySigner } from 'applesauce-signers';
import { DEFAULT_RELAYS } from "./constants.js";
import { DEFAULT_RELAYS, FALLBACK_RELAYS } from "./constants.js";
// Nostr client wrapper using nostr-tools
class NostrClient {
@ -450,65 +450,115 @@ export async function fetchUserProfile(pubkey) { @@ -450,65 +450,115 @@ export async function fetchUserProfile(pubkey) {
console.warn("Failed to load cached profile", e);
}
// 2) Fetch profile from relays
const filters = [{
kinds: [0],
authors: [pubkey],
limit: 1
}];
// 2) Fetch profile from local relay first
try {
const filters = [{
kinds: [0],
authors: [pubkey],
limit: 1
}];
const events = await fetchEvents(filters, { timeout: 10000 });
if (events.length > 0) {
const profileEvent = events[0];
console.log("Profile fetched:", profileEvent);
// Cache the event
await putEvent(profileEvent);
// Publish the profile event to the local relay
try {
console.log("Publishing profile event to local relay:", profileEvent.id);
await nostrClient.publish(profileEvent);
console.log("Profile event successfully saved to local relay");
} catch (publishError) {
console.warn("Failed to publish profile to local relay:", publishError);
// Don't fail the whole operation if publishing fails
console.log("Profile fetched from local relay:", profileEvent);
return processProfileEvent(profileEvent, pubkey);
}
} catch (error) {
console.warn("Failed to fetch profile from local relay:", error);
}
// 3) Try fallback relays if local relay doesn't have the profile
console.log("Profile not found on local relay, trying fallback relays:", FALLBACK_RELAYS);
try {
const profileEvent = await fetchProfileFromFallbackRelays(pubkey, filters);
if (profileEvent) {
return processProfileEvent(profileEvent, pubkey);
}
} catch (error) {
console.warn("Failed to fetch profile from fallback relays:", error);
}
// 4) No profile found anywhere - create a default profile for new users
console.log("No profile found for pubkey, creating default:", pubkey);
try {
return await createDefaultProfile(pubkey);
} catch (e) {
console.error("Failed to create default profile:", e);
return null;
}
}
// Helper to fetch profile from fallback relays
async function fetchProfileFromFallbackRelays(pubkey, filters) {
return new Promise((resolve) => {
const events = [];
const timeoutId = setTimeout(() => {
sub.close();
// Return the most recent profile event
if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at);
resolve(events[0]);
} else {
resolve(null);
}
// Parse profile data
const profile = parseProfileFromEvent(profileEvent);
// Notify listeners that an updated profile is available
try {
if (typeof window !== "undefined" && window.dispatchEvent) {
window.dispatchEvent(
new CustomEvent("profile-updated", {
detail: { pubkey, profile, event: profileEvent },
}),
);
}, 5000);
const sub = nostrClient.pool.subscribeMany(
FALLBACK_RELAYS,
filters,
{
onevent(event) {
console.log("Profile event received from fallback relay:", event.id?.substring(0, 8));
events.push(event);
},
oneose() {
clearTimeout(timeoutId);
sub.close();
if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at);
resolve(events[0]);
} else {
resolve(null);
}
}
} catch (e) {
console.warn("Failed to dispatch profile-updated event", e);
}
return profile;
} else {
// No profile found - create a default profile for new users
console.log("No profile found for pubkey, creating default:", pubkey);
return await createDefaultProfile(pubkey);
}
} catch (error) {
console.error("Failed to fetch profile:", error);
// Try to create default profile on error too
try {
return await createDefaultProfile(pubkey);
} catch (e) {
console.error("Failed to create default profile:", e);
return null;
);
});
}
// Helper to process and cache a profile event
async function processProfileEvent(profileEvent, pubkey) {
// Cache the event
await putEvent(profileEvent);
// Publish the profile event to the local relay
try {
console.log("Publishing profile event to local relay:", profileEvent.id);
await nostrClient.publish(profileEvent);
console.log("Profile event successfully saved to local relay");
} catch (publishError) {
console.warn("Failed to publish profile to local relay:", publishError);
}
// Parse profile data
const profile = parseProfileFromEvent(profileEvent);
// Notify listeners that an updated profile is available
try {
if (typeof window !== "undefined" && window.dispatchEvent) {
window.dispatchEvent(
new CustomEvent("profile-updated", {
detail: { pubkey, profile, event: profileEvent },
}),
);
}
} catch (e) {
console.warn("Failed to dispatch profile-updated event", e);
}
return profile;
}
/**

73
deploy/orly-tor.service

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
# ORLY Relay with Tor Hidden Service - Systemd Unit
#
# This is an example systemd unit for running ORLY with Tor support.
# Copy and customize for your deployment.
#
# Installation:
# 1. Copy to /etc/systemd/system/orly-tor.service
# 2. Edit paths and environment variables as needed
# 3. sudo systemctl daemon-reload
# 4. sudo systemctl enable orly-tor
# 5. sudo systemctl start orly-tor
#
# Prerequisites:
# - Tor daemon running (systemctl enable tor && systemctl start tor)
# - Hidden service configured (run scripts/tor-setup.sh)
[Unit]
Description=ORLY Nostr Relay with Tor Hidden Service
Documentation=https://git.mleku.dev/mleku/orly
After=network.target tor.service
Requires=tor.service
Wants=tor.service
[Service]
Type=simple
User=orly
Group=orly
# Working directory
WorkingDirectory=/opt/orly
# Main relay binary
ExecStart=/opt/orly/orly
# Environment configuration
# Core settings
Environment=ORLY_PORT=3334
Environment=ORLY_DATA_DIR=/var/lib/orly
Environment=ORLY_LOG_LEVEL=info
# Tor hidden service settings
Environment=ORLY_TOR_ENABLED=true
Environment=ORLY_TOR_PORT=3336
Environment=ORLY_TOR_HS_DIR=/var/lib/tor/orly-relay
# ACL mode (choose one: none, follows, managed)
Environment=ORLY_ACL_MODE=none
# TLS (optional - uncomment and configure for production)
# Environment=ORLY_TLS_DOMAINS=relay.example.com
# Resource limits
LimitNOFILE=65535
LimitNPROC=4096
# Restart policy
Restart=always
RestartSec=5
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/orly
PrivateTmp=yes
# Allow reading Tor hidden service directory
# Note: The Tor user must grant read access to the orly user
# Option 1: Add orly user to debian-tor group
# Option 2: Use ACLs: setfacl -R -m u:orly:rx /var/lib/tor/orly-relay
[Install]
WantedBy=multi-user.target

294
docs/TOR_SETUP.md

@ -0,0 +1,294 @@ @@ -0,0 +1,294 @@
# Tor Hidden Service Setup for ORLY Relay
This guide explains how to configure ORLY to automatically mirror your relay as a Tor hidden service, making it accessible via a `.onion` address.
## Overview
When Tor support is enabled:
1. ORLY listens on a dedicated internal port for Tor traffic
2. The Tor daemon forwards `.onion` traffic to this port
3. ORLY automatically detects the `.onion` address
4. The `.onion` address is included in NIP-11 relay information
## Quick Start
### Development (Local Testing)
```bash
# One-time setup (requires Tor installed)
./scripts/tor-dev-setup.sh
# Start relay with Tor
ORLY_TOR_ENABLED=true ORLY_TOR_HS_DIR=~/.tor/orly-dev/hidden_service ./orly
```
### Production
```bash
# One-time setup (requires root)
sudo ./scripts/tor-setup.sh
# Start relay with Tor
ORLY_TOR_ENABLED=true ORLY_TOR_HS_DIR=/var/lib/tor/orly-relay ./orly
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ORLY_TOR_ENABLED` | `false` | Enable Tor hidden service integration |
| `ORLY_TOR_PORT` | `3336` | Internal port Tor forwards traffic to |
| `ORLY_TOR_HS_DIR` | - | Path to Tor's HiddenServiceDir |
| `ORLY_TOR_ONION_ADDRESS` | - | Manual `.onion` override (optional) |
### Example Configurations
**Basic Tor setup:**
```bash
export ORLY_TOR_ENABLED=true
export ORLY_TOR_HS_DIR=/var/lib/tor/orly-relay
./orly
```
**Custom port:**
```bash
export ORLY_TOR_ENABLED=true
export ORLY_TOR_PORT=3337
export ORLY_TOR_HS_DIR=/var/lib/tor/orly-relay
./orly
```
**Manual address (if auto-detection doesn't work):**
```bash
export ORLY_TOR_ENABLED=true
export ORLY_TOR_ONION_ADDRESS=abc123xyz.onion
./orly
```
## How It Works
### Architecture
```
Internet Users Tor Users
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Regular │ │ Tor │
│ Traffic │ │ Network │
│ (HTTPS) │ │ │
└────┬─────┘ └──────┬───────┘
│ │
│ Port 443 │ .onion:80
▼ ▼
┌─────────────────────────────────────┐
│ ORLY Relay │
│ │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ Main Server │ │ Tor Service │ │
│ │ Port 3334 │ │ Port 3336 │ │
│ └──────┬──────┘ └───────┬───────┘ │
│ │ │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌────────────┐ │
│ │ Database │ │
│ └────────────┘ │
└─────────────────────────────────────┘
```
### Address Detection
1. The Tor daemon creates a hidden service directory containing:
- `hostname` - The `.onion` address
- `hs_ed25519_secret_key` - Private key (persistent)
- `hs_ed25519_public_key` - Public key
2. ORLY watches the `hostname` file and automatically detects the address
3. The address is included in NIP-11 relay information under the `addresses` field
### NIP-11 Integration
When Tor is enabled and the `.onion` address is detected, the NIP-11 relay info includes:
```json
{
"name": "ORLY",
"description": "...",
"pubkey": "...",
"addresses": [
"wss://relay.example.com",
"ws://abc123xyz.onion/"
]
}
```
## Manual Tor Configuration
If you prefer to configure Tor manually instead of using the setup scripts:
### 1. Install Tor
**Debian/Ubuntu:**
```bash
sudo apt update && sudo apt install tor
```
**Arch Linux:**
```bash
sudo pacman -S tor
```
**macOS:**
```bash
brew install tor
```
### 2. Configure Hidden Service
Add to `/etc/tor/torrc`:
```
HiddenServiceDir /var/lib/tor/orly-relay/
HiddenServicePort 80 127.0.0.1:3336
```
### 3. Set Permissions
```bash
# Create directory
sudo mkdir -p /var/lib/tor/orly-relay
# Set ownership (Debian/Ubuntu)
sudo chown debian-tor:debian-tor /var/lib/tor/orly-relay
sudo chmod 700 /var/lib/tor/orly-relay
# Or on other systems
sudo chown tor:tor /var/lib/tor/orly-relay
```
### 4. Restart Tor
```bash
sudo systemctl restart tor
```
### 5. Verify
```bash
# Check the .onion address
sudo cat /var/lib/tor/orly-relay/hostname
```
## Systemd Service
For production deployments, use the provided systemd unit:
```bash
# Copy unit file
sudo cp deploy/orly-tor.service /etc/systemd/system/
# Edit configuration
sudo nano /etc/systemd/system/orly-tor.service
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable orly-tor
sudo systemctl start orly-tor
```
### Permissions for Hidden Service Directory
The ORLY process needs read access to the Tor hidden service directory:
**Option 1: Add user to Tor group**
```bash
sudo usermod -aG debian-tor orly
```
**Option 2: Use ACLs**
```bash
sudo setfacl -R -m u:orly:rx /var/lib/tor/orly-relay
```
## Troubleshooting
### .onion address not appearing in NIP-11
1. Check if Tor is running:
```bash
systemctl status tor
```
2. Check if hostname file exists:
```bash
cat /var/lib/tor/orly-relay/hostname
```
3. Check ORLY logs for Tor-related messages
4. Verify environment variables are set:
```bash
echo $ORLY_TOR_ENABLED
echo $ORLY_TOR_HS_DIR
```
### Permission denied errors
Ensure ORLY can read the hidden service directory:
```bash
# Check permissions
ls -la /var/lib/tor/orly-relay/
# Fix with ACL
sudo setfacl -m u:$(whoami):rx /var/lib/tor/orly-relay
```
### Tor connection timeouts
1. Check Tor logs:
```bash
journalctl -u tor -f
```
2. For development, check:
```bash
tail -f ~/.tor/orly-dev/tor.log
```
3. Ensure Tor can reach the network (check firewall rules)
### Different .onion address after restart
This means the hidden service key was lost. The key is stored in:
- Production: `/var/lib/tor/orly-relay/hs_ed25519_secret_key`
- Development: `~/.tor/orly-dev/hidden_service/hs_ed25519_secret_key`
To preserve your `.onion` address, back up the entire hidden service directory.
## Security Considerations
1. **Keep the hidden service key safe** - The `hs_ed25519_secret_key` file is your identity. If compromised, attackers can impersonate your relay.
2. **Restrict file permissions** - Hidden service directories should be `chmod 700`.
3. **Separate Tor traffic** - The dedicated Tor port (3336) keeps Tor traffic isolated from regular traffic.
4. **Regular updates** - Keep Tor updated for security patches.
## Testing with Tor Browser
1. Download Tor Browser from https://www.torproject.org/
2. Navigate to your `.onion` address:
```
ws://your-address.onion/
```
3. Or test with curl over Tor:
```bash
curl --socks5-hostname localhost:9050 -H "Accept: application/nostr+json" http://your-address.onion/
```

116
pkg/tor/hostname.go

@ -0,0 +1,116 @@ @@ -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

@ -0,0 +1,188 @@ @@ -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
},
}
}

2
pkg/version/version

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

217
scripts/tor-dev-setup.sh

@ -0,0 +1,217 @@ @@ -0,0 +1,217 @@
#!/bin/bash
# tor-dev-setup.sh - Development Tor hidden service setup for ORLY relay
#
# This script sets up a user-space Tor hidden service for local development.
# No root privileges required (except for initial Tor installation).
#
# Usage: ./scripts/tor-dev-setup.sh [port]
# port: internal port ORLY listens on for Tor traffic (default: 3336)
#
# After running this script:
# 1. Start ORLY with: ORLY_TOR_ENABLED=true ORLY_TOR_HS_DIR=~/.tor/orly-dev ./orly
# 2. Connect via Tor Browser to the .onion address
set -e
# Configuration
TOR_PORT="${1:-3336}"
TOR_DATA_DIR="${HOME}/.tor/orly-dev"
TOR_CONFIG="${TOR_DATA_DIR}/torrc"
TOR_PID_FILE="${TOR_DATA_DIR}/tor.pid"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
debug() { echo -e "${BLUE}[DEBUG]${NC} $1"; }
# Check if Tor is installed
check_tor() {
if ! command -v tor &> /dev/null; then
error "Tor is not installed. Please install it first:
Debian/Ubuntu: sudo apt install tor
Arch: sudo pacman -S tor
macOS: brew install tor
Fedora: sudo dnf install tor"
fi
info "Tor is installed: $(tor --version | head -1)"
}
# Create directory structure
setup_dirs() {
info "Creating directory structure..."
mkdir -p "${TOR_DATA_DIR}"
mkdir -p "${TOR_DATA_DIR}/hidden_service"
chmod 700 "${TOR_DATA_DIR}"
chmod 700 "${TOR_DATA_DIR}/hidden_service"
info "Directory created: ${TOR_DATA_DIR}"
}
# Create Tor configuration
create_config() {
info "Creating Tor configuration..."
cat > "$TOR_CONFIG" << EOF
# ORLY Development Tor Configuration
# Generated by tor-dev-setup.sh on $(date)
# Data directory
DataDirectory ${TOR_DATA_DIR}/data
# Run in background
RunAsDaemon 1
PidFile ${TOR_PID_FILE}
# SOCKS proxy for outgoing connections (optional, for testing)
SocksPort 9150
# Hidden service for ORLY relay
HiddenServiceDir ${TOR_DATA_DIR}/hidden_service/
HiddenServicePort 80 127.0.0.1:${TOR_PORT}
# Logging
Log notice file ${TOR_DATA_DIR}/tor.log
EOF
chmod 600 "$TOR_CONFIG"
info "Configuration created: ${TOR_CONFIG}"
}
# Stop existing Tor instance
stop_tor() {
if [ -f "$TOR_PID_FILE" ]; then
PID=$(cat "$TOR_PID_FILE" 2>/dev/null)
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
info "Stopping existing Tor instance (PID: $PID)..."
kill "$PID" 2>/dev/null || true
sleep 2
fi
rm -f "$TOR_PID_FILE"
fi
}
# Start Tor
start_tor() {
info "Starting Tor..."
# Ensure data directory exists
mkdir -p "${TOR_DATA_DIR}/data"
# Start Tor with our config
tor -f "$TOR_CONFIG" 2>&1 | head -20 &
# Wait for Tor to bootstrap
info "Waiting for Tor to connect to the network..."
for i in {1..60}; do
if [ -f "${TOR_DATA_DIR}/hidden_service/hostname" ]; then
ONION_ADDR=$(cat "${TOR_DATA_DIR}/hidden_service/hostname")
if [ -n "$ONION_ADDR" ]; then
break
fi
fi
# Check if Tor is still running
if [ -f "$TOR_PID_FILE" ]; then
PID=$(cat "$TOR_PID_FILE")
if ! kill -0 "$PID" 2>/dev/null; then
error "Tor process died. Check ${TOR_DATA_DIR}/tor.log"
fi
fi
sleep 1
echo -n "."
done
echo ""
if [ -f "${TOR_DATA_DIR}/hidden_service/hostname" ]; then
ONION_ADDR=$(cat "${TOR_DATA_DIR}/hidden_service/hostname")
info "Tor started successfully"
echo ""
echo -e "${GREEN}======================================${NC}"
echo -e "${GREEN}Hidden Service Address:${NC}"
echo -e "${YELLOW}${ONION_ADDR}${NC}"
echo -e "${GREEN}======================================${NC}"
echo ""
else
warn "Tor started but hidden service not ready yet"
warn "Check: tail -f ${TOR_DATA_DIR}/tor.log"
fi
}
# Print usage instructions
print_instructions() {
echo ""
info "Development Tor setup complete!"
echo ""
echo " To start ORLY with Tor:"
echo -e " ${BLUE}ORLY_TOR_ENABLED=true ORLY_TOR_HS_DIR=${TOR_DATA_DIR}/hidden_service ./orly${NC}"
echo ""
echo " To view the .onion address:"
echo -e " ${BLUE}cat ${TOR_DATA_DIR}/hidden_service/hostname${NC}"
echo ""
echo " To view Tor logs:"
echo -e " ${BLUE}tail -f ${TOR_DATA_DIR}/tor.log${NC}"
echo ""
echo " To stop Tor:"
echo -e " ${BLUE}kill \$(cat ${TOR_PID_FILE})${NC}"
echo ""
echo " To restart Tor:"
echo -e " ${BLUE}./scripts/tor-dev-setup.sh${NC}"
echo ""
}
# Status command
status() {
if [ -f "$TOR_PID_FILE" ]; then
PID=$(cat "$TOR_PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
info "Tor is running (PID: $PID)"
if [ -f "${TOR_DATA_DIR}/hidden_service/hostname" ]; then
ONION_ADDR=$(cat "${TOR_DATA_DIR}/hidden_service/hostname")
echo -e " Address: ${YELLOW}${ONION_ADDR}${NC}"
fi
return 0
fi
fi
warn "Tor is not running"
return 1
}
# Main
main() {
case "${1:-}" in
status)
status
exit $?
;;
stop)
stop_tor
info "Tor stopped"
exit 0
;;
*)
;;
esac
info "ORLY Development Tor Setup"
info "Internal port: ${TOR_PORT}"
echo ""
check_tor
setup_dirs
stop_tor
create_config
start_tor
print_instructions
}
main "$@"

197
scripts/tor-setup.sh

@ -0,0 +1,197 @@ @@ -0,0 +1,197 @@
#!/bin/bash
# tor-setup.sh - Production Tor hidden service setup for ORLY relay
#
# This script installs Tor and configures a hidden service for the relay.
# The .onion address will be automatically detected by ORLY.
#
# Usage: sudo ./scripts/tor-setup.sh [port]
# port: internal port ORLY listens on for Tor traffic (default: 3336)
#
# Requirements:
# - Root privileges (for installing packages and configuring Tor)
# - Systemd-based Linux distribution
#
# After running this script:
# 1. Start ORLY with: ORLY_TOR_ENABLED=true ORLY_TOR_HS_DIR=/var/lib/tor/orly-relay ./orly
# 2. The .onion address will appear in logs and NIP-11
set -e
# Configuration
TOR_PORT="${1:-3336}"
HS_NAME="orly-relay"
HS_DIR="/var/lib/tor/${HS_NAME}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
# Check if running as root
if [ "$EUID" -ne 0 ]; then
error "Please run as root: sudo $0"
fi
# Detect package manager and install Tor
install_tor() {
info "Installing Tor..."
if command -v apt-get &> /dev/null; then
# Debian/Ubuntu
apt-get update
apt-get install -y tor
elif command -v dnf &> /dev/null; then
# Fedora/RHEL
dnf install -y tor
elif command -v pacman &> /dev/null; then
# Arch Linux
pacman -Sy --noconfirm tor
elif command -v apk &> /dev/null; then
# Alpine
apk add tor
elif command -v brew &> /dev/null; then
# macOS (run as user, not root)
brew install tor
else
error "Unsupported package manager. Please install Tor manually."
fi
info "Tor installed successfully"
}
# Configure hidden service
configure_tor() {
info "Configuring Tor hidden service..."
TORRC="/etc/tor/torrc"
# Check if hidden service already configured
if grep -q "HiddenServiceDir ${HS_DIR}" "$TORRC" 2>/dev/null; then
warn "Hidden service already configured in ${TORRC}"
return 0
fi
# Backup original torrc
if [ -f "$TORRC" ]; then
cp "$TORRC" "${TORRC}.backup.$(date +%Y%m%d%H%M%S)"
info "Backed up original torrc"
fi
# Add hidden service configuration
cat >> "$TORRC" << EOF
# ORLY Relay Hidden Service
# Added by tor-setup.sh on $(date)
HiddenServiceDir ${HS_DIR}/
HiddenServicePort 80 127.0.0.1:${TOR_PORT}
EOF
info "Hidden service configured: ${HS_DIR}"
}
# Set permissions
set_permissions() {
info "Setting directory permissions..."
# Create hidden service directory if it doesn't exist
mkdir -p "$HS_DIR"
# Set correct ownership (debian-tor on Debian/Ubuntu, tor on others)
if id "debian-tor" &>/dev/null; then
chown -R debian-tor:debian-tor "$HS_DIR"
elif id "tor" &>/dev/null; then
chown -R tor:tor "$HS_DIR"
fi
chmod 700 "$HS_DIR"
info "Permissions set"
}
# Restart Tor service
restart_tor() {
info "Restarting Tor service..."
if command -v systemctl &> /dev/null; then
systemctl enable tor
systemctl restart tor
elif command -v service &> /dev/null; then
service tor restart
else
warn "Could not restart Tor. Please restart manually."
return 1
fi
# Wait for Tor to create the hostname file
info "Waiting for hidden service to initialize..."
for i in {1..30}; do
if [ -f "${HS_DIR}/hostname" ]; then
break
fi
sleep 1
done
if [ -f "${HS_DIR}/hostname" ]; then
ONION_ADDR=$(cat "${HS_DIR}/hostname")
info "Tor service started successfully"
echo ""
echo -e "${GREEN}======================================${NC}"
echo -e "${GREEN}Hidden Service Address:${NC}"
echo -e "${YELLOW}${ONION_ADDR}${NC}"
echo -e "${GREEN}======================================${NC}"
echo ""
else
warn "Tor started but hostname file not yet created"
warn "Check: ls -la ${HS_DIR}/"
fi
}
# Print usage instructions
print_instructions() {
echo ""
info "Setup complete! To enable Tor in ORLY:"
echo ""
echo " Option 1 - Environment variables:"
echo " export ORLY_TOR_ENABLED=true"
echo " export ORLY_TOR_HS_DIR=${HS_DIR}"
echo " export ORLY_TOR_PORT=${TOR_PORT}"
echo " ./orly"
echo ""
echo " Option 2 - Command line:"
echo " ORLY_TOR_ENABLED=true ORLY_TOR_HS_DIR=${HS_DIR} ./orly"
echo ""
echo " The .onion address will automatically appear in NIP-11 relay info."
echo ""
echo " To view the .onion address:"
echo " cat ${HS_DIR}/hostname"
echo ""
echo " To check Tor status:"
echo " systemctl status tor"
echo ""
}
# Main
main() {
info "ORLY Tor Hidden Service Setup"
info "Internal port: ${TOR_PORT}"
echo ""
# Check if Tor is already installed
if ! command -v tor &> /dev/null; then
install_tor
else
info "Tor is already installed"
fi
configure_tor
set_permissions
restart_tor
print_instructions
}
main
Loading…
Cancel
Save