567 lines
19 KiB
Go

package installers
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// AnyoneRelayConfig holds configuration for the Anyone relay
type AnyoneRelayConfig struct {
Nickname string // Relay nickname (1-19 alphanumeric)
Contact string // Contact info (email or @telegram)
Wallet string // Ethereum wallet for rewards
ORPort int // ORPort for relay (default 9001)
ExitRelay bool // Whether to run as exit relay
Migrate bool // Whether to migrate existing installation
MyFamily string // Comma-separated list of family fingerprints (for multi-relay operators)
BandwidthRate int // RelayBandwidthRate in KBytes/s (0 = unlimited)
BandwidthBurst int // RelayBandwidthBurst in KBytes/s (0 = unlimited)
AccountingMax int // Monthly data cap in GB (0 = unlimited)
}
// ExistingAnyoneInfo contains information about an existing Anyone installation
type ExistingAnyoneInfo struct {
HasKeys bool
HasConfig bool
IsRunning bool
Fingerprint string
Wallet string
Nickname string
MyFamily string // Existing MyFamily setting (important to preserve!)
ConfigPath string
KeysPath string
}
// AnyoneRelayInstaller handles Anyone relay installation
type AnyoneRelayInstaller struct {
*BaseInstaller
config AnyoneRelayConfig
}
// NewAnyoneRelayInstaller creates a new Anyone relay installer
func NewAnyoneRelayInstaller(arch string, logWriter io.Writer, config AnyoneRelayConfig) *AnyoneRelayInstaller {
return &AnyoneRelayInstaller{
BaseInstaller: NewBaseInstaller(arch, logWriter),
config: config,
}
}
// DetectExistingAnyoneInstallation checks for an existing Anyone relay installation
func DetectExistingAnyoneInstallation() (*ExistingAnyoneInfo, error) {
info := &ExistingAnyoneInfo{
ConfigPath: "/etc/anon/anonrc",
KeysPath: "/var/lib/anon/keys",
}
// Check for existing keys
if _, err := os.Stat(info.KeysPath); err == nil {
info.HasKeys = true
}
// Check for existing config
if _, err := os.Stat(info.ConfigPath); err == nil {
info.HasConfig = true
// Parse existing config for fingerprint/wallet/nickname
if file, err := os.Open(info.ConfigPath); err == nil {
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") {
continue
}
// Parse Nickname
if strings.HasPrefix(line, "Nickname ") {
info.Nickname = strings.TrimPrefix(line, "Nickname ")
}
// Parse ContactInfo for wallet (format: ... @anon:0x... or @anon: 0x...)
if strings.HasPrefix(line, "ContactInfo ") {
contact := strings.TrimPrefix(line, "ContactInfo ")
// Extract wallet address from @anon: prefix (handle space after colon)
if idx := strings.Index(contact, "@anon:"); idx != -1 {
wallet := strings.TrimSpace(contact[idx+6:])
info.Wallet = wallet
}
}
// Parse MyFamily (critical to preserve for multi-relay operators)
if strings.HasPrefix(line, "MyFamily ") {
info.MyFamily = strings.TrimPrefix(line, "MyFamily ")
}
}
}
}
// Check if anon service is running
cmd := exec.Command("systemctl", "is-active", "--quiet", "anon")
if cmd.Run() == nil {
info.IsRunning = true
}
// Try to get fingerprint from data directory (it's in /var/lib/anon/, not keys/)
fingerprintFile := "/var/lib/anon/fingerprint"
if data, err := os.ReadFile(fingerprintFile); err == nil {
info.Fingerprint = strings.TrimSpace(string(data))
}
// Return nil if no installation detected
if !info.HasKeys && !info.HasConfig && !info.IsRunning {
return nil, nil
}
return info, nil
}
// IsInstalled checks if the anon relay binary is installed
func (ari *AnyoneRelayInstaller) IsInstalled() bool {
// Check if anon binary exists
if _, err := exec.LookPath("anon"); err == nil {
return true
}
// Check common installation path
if _, err := os.Stat("/usr/bin/anon"); err == nil {
return true
}
return false
}
// Install downloads and installs the Anyone relay using the official install script
func (ari *AnyoneRelayInstaller) Install() error {
fmt.Fprintf(ari.logWriter, " Installing Anyone relay...\n")
// Create required directories
dirs := []string{
"/etc/anon",
"/var/lib/anon",
"/var/log/anon",
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
// Download the official install script
installScript := "/tmp/anon-install.sh"
scriptURL := "https://raw.githubusercontent.com/anyone-protocol/anon-install/refs/heads/main/install.sh"
fmt.Fprintf(ari.logWriter, " Downloading install script...\n")
if err := DownloadFile(scriptURL, installScript); err != nil {
return fmt.Errorf("failed to download install script: %w", err)
}
// Make script executable
if err := os.Chmod(installScript, 0755); err != nil {
return fmt.Errorf("failed to chmod install script: %w", err)
}
// The official script is interactive, so we need to provide answers via stdin
// or install the package directly
fmt.Fprintf(ari.logWriter, " Installing anon package...\n")
// Add the Anyone repository and install the package directly
// This is more reliable than running the interactive script
if err := ari.addAnyoneRepository(); err != nil {
return fmt.Errorf("failed to add Anyone repository: %w", err)
}
// Pre-accept terms via debconf to avoid interactive prompt during apt install.
// The anon package preinst script checks "anon/terms" via debconf.
preseed := exec.Command("bash", "-c", `echo "anon anon/terms boolean true" | debconf-set-selections`)
if output, err := preseed.CombinedOutput(); err != nil {
fmt.Fprintf(ari.logWriter, " ⚠️ debconf preseed warning: %v (%s)\n", err, string(output))
}
// Install the anon package non-interactively.
// --force-confold keeps existing config files if present (e.g. during migration).
cmd := exec.Command("apt-get", "install", "-y", "-o", "Dpkg::Options::=--force-confold", "anon")
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to install anon package: %w\n%s", err, string(output))
}
// Clean up
os.Remove(installScript)
// Stop and disable the default 'anon' systemd service that the apt package
// auto-enables. We use our own 'debros-anyone-relay' service instead.
exec.Command("systemctl", "stop", "anon").Run()
exec.Command("systemctl", "disable", "anon").Run()
// Fix logrotate: the apt package installs /etc/logrotate.d/anon with
// "invoke-rc.d anon reload" in postrotate, but we disabled the anon service.
// Without this fix, log rotation leaves an empty notices.log and the relay
// keeps writing to the old (rotated) file descriptor.
ari.fixLogrotate()
fmt.Fprintf(ari.logWriter, " ✓ Anyone relay binary installed\n")
// Install nyx for relay monitoring (connects to ControlPort 9051)
if err := ari.installNyx(); err != nil {
fmt.Fprintf(ari.logWriter, " ⚠️ nyx install warning: %v\n", err)
}
return nil
}
// fixLogrotate replaces the apt-provided logrotate config which uses
// "invoke-rc.d anon reload" (broken because we disable the anon service).
// Without this, log rotation creates an empty notices.log but the relay
// process keeps writing to the old file descriptor, so bootstrap detection
// and all log-based monitoring breaks after the first midnight rotation.
func (ari *AnyoneRelayInstaller) fixLogrotate() {
config := `/var/log/anon/*log {
daily
rotate 5
compress
delaycompress
missingok
notifempty
create 0640 debian-anon adm
sharedscripts
postrotate
/usr/bin/killall -HUP anon 2>/dev/null || true
endscript
}
`
if err := os.WriteFile("/etc/logrotate.d/anon", []byte(config), 0644); err != nil {
fmt.Fprintf(ari.logWriter, " ⚠️ logrotate fix warning: %v\n", err)
}
}
// installNyx installs the nyx relay monitor tool
func (ari *AnyoneRelayInstaller) installNyx() error {
// Check if already installed
if _, err := exec.LookPath("nyx"); err == nil {
fmt.Fprintf(ari.logWriter, " ✓ nyx already installed\n")
return nil
}
fmt.Fprintf(ari.logWriter, " Installing nyx (relay monitor)...\n")
cmd := exec.Command("apt-get", "install", "-y", "nyx")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to install nyx: %w\n%s", err, string(output))
}
fmt.Fprintf(ari.logWriter, " ✓ nyx installed (use 'nyx' to monitor relay on ControlPort 9051)\n")
return nil
}
// addAnyoneRepository adds the Anyone apt repository
func (ari *AnyoneRelayInstaller) addAnyoneRepository() error {
// Add GPG key using wget (as per official install script)
fmt.Fprintf(ari.logWriter, " Adding Anyone repository key...\n")
// Download and add the GPG key using the official method
keyPath := "/etc/apt/trusted.gpg.d/anon.asc"
cmd := exec.Command("bash", "-c", "wget -qO- https://deb.en.anyone.tech/anon.asc | tee "+keyPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to download GPG key: %w\n%s", err, string(output))
}
// Add repository
fmt.Fprintf(ari.logWriter, " Adding Anyone repository...\n")
// Determine distribution codename
codename := "stable"
if data, err := exec.Command("lsb_release", "-cs").Output(); err == nil {
codename = strings.TrimSpace(string(data))
}
// Create sources.list entry using the official format: anon-live-$VERSION_CODENAME
repoLine := fmt.Sprintf("deb [signed-by=%s] https://deb.en.anyone.tech anon-live-%s main\n", keyPath, codename)
if err := os.WriteFile("/etc/apt/sources.list.d/anon.list", []byte(repoLine), 0644); err != nil {
return fmt.Errorf("failed to write repository file: %w", err)
}
// Update apt
cmd = exec.Command("apt-get", "update", "--yes")
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Fprintf(ari.logWriter, " ⚠️ Warning: apt update failed: %s\n", string(output))
}
return nil
}
// Configure generates the anonrc configuration file
func (ari *AnyoneRelayInstaller) Configure() error {
fmt.Fprintf(ari.logWriter, " Configuring Anyone relay...\n")
configPath := "/etc/anon/anonrc"
// Backup existing config if it exists
if _, err := os.Stat(configPath); err == nil {
backupPath := configPath + ".bak"
if err := exec.Command("cp", configPath, backupPath).Run(); err != nil {
fmt.Fprintf(ari.logWriter, " ⚠️ Warning: failed to backup existing config: %v\n", err)
} else {
fmt.Fprintf(ari.logWriter, " Backed up existing config to %s\n", backupPath)
}
}
// Generate configuration
config := ari.generateAnonrc()
// Write configuration
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
return fmt.Errorf("failed to write anonrc: %w", err)
}
fmt.Fprintf(ari.logWriter, " ✓ Anyone relay configured\n")
return nil
}
// ConfigureClient generates a client-only anonrc (SocksPort 9050, no relay)
func (ari *AnyoneRelayInstaller) ConfigureClient() error {
fmt.Fprintf(ari.logWriter, " Configuring Anyone client-only mode...\n")
configPath := "/etc/anon/anonrc"
// Backup existing config if it exists
if _, err := os.Stat(configPath); err == nil {
backupPath := configPath + ".bak"
if err := exec.Command("cp", configPath, backupPath).Run(); err != nil {
fmt.Fprintf(ari.logWriter, " ⚠️ Warning: failed to backup existing config: %v\n", err)
}
}
config := `# Anyone Client Configuration (Managed by Orama Network)
# Client-only mode — no relay traffic, no ORPort
SocksPort 9050
Log notice file /var/log/anon/notices.log
DataDirectory /var/lib/anon
ControlPort 9051
`
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
return fmt.Errorf("failed to write client anonrc: %w", err)
}
fmt.Fprintf(ari.logWriter, " ✓ Anyone client configured (SocksPort 9050)\n")
return nil
}
// generateAnonrc creates the anonrc configuration content
func (ari *AnyoneRelayInstaller) generateAnonrc() string {
var sb strings.Builder
sb.WriteString("# Anyone Relay Configuration (Managed by Orama Network)\n")
sb.WriteString("# Generated automatically - manual edits may be overwritten\n\n")
// Nickname
sb.WriteString(fmt.Sprintf("Nickname %s\n", ari.config.Nickname))
// Contact info with wallet
if ari.config.Wallet != "" {
sb.WriteString(fmt.Sprintf("ContactInfo %s @anon:%s\n", ari.config.Contact, ari.config.Wallet))
} else {
sb.WriteString(fmt.Sprintf("ContactInfo %s\n", ari.config.Contact))
}
sb.WriteString("\n")
// ORPort
sb.WriteString(fmt.Sprintf("ORPort %d\n", ari.config.ORPort))
// SOCKS port for local use
sb.WriteString("SocksPort 9050\n")
sb.WriteString("\n")
// Exit relay configuration
if ari.config.ExitRelay {
sb.WriteString("ExitRelay 1\n")
sb.WriteString("# Exit policy - allow common ports\n")
sb.WriteString("ExitPolicy accept *:80\n")
sb.WriteString("ExitPolicy accept *:443\n")
sb.WriteString("ExitPolicy reject *:*\n")
} else {
sb.WriteString("ExitRelay 0\n")
sb.WriteString("ExitPolicy reject *:*\n")
}
sb.WriteString("\n")
// Logging
sb.WriteString("Log notice file /var/log/anon/notices.log\n")
// Data directory
sb.WriteString("DataDirectory /var/lib/anon\n")
// Control port for monitoring
sb.WriteString("ControlPort 9051\n")
// Bandwidth limiting
if ari.config.BandwidthRate > 0 {
sb.WriteString("\n")
sb.WriteString("# Bandwidth limiting (managed by Orama Network)\n")
sb.WriteString(fmt.Sprintf("RelayBandwidthRate %d KBytes\n", ari.config.BandwidthRate))
sb.WriteString(fmt.Sprintf("RelayBandwidthBurst %d KBytes\n", ari.config.BandwidthBurst))
rateMbps := float64(ari.config.BandwidthRate) * 8 / 1024
burstMbps := float64(ari.config.BandwidthBurst) * 8 / 1024
sb.WriteString(fmt.Sprintf("# Rate: %.1f Mbps, Burst: %.1f Mbps\n", rateMbps, burstMbps))
}
// Monthly data cap
if ari.config.AccountingMax > 0 {
sb.WriteString("\n")
sb.WriteString("# Monthly data cap (managed by Orama Network)\n")
sb.WriteString("AccountingStart month 1 00:00\n")
sb.WriteString(fmt.Sprintf("AccountingMax %d GBytes\n", ari.config.AccountingMax))
}
// MyFamily for multi-relay operators (preserve from existing config)
if ari.config.MyFamily != "" {
sb.WriteString("\n")
sb.WriteString(fmt.Sprintf("MyFamily %s\n", ari.config.MyFamily))
}
return sb.String()
}
// MigrateExistingInstallation migrates an existing Anyone installation into Orama Network
func (ari *AnyoneRelayInstaller) MigrateExistingInstallation(existing *ExistingAnyoneInfo, backupDir string) error {
fmt.Fprintf(ari.logWriter, " Migrating existing Anyone installation...\n")
// Create backup directory
backupAnonDir := filepath.Join(backupDir, "anon-backup")
if err := os.MkdirAll(backupAnonDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %w", err)
}
// Stop existing anon service if running
if existing.IsRunning {
fmt.Fprintf(ari.logWriter, " Stopping existing anon service...\n")
exec.Command("systemctl", "stop", "anon").Run()
}
// Backup keys
if existing.HasKeys {
fmt.Fprintf(ari.logWriter, " Backing up keys...\n")
keysBackup := filepath.Join(backupAnonDir, "keys")
if err := exec.Command("cp", "-r", existing.KeysPath, keysBackup).Run(); err != nil {
return fmt.Errorf("failed to backup keys: %w", err)
}
}
// Backup config
if existing.HasConfig {
fmt.Fprintf(ari.logWriter, " Backing up config...\n")
configBackup := filepath.Join(backupAnonDir, "anonrc")
if err := exec.Command("cp", existing.ConfigPath, configBackup).Run(); err != nil {
return fmt.Errorf("failed to backup config: %w", err)
}
}
// Preserve nickname from existing installation if not provided
if ari.config.Nickname == "" && existing.Nickname != "" {
fmt.Fprintf(ari.logWriter, " Using existing nickname: %s\n", existing.Nickname)
ari.config.Nickname = existing.Nickname
}
// Preserve wallet from existing installation if not provided
if ari.config.Wallet == "" && existing.Wallet != "" {
fmt.Fprintf(ari.logWriter, " Using existing wallet: %s\n", existing.Wallet)
ari.config.Wallet = existing.Wallet
}
// Preserve MyFamily from existing installation (critical for multi-relay operators)
if existing.MyFamily != "" {
fmt.Fprintf(ari.logWriter, " Preserving MyFamily configuration (%d relays)\n", len(strings.Split(existing.MyFamily, ",")))
ari.config.MyFamily = existing.MyFamily
}
fmt.Fprintf(ari.logWriter, " ✓ Backup created at %s\n", backupAnonDir)
fmt.Fprintf(ari.logWriter, " ✓ Migration complete - keys and fingerprint preserved\n")
return nil
}
// MeasureBandwidth downloads a test file and returns the measured download speed in KBytes/s.
// Uses wget to download a 10MB file from a public CDN and measures throughput.
// Returns 0 if the test fails (caller should skip bandwidth limiting).
func MeasureBandwidth(logWriter io.Writer) (int, error) {
fmt.Fprintf(logWriter, " Running bandwidth test...\n")
testFile := "/tmp/speedtest-orama.tmp"
defer os.Remove(testFile)
// Use wget with progress output to download a 10MB test file
// We time the download ourselves for accuracy
start := time.Now()
cmd := exec.Command("wget", "-q", "-O", testFile, "http://speedtest.tele2.net/10MB.zip")
cmd.Env = append(os.Environ(), "LC_ALL=C")
if err := cmd.Run(); err != nil {
fmt.Fprintf(logWriter, " ⚠️ Bandwidth test failed: %v\n", err)
return 0, fmt.Errorf("bandwidth test download failed: %w", err)
}
elapsed := time.Since(start)
// Get file size
info, err := os.Stat(testFile)
if err != nil {
return 0, fmt.Errorf("failed to stat test file: %w", err)
}
// Calculate speed in KBytes/s
sizeKB := int(info.Size() / 1024)
seconds := elapsed.Seconds()
if seconds < 0.1 {
seconds = 0.1 // avoid division by zero
}
speedKBs := int(float64(sizeKB) / seconds)
speedMbps := float64(speedKBs) * 8 / 1024 // Convert KBytes/s to Mbps
fmt.Fprintf(logWriter, " Measured download speed: %d KBytes/s (%.1f Mbps)\n", speedKBs, speedMbps)
return speedKBs, nil
}
// CalculateBandwidthLimits computes RelayBandwidthRate and RelayBandwidthBurst
// from measured speed and a percentage. Returns rate and burst in KBytes/s.
func CalculateBandwidthLimits(measuredKBs int, percent int) (rate int, burst int) {
rate = measuredKBs * percent / 100
burst = rate * 3 / 2 // 1.5x rate for burst headroom
if rate < 1 {
rate = 1
}
if burst < rate {
burst = rate
}
return rate, burst
}
// ValidateNickname validates the relay nickname (1-19 alphanumeric chars)
func ValidateNickname(nickname string) error {
if len(nickname) < 1 || len(nickname) > 19 {
return fmt.Errorf("nickname must be 1-19 characters")
}
if !regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString(nickname) {
return fmt.Errorf("nickname must be alphanumeric only")
}
return nil
}
// ValidateWallet validates an Ethereum wallet address
func ValidateWallet(wallet string) error {
if !regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`).MatchString(wallet) {
return fmt.Errorf("invalid Ethereum wallet address (must be 0x followed by 40 hex characters)")
}
return nil
}