~bigbes/shroud

ca7efb0f8ff6e45da70363dcde1608ac92cd46d4 — Eugene Blikh 2 months ago
Initial commit: Shadowsocks + AmneziaWG VPN server

Single-binary Go replacement for the Outline VPN server stack.
Supports Shadowsocks (via outline-ss-server library) and AmneziaWG
with HTTP/3 mux for DPI resistance (MuxBind shares UDP port between
AWG and a real QUIC/HTTP/3 server with Let's Encrypt).

Includes systemd unit, install script for Ubuntu, and REST API
compatible with the Outline Manager.

false
A  => .gitignore +13 -0
@@ 1,13 @@
# Binary (root only, not cmd/outline-distro/)
/outline-distro

# State/runtime
state.yaml
access.yaml
*.tmp

# IDE
.idea/
.vscode/
*.swp
*~

A  => cmd/outline-distro/main.go +722 -0
@@ 1,722 @@
package main

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/tls"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"net/netip"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"
	"time"

	"github.com/Jigsaw-Code/outline-ss-server/ipinfo"
	"github.com/google/uuid"
	"github.com/lmittmann/tint"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	"github.com/spf13/cobra"
	"golang.org/x/term"
	"gopkg.in/yaml.v3"

	"sourcecraft.dev/bigbes/outline-distro/internal/api"
	"sourcecraft.dev/bigbes/outline-distro/internal/awgserver"
	"sourcecraft.dev/bigbes/outline-distro/internal/config"
	"sourcecraft.dev/bigbes/outline-distro/internal/metrics"
	"sourcecraft.dev/bigbes/outline-distro/internal/ssserver"
	"sourcecraft.dev/bigbes/outline-distro/internal/store"
)

var version = "dev"

func main() {
	if err := newRootCmd().Execute(); err != nil {
		os.Exit(1)
	}
}

func newRootCmd() *cobra.Command {
	var (
		configFile string
		verbose    bool
	)

	root := &cobra.Command{
		Use:     "outline-distro",
		Short:   "Single-binary Outline Shadowsocks server with Prometheus metrics",
		Version: version,
		RunE: func(cmd *cobra.Command, args []string) error {
			return runServe(configFile, verbose)
		},
		SilenceUsage:  true,
		SilenceErrors: true,
	}

	pf := root.PersistentFlags()
	pf.StringVarP(&configFile, "config", "c", "config.yaml", "configuration file path")
	pf.BoolVarP(&verbose, "verbose", "v", false, "enable debug logging")

	root.AddCommand(
		newCompletionCmd(),
		newKeyCmd(&configFile),
		newServerCmd(&configFile),
	)

	return root
}

// --- completion ---

func newCompletionCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "completion [bash|zsh|fish|powershell]",
		Short: "Generate shell completion scripts",
		Long: `Generate shell completion scripts for outline-distro.

To load completions:

Bash:
  $ source <(outline-distro completion bash)
  # To install permanently:
  $ outline-distro completion bash > /etc/bash_completion.d/outline-distro

Zsh:
  $ source <(outline-distro completion zsh)
  # To install permanently:
  $ outline-distro completion zsh > "${fpath[1]}/_outline-distro"

Fish:
  $ outline-distro completion fish | source
  # To install permanently:
  $ outline-distro completion fish > ~/.config/fish/completions/outline-distro.fish

PowerShell:
  PS> outline-distro completion powershell | Out-String | Invoke-Expression
`,
		DisableFlagsInUseLine: true,
		ValidArgs:             []string{"bash", "zsh", "fish", "powershell"},
		Args:                  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			switch args[0] {
			case "bash":
				return cmd.Root().GenBashCompletion(os.Stdout)
			case "zsh":
				return cmd.Root().GenZshCompletion(os.Stdout)
			case "fish":
				return cmd.Root().GenFishCompletion(os.Stdout, true)
			case "powershell":
				return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
			default:
				return fmt.Errorf("unsupported shell: %s", args[0])
			}
		},
	}
	return cmd
}

// --- key commands ---

func newKeyCmd(configFile *string) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "key",
		Short: "Manage access keys",
	}
	cmd.AddCommand(
		newKeyListCmd(configFile),
		newKeyAddCmd(configFile),
		newKeyRemoveCmd(configFile),
		newKeyRenameCmd(configFile),
	)
	return cmd
}

func newKeyListCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "list",
		Short: "List all access keys",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			keys := st.ListKeys()
			if len(keys) == 0 {
				fmt.Println("No access keys configured.")
				return nil
			}
			for _, k := range keys {
				limit := "none"
				if k.DataLimit != nil {
					limit = fmt.Sprintf("%d bytes", k.DataLimit.Bytes)
				}
				awgInfo := ""
				if k.AWG != nil {
					awgInfo = fmt.Sprintf("  awg=%s", k.AWG.AllowedIP)
				}
				fmt.Printf("%-6s  %-20s  port=%-6d  cipher=%s  limit=%s%s\n",
					k.ID, k.Name, k.Port, k.Method, limit, awgInfo)
			}
			return nil
		},
	}
}

func newKeyAddCmd(configFile *string) *cobra.Command {
	var (
		name   string
		port   int
		cipher string
	)
	cmd := &cobra.Command{
		Use:   "add",
		Short: "Add a new access key",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			cfg, err := config.Load(*configFile)
			if err != nil {
				return fmt.Errorf("loading config: %w", err)
			}
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			srv := st.GetServer()

			password, err := store.GeneratePassword()
			if err != nil {
				return err
			}

			id := fmt.Sprintf("%d", srv.NextID)
			if err := st.UpdateServer(func(s *store.ServerState) {
				s.NextID++
			}); err != nil {
				return err
			}

			if cipher == "" {
				cipher = srv.DefaultCipher
			}
			if port == 0 {
				port = srv.PortForNewAccessKeys
			}

			ak := store.AccessKey{
				ID:       id,
				Name:     name,
				Password: password,
				Port:     port,
				Method:   cipher,
			}

			// Generate AWG credentials if AWG is enabled.
			if cfg.AmneziaWG.Enabled && cfg.AmneziaWG.Address != "" {
				privKey, err := awgserver.GeneratePrivateKey()
				if err != nil {
					return fmt.Errorf("generating AWG key: %w", err)
				}
				pubKey, err := awgserver.PublicKeyFromPrivate(privKey)
				if err != nil {
					return fmt.Errorf("deriving AWG public key: %w", err)
				}
				keys := st.ListKeys()
				used := make([]string, 0, len(keys))
				for _, k := range keys {
					if k.AWG != nil {
						used = append(used, k.AWG.AllowedIP)
					}
				}
				alloc, err := awgserver.NewIPAllocator(cfg.AmneziaWG.Address)
				if err != nil {
					return fmt.Errorf("creating IP allocator: %w", err)
				}
				ip, err := alloc.Allocate(used)
				if err != nil {
					return fmt.Errorf("allocating AWG IP: %w", err)
				}
				ak.AWG = &store.AWGKeyData{
					PrivateKey: privKey,
					PublicKey:  pubKey,
					AllowedIP:  ip,
				}
			}

			if err := st.CreateKey(ak); err != nil {
				return err
			}
			fmt.Printf("Created key %s (port=%d, cipher=%s)\n", id, port, cipher)
			fmt.Printf("  ss://%s\n", password)
			if ak.AWG != nil {
				fmt.Printf("  awg: %s (pubkey=%s)\n", ak.AWG.AllowedIP, ak.AWG.PublicKey)
			}
			return nil
		},
	}
	cmd.Flags().StringVarP(&name, "name", "n", "", "key name")
	cmd.Flags().IntVarP(&port, "port", "p", 0, "port (default: server's default port)")
	cmd.Flags().StringVar(&cipher, "cipher", "", "cipher (default: server's default cipher)")
	return cmd
}

func newKeyRemoveCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "remove <id>",
		Short: "Remove an access key",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			if err := st.DeleteKey(args[0]); err != nil {
				return err
			}
			fmt.Printf("Removed key %s\n", args[0])
			return nil
		},
	}
}

func newKeyRenameCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "rename <id> <name>",
		Short: "Rename an access key",
		Args:  cobra.ExactArgs(2),
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			return st.UpdateKey(args[0], func(k *store.AccessKey) {
				k.Name = args[1]
			})
		},
	}
}

// --- server commands ---

func newServerCmd(configFile *string) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "server",
		Short: "Server configuration commands",
	}
	cmd.AddCommand(
		newServerInfoCmd(configFile),
		newServerSetPortCmd(configFile),
		newServerSetHostnameCmd(configFile),
		newServerSetNameCmd(configFile),
	)
	return cmd
}

func newServerInfoCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "info",
		Short: "Show server configuration",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			srv := st.GetServer()
			fmt.Printf("Server ID:        %s\n", srv.ID)
			fmt.Printf("Name:             %s\n", srv.Name)
			fmt.Printf("Hostname:         %s\n", srv.Hostname)
			fmt.Printf("Default Port:     %d\n", srv.PortForNewAccessKeys)
			fmt.Printf("Default Cipher:   %s\n", srv.DefaultCipher)
			fmt.Printf("Metrics Enabled:  %v\n", srv.MetricsEnabled)
			fmt.Printf("Access Keys:      %d\n", len(st.ListKeys()))
			if srv.AccessKeyDataLimit != nil {
				fmt.Printf("Data Limit:       %d bytes\n", srv.AccessKeyDataLimit.Bytes)
			}
			return nil
		},
	}
}

func newServerSetPortCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "set-port <port>",
		Short: "Set the default port for new access keys",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			var port int
			if _, err := fmt.Sscanf(args[0], "%d", &port); err != nil || port < 1 || port > 65535 {
				return fmt.Errorf("invalid port: %s", args[0])
			}
			if err := st.UpdateServer(func(s *store.ServerState) {
				s.PortForNewAccessKeys = port
			}); err != nil {
				return err
			}
			fmt.Printf("Default port set to %d\n", port)
			return nil
		},
	}
}

func newServerSetHostnameCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "set-hostname <hostname>",
		Short: "Set the hostname for access key URLs",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			if err := st.UpdateServer(func(s *store.ServerState) {
				s.Hostname = args[0]
			}); err != nil {
				return err
			}
			fmt.Printf("Hostname set to %s\n", args[0])
			return nil
		},
	}
}

func newServerSetNameCmd(configFile *string) *cobra.Command {
	return &cobra.Command{
		Use:   "set-name <name>",
		Short: "Set the server display name",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			st, err := openStore(*configFile)
			if err != nil {
				return err
			}
			if err := st.UpdateServer(func(s *store.ServerState) {
				s.Name = args[0]
			}); err != nil {
				return err
			}
			fmt.Printf("Server name set to %s\n", args[0])
			return nil
		},
	}
}

// --- helpers ---

func openStore(configFile string) (*store.YAMLFileStore, error) {
	cfg, err := config.Load(configFile)
	if err != nil {
		return nil, fmt.Errorf("loading config: %w", err)
	}
	return store.NewYAMLFileStore(
		cfg.StateFile(),
		"", cfg.Server.Name, cfg.Server.Hostname,
		cfg.Shadowsocks.DefaultCipher, cfg.Shadowsocks.DefaultPort,
	)
}

// --- access info YAML ---

type accessInfo struct {
	APIURL     string `yaml:"api_url"`
	CertSHA256 string `yaml:"cert_sha256"`
}

func writeAccessInfo(apiAddr, secret, certFile, infoFile string) error {
	certSHA256 := ""
	if certFile != "" {
		cert, err := tls.LoadX509KeyPair(certFile, certFile)
		if err == nil && len(cert.Certificate) > 0 {
			hash := sha256.Sum256(cert.Certificate[0])
			certSHA256 = hex.EncodeToString(hash[:])
		}
	}

	info := accessInfo{
		APIURL:     fmt.Sprintf("https://%s/%s", apiAddr, secret),
		CertSHA256: certSHA256,
	}

	data, err := yaml.Marshal(info)
	if err != nil {
		return err
	}
	return os.WriteFile(infoFile, data, 0600)
}

// --- serve ---

func runServe(configFile string, verbose bool) error {
	logLevel := new(slog.LevelVar)
	logHandler := tint.NewHandler(
		os.Stderr,
		&tint.Options{NoColor: !term.IsTerminal(int(os.Stderr.Fd())), Level: logLevel},
	)
	logger := slog.New(logHandler)
	slog.SetDefault(logger)

	if verbose {
		logLevel.Set(slog.LevelDebug)
	}

	cfg, err := config.Load(configFile)
	if err != nil {
		return fmt.Errorf("loading config: %w", err)
	}

	// Generate server ID if not set.
	serverID := cfg.Server.ID
	if serverID == "" {
		serverID = uuid.New().String()
	}

	// Generate API secret if not set.
	apiSecret := cfg.API.Secret
	if apiSecret == "" {
		buf := make([]byte, 16)
		if _, err := rand.Read(buf); err != nil {
			return fmt.Errorf("generating API secret: %w", err)
		}
		apiSecret = hex.EncodeToString(buf)
		logger.Info("Generated API secret.", "secret", apiSecret)
	}

	// Parse NAT timeout.
	natTimeout, err := time.ParseDuration(cfg.Shadowsocks.NATTimeout)
	if err != nil {
		return fmt.Errorf("invalid NAT timeout: %w", err)
	}

	// Pick a random unused port for Shadowsocks if not configured.
	ssDefaultPort := cfg.Shadowsocks.DefaultPort
	if ssDefaultPort == 0 {
		p, err := pickRandomPort()
		if err != nil {
			return fmt.Errorf("picking random port for shadowsocks: %w", err)
		}
		ssDefaultPort = p
		logger.Info("Selected random port for new access keys.", "port", ssDefaultPort)
	}

	// Set up IP info for geo-metrics.
	var ip2info ipinfo.IPInfoMap
	if cfg.Shadowsocks.IPCountryDB != "" || cfg.Shadowsocks.IPASNDB != "" {
		mmdb, err := ipinfo.NewMMDBIPInfoMap(cfg.Shadowsocks.IPCountryDB, cfg.Shadowsocks.IPASNDB)
		if err != nil {
			return fmt.Errorf("loading IP info databases: %w", err)
		}
		defer mmdb.Close()
		ip2info = mmdb
	}

	// Set up metrics.
	serverMetrics := metrics.NewServerMetrics()
	registry, transferTracker, _, err := metrics.SetupRegistry(
		ip2info,
		serverMetrics,
		version,
		cfg.Metrics.NodeExporterCollectors,
		logger,
	)
	if err != nil {
		return fmt.Errorf("setting up metrics: %w", err)
	}

	// Set up persistent store.
	st, err := store.NewYAMLFileStore(
		cfg.StateFile(),
		serverID,
		cfg.Server.Name,
		cfg.Server.Hostname,
		cfg.Shadowsocks.DefaultCipher,
		ssDefaultPort,
	)
	if err != nil {
		return fmt.Errorf("initializing store: %w", err)
	}

	// Set up Shadowsocks server.
	ss := ssserver.New(
		natTimeout,
		cfg.Shadowsocks.ReplayHistory,
		serverMetrics,
		transferTracker,
		logger,
	)

	// Start with existing keys.
	if keys := st.ListKeys(); len(keys) > 0 {
		if err := ss.SyncKeys(keys); err != nil {
			return fmt.Errorf("starting Shadowsocks with existing keys: %w", err)
		}
		logger.Info("Loaded existing access keys.", "count", len(keys))
	}

	// Set up AmneziaWG server (optional).
	var awg *awgserver.Server
	if cfg.AmneziaWG.Enabled {
		// Ensure server has AWG keypair.
		srv := st.GetServer()
		if srv.AWGPrivateKey == "" {
			privKey, err := awgserver.GeneratePrivateKey()
			if err != nil {
				return fmt.Errorf("generating AWG server key: %w", err)
			}
			pubKey, err := awgserver.PublicKeyFromPrivate(privKey)
			if err != nil {
				return fmt.Errorf("deriving AWG server public key: %w", err)
			}
			if err := st.UpdateServer(func(s *store.ServerState) {
				s.AWGPrivateKey = privKey
				s.AWGPublicKey = pubKey
			}); err != nil {
				return fmt.Errorf("persisting AWG server key: %w", err)
			}
			logger.Info("Generated AWG server keypair.")
			srv = st.GetServer()
		}

		// Parse H1-H4 ranges.
		h1, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H1)
		if err != nil {
			return fmt.Errorf("parsing AWG H1: %w", err)
		}
		h2, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H2)
		if err != nil {
			return fmt.Errorf("parsing AWG H2: %w", err)
		}
		h3, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H3)
		if err != nil {
			return fmt.Errorf("parsing AWG H3: %w", err)
		}
		h4, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H4)
		if err != nil {
			return fmt.Errorf("parsing AWG H4: %w", err)
		}

		// Build server address from subnet (take .1).
		alloc, err := awgserver.NewIPAllocator(cfg.AmneziaWG.Address)
		if err != nil {
			return fmt.Errorf("parsing AWG address: %w", err)
		}
		serverAddr := fmt.Sprintf("%s/%d", alloc.ServerIP().String(), netip.MustParsePrefix(cfg.AmneziaWG.Address).Bits())

		awgCfg := awgserver.Config{
			ListenPort:   cfg.AmneziaWG.ListenPort,
			TUNName:      cfg.AmneziaWG.TUNName,
			Address:      serverAddr,
			MTU:          cfg.AmneziaWG.MTU,
			PrivateKey:   srv.AWGPrivateKey,
			Domain:       cfg.AmneziaWG.Domain,
			CertCache:    cfg.AmneziaWG.CertCache,
			ACMEHTTPPort: cfg.AmneziaWG.ACMEHTTPPort,
			Jc:           cfg.AmneziaWG.Jc,
			Jmin:         cfg.AmneziaWG.Jmin,
			Jmax:         cfg.AmneziaWG.Jmax,
			S1:           cfg.AmneziaWG.S1,
			S2:           cfg.AmneziaWG.S2,
			S3:           cfg.AmneziaWG.S3,
			S4:           cfg.AmneziaWG.S4,
			H1:           h1,
			H2:           h2,
			H3:           h3,
			H4:           h4,
		}

		awg = awgserver.New(awgCfg, logger)
		if err := awg.Start(); err != nil {
			return fmt.Errorf("starting AWG server: %w", err)
		}

		if keys := st.ListKeys(); len(keys) > 0 {
			if err := awg.SyncKeys(keys); err != nil {
				return fmt.Errorf("syncing AWG peers: %w", err)
			}
		}
		logger.Info("AmneziaWG server started.", "port", cfg.AmneziaWG.ListenPort)
	}

	// Start Prometheus metrics + /healthz endpoint.
	metricsHandler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
	metricsMux := http.NewServeMux()
	metricsMux.Handle("/metrics", metricsHandler)
	metricsMux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]any{
			"healthy":    true,
			"version":    version,
			"accessKeys": len(st.ListKeys()),
		})
	})
	metricsServer := &http.Server{Addr: cfg.Metrics.ListenAddr, Handler: metricsMux}
	go func() {
		logger.Info("Prometheus metrics server started.", "address", cfg.Metrics.ListenAddr)
		if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			logger.Error("Metrics server failed.", "err", err)
		}
	}()

	// Start REST API.
	var awgCfgPtr *config.AmneziaWGConfig
	if cfg.AmneziaWG.Enabled {
		awgCfgPtr = &cfg.AmneziaWG
	}
	handler := api.NewHandler(st, ss, awg, awgCfgPtr, transferTracker, version, logger)
	router := api.NewRouter(apiSecret, handler)
	apiServer := &http.Server{Addr: cfg.API.ListenAddr, Handler: router}
	go func() {
		logger.Info("Management API started.", "address", cfg.API.ListenAddr, "prefix", "/"+apiSecret)
		if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			logger.Error("API server failed.", "err", err)
		}
	}()

	// Write access info.
	infoFile := filepath.Join(filepath.Dir(cfg.StateFile()), "access.yaml")
	if err := writeAccessInfo(cfg.API.ListenAddr, apiSecret, cfg.API.CertFile, infoFile); err != nil {
		logger.Warn("Failed to write access info.", "err", err)
	} else {
		logger.Info("Access info written.", "file", infoFile)
	}

	logger.Info("outline-distro started.",
		"version", version,
		"api", cfg.API.ListenAddr,
		"metrics", cfg.Metrics.ListenAddr,
	)

	// Wait for shutdown signal.
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
	<-sigCh

	logger.Info("Shutting down...")
	if awg != nil {
		awg.Stop()
	}
	ss.Stop()
	apiServer.Close()
	metricsServer.Close()

	return nil
}

func pickRandomPort() (int, error) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return 0, err
	}
	port := ln.Addr().(*net.TCPAddr).Port
	ln.Close()
	return port, nil
}

A  => config.example.yaml +54 -0
@@ 1,54 @@
server:
  name: My Outline Server
  hostname: example.com

api:
  listen_addr: ":8081"
  secret: ""
  cert_file: ""

metrics:
  listen_addr: "127.0.0.1:8081"
  node_exporter_collectors:
    - cpu
    - meminfo
    - loadavg
    - uname
    - filesystem
    - diskstats
    - netdev

shadowsocks:
  default_port: 0  # 0 = pick random unused port on first start
  default_cipher: chacha20-ietf-poly1305
  nat_timeout: 5m
  replay_history: 10000
  ip_country_db: ""
  ip_asn_db: ""

amneziawg:
  enabled: false
  listen_port: 443          # shared UDP port for AWG + HTTP/3
  tun_name: awg0
  address: "10.14.0.0/24"   # server gets .1, peers get .2+
  mtu: 1420
  private_key: ""            # auto-generated if empty
  dns: "1.1.1.1, 8.8.8.8"
  # HTTP/3 cover for DPI resistance (requires domain)
  domain: ""                 # e.g., vpn.example.com
  cert_cache: /var/lib/outline-distro/certs
  acme_http_port: 80
  # Obfuscation parameters (must match client config)
  jc: 4
  jmin: 64
  jmax: 256
  s1: 32
  s2: 28
  s3: 20
  s4: 25
  h1: "50000-100000"
  h2: "150000-200000"
  h3: "250000-300000"
  h4: "350000-400000"

state_file: state.yaml

A  => dist/outline-distro.service +59 -0
@@ 1,59 @@
[Unit]
Description=Outline Distro — Shadowsocks + AmneziaWG VPN Server
Documentation=https://sourcecraft.dev/bigbes/outline-distro
After=network-online.target nss-lookup.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/outline-distro -c /etc/outline-distro/config.yaml
Restart=on-failure
RestartSec=5
WatchdogSec=60

# Logging — stdout/stderr go straight to journald.
# View with: journalctl -u outline-distro -f
# Filter errors: journalctl -u outline-distro -p err
StandardOutput=journal
StandardError=journal
SyslogIdentifier=outline-distro

# File descriptors
LimitNOFILE=65536

# Run as dedicated user (created by install script)
User=outline-distro
Group=outline-distro

# Capabilities — needed for:
#   CAP_NET_BIND_SERVICE  — bind to ports < 1024 (AWG on 443, ACME on 80)
#   CAP_NET_ADMIN         — create/configure TUN device (AWG)
#   CAP_NET_RAW           — raw socket for UDP listeners
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW

# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=no
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RemoveIPC=yes
SystemCallArchitectures=native

# Writable paths for state, certs, and TUN device
ReadWritePaths=/var/lib/outline-distro /etc/outline-distro /dev/net/tun

# Allow /dev/net/tun access for AWG
DeviceAllow=/dev/net/tun rw

[Install]
WantedBy=multi-user.target

A  => go.mod +71 -0
@@ 1,71 @@
module sourcecraft.dev/bigbes/outline-distro

go 1.26.1

require (
	github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629
	github.com/Jigsaw-Code/outline-ss-server v1.9.2
	github.com/amnezia-vpn/amneziawg-go v1.0.4
	github.com/google/uuid v1.6.0
	github.com/lmittmann/tint v1.1.3
	github.com/prometheus/client_golang v1.23.2
	github.com/prometheus/node_exporter v1.10.2
	github.com/quic-go/quic-go v0.59.0
	github.com/spf13/cobra v1.10.2
	golang.org/x/crypto v0.42.0
	golang.org/x/term v0.35.0
	gopkg.in/yaml.v3 v3.0.1
)

require (
	github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
	github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
	github.com/beevik/ntp v1.5.0 // indirect
	github.com/beorn7/perks v1.0.1 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/coreos/go-systemd/v22 v22.6.0 // indirect
	github.com/dennwc/btrfs v0.0.0-20241002142654-12ae127e0bf6 // indirect
	github.com/dennwc/ioctl v1.0.0 // indirect
	github.com/ema/qdisc v1.0.0 // indirect
	github.com/godbus/dbus/v5 v5.1.0 // indirect
	github.com/google/go-cmp v0.7.0 // indirect
	github.com/hashicorp/go-envparse v0.1.0 // indirect
	github.com/hodgesds/perf-utils v0.7.0 // indirect
	github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/jsimonetti/rtnetlink/v2 v2.0.5 // indirect
	github.com/kr/text v0.2.0 // indirect
	github.com/lufia/iostat v1.2.1 // indirect
	github.com/mattn/go-xmlrpc v0.0.3 // indirect
	github.com/mdlayher/ethtool v0.5.0 // indirect
	github.com/mdlayher/genetlink v1.3.2 // indirect
	github.com/mdlayher/netlink v1.8.0 // indirect
	github.com/mdlayher/socket v0.5.1 // indirect
	github.com/mdlayher/wifi v0.6.0 // indirect
	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
	github.com/opencontainers/selinux v1.12.0 // indirect
	github.com/oschwald/geoip2-golang v1.11.0 // indirect
	github.com/oschwald/maxminddb-golang v1.13.1 // indirect
	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
	github.com/prometheus-community/go-runit v0.1.0 // indirect
	github.com/prometheus/client_model v0.6.2 // indirect
	github.com/prometheus/common v0.67.1 // indirect
	github.com/prometheus/procfs v0.19.0 // indirect
	github.com/quic-go/qpack v0.6.0 // indirect
	github.com/safchain/ethtool v0.6.2 // indirect
	github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect
	github.com/spf13/pflag v1.0.10 // indirect
	github.com/tevino/abool v1.2.0 // indirect
	github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
	go.uber.org/atomic v1.11.0 // indirect
	go.uber.org/multierr v1.9.0 // indirect
	go.yaml.in/yaml/v2 v2.4.3 // indirect
	golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
	golang.org/x/net v0.44.0 // indirect
	golang.org/x/sync v0.17.0 // indirect
	golang.org/x/sys v0.37.0 // indirect
	golang.org/x/text v0.29.0 // indirect
	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
	google.golang.org/protobuf v1.36.10 // indirect
	howett.net/plist v1.0.1 // indirect
)

A  => go.sum +188 -0
@@ 1,188 @@
github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 h1:sHi1X4vwtNNBUDCbxynGXe7cM/inwTbavowHziaxlbk=
github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw=
github.com/Jigsaw-Code/outline-ss-server v1.9.2 h1:8AlzPLugCCa9H4ZIV79rWOdgVshRzKZalq8ZD+APjqk=
github.com/Jigsaw-Code/outline-ss-server v1.9.2/go.mod h1:v0jS3ExOGwGTbWTpOw16/sid91k7PKxazdK9eLCpUlQ=
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/amnezia-vpn/amneziawg-go v1.0.4 h1:hyS3dEY+znvfGVZznYdLWaKGPBwJzGqJLuO/9s3sTok=
github.com/amnezia-vpn/amneziawg-go v1.0.4/go.mod h1:uD0Cz0XbnhE0k1vpJZiUq47YDP5vne9FtV9Bc1lQJEs=
github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4=
github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao=
github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dennwc/btrfs v0.0.0-20241002142654-12ae127e0bf6 h1:fV+JlCY0cCJh3l0jfE7iB3ZmrdfJSgfcjdrCQhPokGg=
github.com/dennwc/btrfs v0.0.0-20241002142654-12ae127e0bf6/go.mod h1:MYsOV9Dgsec3FFSOjywi0QK5r6TeBbdWxdrMGtiYXHA=
github.com/dennwc/ioctl v1.0.0 h1:DsWAAjIxRqNcLn9x6mwfuf2pet3iB7aK90K4tF16rLg=
github.com/dennwc/ioctl v1.0.0/go.mod h1:ellh2YB5ldny99SBU/VX7Nq0xiZbHphf1DrtHxxjMk0=
github.com/ema/qdisc v1.0.0 h1:EHLG08FVRbWLg8uRICa3xzC9Zm0m7HyMHfXobWFnXYg=
github.com/ema/qdisc v1.0.0/go.mod h1:FhIc0fLYi7f+lK5maMsesDqwYojIOh3VfRs8EVd5YJQ=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
github.com/hodgesds/perf-utils v0.7.0 h1:7KlHGMuig4FRH5fNw68PV6xLmgTe7jKs9hgAcEAbioU=
github.com/hodgesds/perf-utils v0.7.0/go.mod h1:LAklqfDadNKpkxoAJNHpD5tkY0rkZEVdnCEWN5k4QJY=
github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 h1:hk4LPqXIY/c9XzRbe7dA6qQxaT6Axcbny0L/G5a4owQ=
github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:PoK3ejP3LJkGTzKqRlpvCIFas3ncU02v8zzWDW+g0FY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jsimonetti/rtnetlink/v2 v2.0.5 h1:l5S9iedrSW4thUfgiU+Hzsnk1cOR0upGD5ttt6mirHw=
github.com/jsimonetti/rtnetlink/v2 v2.0.5/go.mod h1:9yTlq3Ojr1rbmh/Y5L30/KIojpFhTRph2xKeZ+y+Pic=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lufia/iostat v1.2.1 h1:tnCdZBIglgxD47RyD55kfWQcJMGzO+1QBziSQfesf2k=
github.com/lufia/iostat v1.2.1/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg=
github.com/mattn/go-xmlrpc v0.0.3 h1:Y6WEMLEsqs3RviBrAa1/7qmbGB7DVD3brZIbqMbQdGY=
github.com/mattn/go-xmlrpc v0.0.3/go.mod h1:mqc2dz7tP5x5BKlCahN/n+hs7OSZKJkS9JsHNBRlrxA=
github.com/mdlayher/ethtool v0.5.0 h1:7MpuhvUE574uVQDfkXotePLdfSNetlx3GDikFcdlVQA=
github.com/mdlayher/ethtool v0.5.0/go.mod h1:ROV9hwnETqDdpLv8E8WkCa8FymlkhFEeiB9cg3qzNkk=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/mdlayher/wifi v0.6.0 h1:yBVPVgyCWcdyLkztUVM2Czd2XFKRJegHOoBm2gBWKG8=
github.com/mdlayher/wifi v0.6.0/go.mod h1:qwcTzRuC2bV+s4PFhGMzPi0sFHAr2jXkUSumSMIU6+4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8=
github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus-community/go-runit v0.1.0 h1:uTWEj/Fn2RoLdfg/etSqwzgYNOYPrARx1BHUN052tGA=
github.com/prometheus-community/go-runit v0.1.0/go.mod h1:AvJ9Jo3gAFu2lbM4+qfjdpq30FfiLDJZKbQ015u08IQ=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/node_exporter v1.10.2 h1:H88cUFLuB8Jn/u2U3M4D5KYnae07LIm+0ZTcgoKEK54=
github.com/prometheus/node_exporter v1.10.2/go.mod h1:F9EKoxCWmKgzJHBfL1EKEvxaGWyahuKZpxArLSI70lA=
github.com/prometheus/procfs v0.19.0 h1:2gU9KiEMZUhDokz1/0GToOjT7ljqxHi+GhEjk9UUMgU=
github.com/prometheus/procfs v0.19.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.6.2 h1:O3ZPFAKEUEfbtE6J/feEe2Ft7dIJ2Sy8t4SdMRiIMHY=
github.com/safchain/ethtool v0.6.2/go.mod h1:VS7cn+bP3Px3rIq55xImBiZGHVLNyBh5dqG6dDQy8+I=
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973 h1:GfSdC6wKfTGcgCS7BtzF5694Amne1pGCSTY252WhlEY=
github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:G81aIFAMS9ECrwBYR9YxhlPjWgrItd+Kje78O6+uqm8=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA=
github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211031064116-611d5d643895/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20250606233247-e3c4c4cad86f h1:zmc4cHEcCudRt2O8VsCW7nYLfAsbVY2i910/DAop1TM=
gvisor.dev/gvisor v0.0.0-20250606233247-e3c4c4cad86f/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=

A  => install.sh +374 -0
@@ 1,374 @@
#!/usr/bin/env bash
#
# outline-distro installer (Ubuntu)
#
# Builds from source and installs outline-distro as a systemd service
# with Shadowsocks + optional AmneziaWG (HTTP/3 mux for DPI resistance).
#
# Usage:
#   curl -sSL <url>/install.sh | bash                  # SS only
#   curl -sSL <url>/install.sh | bash -s -- --awg      # SS + AmneziaWG
#   curl -sSL <url>/install.sh | bash -s -- --awg --domain vpn.example.com
#
# Requirements: Go 1.22+, root, git, Ubuntu 20.04+
#
set -euo pipefail

# --- Paths ---

INSTALL_DIR="/opt/outline-distro"
CONFIG_DIR="/etc/outline-distro"
DATA_DIR="/var/lib/outline-distro"
BINARY="/usr/local/bin/outline-distro"
SERVICE_NAME="outline-distro"
SERVICE_USER="outline-distro"
UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
REPO_URL="https://sourcecraft.dev/bigbes/outline-distro.git"

# --- Config defaults ---

SERVER_NAME="Outline Server"
HOSTNAME=""
API_PORT="8081"
METRICS_PORT="9091"
SS_PORT="0"
SS_CIPHER="chacha20-ietf-poly1305"

AWG_ENABLED="false"
AWG_PORT="443"
AWG_SUBNET="10.14.0.0/24"
AWG_DNS="1.1.1.1, 8.8.8.8"
AWG_DOMAIN=""
AWG_MTU="1420"
AWG_TUN="awg0"

# --- Parse arguments ---

while [[ $# -gt 0 ]]; do
    case "$1" in
        --awg)          AWG_ENABLED="true"; shift ;;
        --domain)       AWG_DOMAIN="$2"; shift 2 ;;
        --hostname)     HOSTNAME="$2"; shift 2 ;;
        --name)         SERVER_NAME="$2"; shift 2 ;;
        --ss-port)      SS_PORT="$2"; shift 2 ;;
        --awg-port)     AWG_PORT="$2"; shift 2 ;;
        --awg-subnet)   AWG_SUBNET="$2"; shift 2 ;;
        --awg-dns)      AWG_DNS="$2"; shift 2 ;;
        --api-port)     API_PORT="$2"; shift 2 ;;
        --metrics-port) METRICS_PORT="$2"; shift 2 ;;
        --help|-h)
            cat <<'USAGE'
Usage: install.sh [OPTIONS]

Options:
  --awg                  Enable AmneziaWG protocol (with HTTP/3 mux)
  --domain DOMAIN        Domain for Let's Encrypt (enables HTTP/3 cover)
  --hostname HOST        Server hostname/IP for access key URLs
  --name NAME            Server display name
  --ss-port PORT         Shadowsocks port (0 = random)
  --awg-port PORT        AmneziaWG + HTTP/3 listen port (default: 443)
  --awg-subnet CIDR      AWG peer subnet (default: 10.14.0.0/24)
  --awg-dns DNS          DNS for AWG clients (default: 1.1.1.1, 8.8.8.8)
  --api-port PORT        Management API port (default: 8081)
  --metrics-port PORT    Prometheus metrics port (default: 9091)
USAGE
            exit 0
            ;;
        *) echo "Unknown option: $1"; exit 1 ;;
    esac
done

# --- Helpers ---

info()  { echo -e "\033[1;34m==>\033[0m $*"; }
ok()    { echo -e "\033[1;32m==>\033[0m $*"; }
warn()  { echo -e "\033[1;33mWARN:\033[0m $*"; }
fail()  { echo -e "\033[1;31mERROR:\033[0m $*" >&2; exit 1; }

# --- Preflight ---

[[ $EUID -eq 0 ]] || fail "This script must be run as root."

. /etc/os-release 2>/dev/null || true
if [[ "${ID:-}" != "ubuntu" && "${ID_LIKE:-}" != *"ubuntu"* && "${ID_LIKE:-}" != *"debian"* ]]; then
    warn "This script is designed for Ubuntu. Proceeding anyway..."
fi

command -v go  >/dev/null 2>&1 || fail "Go is not installed. Install Go 1.22+: https://go.dev/dl/"
command -v git >/dev/null 2>&1 || fail "git is not installed: apt install git"

GO_VER=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+')
info "Go $GO_VER detected"

if [[ -z "$HOSTNAME" ]]; then
    HOSTNAME=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || hostname -f 2>/dev/null || echo "localhost")
    info "Auto-detected hostname: $HOSTNAME"
fi

# --- Create system user ---

if ! id "$SERVICE_USER" &>/dev/null; then
    info "Creating system user $SERVICE_USER..."
    useradd --system --home-dir "$DATA_DIR" --shell /usr/sbin/nologin \
        --comment "Outline Distro VPN" "$SERVICE_USER"
    ok "User $SERVICE_USER created"
fi

# --- Build from source ---

info "Cloning/updating repository..."
if [[ -d "$INSTALL_DIR/.git" ]]; then
    cd "$INSTALL_DIR"
    git pull --ff-only 2>/dev/null || git fetch --all
else
    rm -rf "$INSTALL_DIR"
    git clone "$REPO_URL" "$INSTALL_DIR"
    cd "$INSTALL_DIR"
fi

VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
info "Building $VERSION..."
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$VERSION" \
    -o "$BINARY" ./cmd/outline-distro/
ok "Binary: $BINARY ($VERSION)"

# --- Directories ---

mkdir -p "$CONFIG_DIR" "$DATA_DIR/certs"
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$DATA_DIR/certs"
chmod 750 "$DATA_DIR"

# Ensure TUN device node exists (needed inside PrivateDevices=no).
if [[ ! -c /dev/net/tun ]]; then
    mkdir -p /dev/net
    mknod /dev/net/tun c 10 200
    chmod 0666 /dev/net/tun
fi

# --- Generate config ---

CONFIG_FILE="$CONFIG_DIR/config.yaml"

if [[ -f "$CONFIG_FILE" ]]; then
    warn "Config exists: $CONFIG_FILE — skipping generation."
else
    info "Generating config..."

    API_SECRET=$(head -c 16 /dev/urandom | xxd -p)

    cat > "$CONFIG_FILE" <<YAML
server:
  name: "${SERVER_NAME}"
  hostname: "${HOSTNAME}"

api:
  listen_addr: ":${API_PORT}"
  secret: "${API_SECRET}"

metrics:
  listen_addr: "127.0.0.1:${METRICS_PORT}"
  node_exporter_collectors:
    - cpu
    - meminfo
    - loadavg
    - uname
    - filesystem
    - diskstats
    - netdev

shadowsocks:
  default_port: ${SS_PORT}
  default_cipher: ${SS_CIPHER}
  nat_timeout: 5m
  replay_history: 10000

amneziawg:
  enabled: ${AWG_ENABLED}
  listen_port: ${AWG_PORT}
  tun_name: ${AWG_TUN}
  address: "${AWG_SUBNET}"
  mtu: ${AWG_MTU}
  dns: "${AWG_DNS}"
  domain: "${AWG_DOMAIN}"
  cert_cache: ${DATA_DIR}/certs
  acme_http_port: 80
  jc: 4
  jmin: 64
  jmax: 256
  s1: 32
  s2: 28
  s3: 20
  s4: 25
  h1: "50000-100000"
  h2: "150000-200000"
  h3: "250000-300000"
  h4: "350000-400000"

state_file: ${DATA_DIR}/state.yaml
YAML

    chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG_FILE"
    chmod 640 "$CONFIG_FILE"
    ok "Config: $CONFIG_FILE"
fi

# --- Install systemd unit ---

info "Installing systemd unit..."

# Use the bundled unit file if present, otherwise generate one.
BUNDLED_UNIT="$INSTALL_DIR/dist/outline-distro.service"
if [[ -f "$BUNDLED_UNIT" ]]; then
    cp "$BUNDLED_UNIT" "$UNIT_FILE"
else
    cat > "$UNIT_FILE" <<UNIT
[Unit]
Description=Outline Distro — Shadowsocks + AmneziaWG VPN Server
Documentation=https://sourcecraft.dev/bigbes/outline-distro
After=network-online.target nss-lookup.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=${BINARY} -c ${CONFIG_FILE}
Restart=on-failure
RestartSec=5
WatchdogSec=60
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
LimitNOFILE=65536
User=${SERVICE_USER}
Group=${SERVICE_USER}
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=no
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RemoveIPC=yes
SystemCallArchitectures=native
ReadWritePaths=${DATA_DIR} ${CONFIG_DIR} /dev/net/tun
DeviceAllow=/dev/net/tun rw

[Install]
WantedBy=multi-user.target
UNIT
fi

systemctl daemon-reload
systemctl enable "$SERVICE_NAME" --quiet
ok "Systemd unit installed and enabled"

# --- Firewall (ufw on Ubuntu) ---

if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "active"; then
    info "Configuring ufw firewall rules..."
    ufw allow "$API_PORT"/tcp comment "outline-distro API" >/dev/null 2>&1 || true
    if [[ "$AWG_ENABLED" == "true" ]]; then
        ufw allow "$AWG_PORT"/udp comment "outline-distro AWG+HTTP3" >/dev/null 2>&1 || true
        ufw allow 80/tcp comment "outline-distro ACME" >/dev/null 2>&1 || true
    fi
    ok "Firewall rules added"
else
    warn "ufw is not active. Ensure these ports are open:"
    warn "  TCP ${API_PORT} (management API)"
    [[ "$AWG_ENABLED" == "true" ]] && warn "  UDP ${AWG_PORT} (AWG + HTTP/3)  |  TCP 80 (ACME)"
fi

# --- IP forwarding for AWG ---

if [[ "$AWG_ENABLED" == "true" ]]; then
    info "Enabling IPv4 forwarding..."
    sysctl -q -w net.ipv4.ip_forward=1
    # Persist via sysctl.d drop-in (idempotent).
    cat > /etc/sysctl.d/99-outline-distro.conf <<'SYSCTL'
# Enable IPv4 forwarding for AmneziaWG VPN
net.ipv4.ip_forward=1
SYSCTL
    ok "IPv4 forwarding enabled"
fi

# --- Start service ---

info "Starting ${SERVICE_NAME}..."

# Stop if already running (upgrade case).
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
systemctl start "$SERVICE_NAME"

# Wait for journal to confirm startup.
for i in 1 2 3 4 5; do
    if systemctl is-active --quiet "$SERVICE_NAME"; then
        break
    fi
    sleep 1
done

if systemctl is-active --quiet "$SERVICE_NAME"; then
    ok "Service is running"
else
    echo ""
    echo "Service failed to start. Recent logs:"
    journalctl -u "$SERVICE_NAME" -n 20 --no-pager
    fail "Check full logs: journalctl -u $SERVICE_NAME"
fi

# --- Summary ---

API_SECRET_DISPLAY=$(grep -oP 'secret:\s*"\K[^"]+' "$CONFIG_FILE" 2>/dev/null || echo "<see config>")

cat <<EOF

=============================================
  outline-distro installed successfully
=============================================

  Config:    ${CONFIG_FILE}
  State:     ${DATA_DIR}/state.yaml
  Binary:    ${BINARY}
  User:      ${SERVICE_USER}

  Service management:
    systemctl start   ${SERVICE_NAME}
    systemctl stop    ${SERVICE_NAME}
    systemctl restart ${SERVICE_NAME}
    systemctl status  ${SERVICE_NAME}

  Logs (journald):
    journalctl -u ${SERVICE_NAME} -f           # follow
    journalctl -u ${SERVICE_NAME} --since today # today's logs
    journalctl -u ${SERVICE_NAME} -p err        # errors only

  API: http://${HOSTNAME}:${API_PORT}/${API_SECRET_DISPLAY}/server

  Quick start:
    ${BINARY} key add -n user1 -c ${CONFIG_FILE}
    ${BINARY} key list -c ${CONFIG_FILE}

EOF

if [[ "$AWG_ENABLED" == "true" ]]; then
    cat <<EOF
  AmneziaWG: UDP :${AWG_PORT} (+ HTTP/3 mux)
EOF
    [[ -n "$AWG_DOMAIN" ]] && echo "  HTTP/3:    https://${AWG_DOMAIN}:${AWG_PORT}/"
    cat <<EOF

  Download client config:
    curl -s http://127.0.0.1:${API_PORT}/${API_SECRET_DISPLAY}/access-keys/<ID>/awg-config

EOF
fi

echo "  Open the Shadowsocks port once keys are created (see 'key list')."
echo ""

A  => internal/api/handlers.go +415 -0
@@ 1,415 @@
package api

import (
	"fmt"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/outline-distro/internal/awgserver"
	"sourcecraft.dev/bigbes/outline-distro/internal/store"
)

func (h *Handler) getServer(w http.ResponseWriter, r *http.Request) {
	srv := h.store.GetServer()
	resp := ServerResponse{
		Name:                  srv.Name,
		ServerID:              srv.ID,
		MetricsEnabled:        srv.MetricsEnabled,
		CreatedTimestampMs:    srv.CreatedTimestampMs,
		Version:               h.version,
		HostnameForAccessKeys: srv.Hostname,
		PortForNewAccessKeys:  srv.PortForNewAccessKeys,
	}
	if srv.AccessKeyDataLimit != nil {
		resp.AccessKeyDataLimit = &DataLimitJSON{Bytes: srv.AccessKeyDataLimit.Bytes}
	}
	writeJSON(w, http.StatusOK, resp)
}

func (h *Handler) renameServer(w http.ResponseWriter, r *http.Request) {
	var req SetNameRequest
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if err := h.store.UpdateServer(func(s *store.ServerState) {
		s.Name = req.Name
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) listAccessKeys(w http.ResponseWriter, r *http.Request) {
	keys := h.store.ListKeys()
	resp := make([]AccessKeyResponse, len(keys))
	for i, k := range keys {
		resp[i] = h.keyToResponse(k)
	}
	writeJSON(w, http.StatusOK, map[string]any{"accessKeys": resp})
}

func (h *Handler) getAccessKey(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	key, ok := h.store.GetKey(id)
	if !ok {
		writeError(w, http.StatusNotFound, "key not found")
		return
	}
	writeJSON(w, http.StatusOK, h.keyToResponse(key))
}

func (h *Handler) createAccessKey(w http.ResponseWriter, r *http.Request) {
	var req CreateAccessKeyRequest
	if r.ContentLength > 0 {
		if err := readJSON(r, &req); err != nil {
			writeError(w, http.StatusBadRequest, "invalid request body")
			return
		}
	}
	h.doCreateKey(w, req)
}

func (h *Handler) createAccessKeyWithID(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req CreateAccessKeyRequest
	if r.ContentLength > 0 {
		if err := readJSON(r, &req); err != nil {
			writeError(w, http.StatusBadRequest, "invalid request body")
			return
		}
	}
	req.ID = id
	h.doCreateKey(w, req)
}

func (h *Handler) doCreateKey(w http.ResponseWriter, req CreateAccessKeyRequest) {
	srv := h.store.GetServer()

	password := req.Password
	if password == "" {
		var err error
		password, err = store.GeneratePassword()
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to generate password")
			return
		}
	}

	id := req.ID
	if id == "" {
		id = fmt.Sprintf("%d", srv.NextID)
		if err := h.store.UpdateServer(func(s *store.ServerState) {
			s.NextID++
		}); err != nil {
			writeError(w, http.StatusInternalServerError, err.Error())
			return
		}
	}

	method := req.Method
	if method == "" {
		method = srv.DefaultCipher
	}

	port := req.Port
	if port == 0 {
		port = srv.PortForNewAccessKeys
	}

	ak := store.AccessKey{
		ID:       id,
		Name:     req.Name,
		Password: password,
		Port:     port,
		Method:   method,
	}
	if req.DataLimit != nil {
		ak.DataLimit = &store.DataLimit{Bytes: req.DataLimit.Bytes}
	}

	// Generate AWG credentials if AWG is enabled.
	if h.awgServer != nil && h.awgConfig != nil {
		awgData, err := h.generateAWGKey()
		if err != nil {
			h.logger.Error("Failed to generate AWG key.", "err", err)
			writeError(w, http.StatusInternalServerError, "failed to generate AWG credentials")
			return
		}
		ak.AWG = awgData
	}

	if err := h.store.CreateKey(ak); err != nil {
		writeError(w, http.StatusConflict, err.Error())
		return
	}

	if err := h.syncKeys(); err != nil {
		h.logger.Error("Failed to sync keys after create.", "err", err)
		writeError(w, http.StatusInternalServerError, "failed to apply key configuration")
		return
	}

	writeJSON(w, http.StatusCreated, h.keyToResponse(ak))
}

func (h *Handler) deleteAccessKey(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if err := h.store.DeleteKey(id); err != nil {
		writeError(w, http.StatusNotFound, "key not found")
		return
	}
	if err := h.syncKeys(); err != nil {
		h.logger.Error("Failed to sync keys after delete.", "err", err)
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) renameAccessKey(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req SetNameRequest
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if err := h.store.UpdateKey(id, func(k *store.AccessKey) {
		k.Name = req.Name
	}); err != nil {
		writeError(w, http.StatusNotFound, "key not found")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) setKeyDataLimit(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req SetDataLimitRequest
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if err := h.store.UpdateKey(id, func(k *store.AccessKey) {
		k.DataLimit = &store.DataLimit{Bytes: req.Limit.Bytes}
	}); err != nil {
		writeError(w, http.StatusNotFound, "key not found")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) removeKeyDataLimit(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if err := h.store.UpdateKey(id, func(k *store.AccessKey) {
		k.DataLimit = nil
	}); err != nil {
		writeError(w, http.StatusNotFound, "key not found")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) setDefaultDataLimit(w http.ResponseWriter, r *http.Request) {
	var req SetDataLimitRequest
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if err := h.store.UpdateServer(func(s *store.ServerState) {
		s.AccessKeyDataLimit = &store.DataLimit{Bytes: req.Limit.Bytes}
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) removeDefaultDataLimit(w http.ResponseWriter, r *http.Request) {
	if err := h.store.UpdateServer(func(s *store.ServerState) {
		s.AccessKeyDataLimit = nil
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) setDefaultPort(w http.ResponseWriter, r *http.Request) {
	var req SetPortRequest
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if req.Port < 1 || req.Port > 65535 {
		writeError(w, http.StatusBadRequest, "port must be between 1 and 65535")
		return
	}
	if err := h.store.UpdateServer(func(s *store.ServerState) {
		s.PortForNewAccessKeys = req.Port
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) setHostname(w http.ResponseWriter, r *http.Request) {
	var req SetHostnameRequest
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if err := h.store.UpdateServer(func(s *store.ServerState) {
		s.Hostname = req.Hostname
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) getMetricsEnabled(w http.ResponseWriter, r *http.Request) {
	srv := h.store.GetServer()
	writeJSON(w, http.StatusOK, MetricsEnabledResponse{MetricsEnabled: srv.MetricsEnabled})
}

func (h *Handler) setMetricsEnabled(w http.ResponseWriter, r *http.Request) {
	var req MetricsEnabledResponse
	if err := readJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	if err := h.store.UpdateServer(func(s *store.ServerState) {
		s.MetricsEnabled = req.MetricsEnabled
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) getTransferMetrics(w http.ResponseWriter, r *http.Request) {
	transfer := h.tracker.GetTransferByKey()
	writeJSON(w, http.StatusOK, TransferMetricsResponse{BytesTransferredByUserId: transfer})
}

func (h *Handler) keyToResponse(k store.AccessKey) AccessKeyResponse {
	resp := AccessKeyResponse{
		ID:        k.ID,
		Name:      k.Name,
		Password:  k.Password,
		Port:      k.Port,
		Method:    k.Method,
		AccessURL: h.accessURL(k),
	}
	if k.DataLimit != nil {
		resp.DataLimit = &DataLimitJSON{Bytes: k.DataLimit.Bytes}
	}
	if k.AWG != nil && h.awgConfig != nil {
		srv := h.store.GetServer()
		hostname := srv.Hostname
		if hostname == "" {
			hostname = "localhost"
		}
		resp.AWG = &AWGKeyResponse{
			PublicKey: k.AWG.PublicKey,
			AllowedIP: k.AWG.AllowedIP,
			Endpoint:  fmt.Sprintf("%s:%d", hostname, h.awgConfig.ListenPort),
		}
	}
	return resp
}

func (h *Handler) generateAWGKey() (*store.AWGKeyData, error) {
	privKey, err := awgserver.GeneratePrivateKey()
	if err != nil {
		return nil, fmt.Errorf("generating private key: %w", err)
	}
	pubKey, err := awgserver.PublicKeyFromPrivate(privKey)
	if err != nil {
		return nil, fmt.Errorf("deriving public key: %w", err)
	}

	// Collect used IPs from existing keys.
	keys := h.store.ListKeys()
	used := make([]string, 0, len(keys))
	for _, k := range keys {
		if k.AWG != nil {
			used = append(used, k.AWG.AllowedIP)
		}
	}

	alloc, err := awgserver.NewIPAllocator(h.awgConfig.Address)
	if err != nil {
		return nil, fmt.Errorf("creating IP allocator: %w", err)
	}
	ip, err := alloc.Allocate(used)
	if err != nil {
		return nil, fmt.Errorf("allocating IP: %w", err)
	}

	return &store.AWGKeyData{
		PrivateKey: privKey,
		PublicKey:  pubKey,
		AllowedIP:  ip,
	}, nil
}

func (h *Handler) getAWGConfig(w http.ResponseWriter, r *http.Request) {
	if h.awgServer == nil || h.awgConfig == nil {
		writeError(w, http.StatusNotFound, "AmneziaWG is not enabled")
		return
	}

	id := r.PathValue("id")
	key, ok := h.store.GetKey(id)
	if !ok {
		writeError(w, http.StatusNotFound, "key not found")
		return
	}
	if key.AWG == nil {
		writeError(w, http.StatusNotFound, "key has no AWG credentials")
		return
	}

	srv := h.store.GetServer()
	hostname := srv.Hostname
	if hostname == "" {
		hostname = "localhost"
	}

	var b strings.Builder
	fmt.Fprintf(&b, "[Interface]\n")
	fmt.Fprintf(&b, "PrivateKey = %s\n", key.AWG.PrivateKey)
	fmt.Fprintf(&b, "Address = %s\n", key.AWG.AllowedIP)
	if h.awgConfig.DNS != "" {
		fmt.Fprintf(&b, "DNS = %s\n", h.awgConfig.DNS)
	}
	fmt.Fprintf(&b, "MTU = %d\n", h.awgConfig.MTU)

	// Obfuscation parameters.
	if h.awgConfig.Jc > 0 {
		fmt.Fprintf(&b, "Jc = %d\n", h.awgConfig.Jc)
		fmt.Fprintf(&b, "Jmin = %d\n", h.awgConfig.Jmin)
		fmt.Fprintf(&b, "Jmax = %d\n", h.awgConfig.Jmax)
	}
	fmt.Fprintf(&b, "S1 = %d\n", h.awgConfig.S1)
	fmt.Fprintf(&b, "S2 = %d\n", h.awgConfig.S2)
	fmt.Fprintf(&b, "S3 = %d\n", h.awgConfig.S3)
	fmt.Fprintf(&b, "S4 = %d\n", h.awgConfig.S4)
	fmt.Fprintf(&b, "H1 = %s\n", h.awgConfig.H1)
	fmt.Fprintf(&b, "H2 = %s\n", h.awgConfig.H2)
	fmt.Fprintf(&b, "H3 = %s\n", h.awgConfig.H3)
	fmt.Fprintf(&b, "H4 = %s\n", h.awgConfig.H4)

	fmt.Fprintf(&b, "\n[Peer]\n")
	fmt.Fprintf(&b, "PublicKey = %s\n", srv.AWGPublicKey)
	fmt.Fprintf(&b, "Endpoint = %s:%d\n", hostname, h.awgConfig.ListenPort)
	fmt.Fprintf(&b, "AllowedIPs = 0.0.0.0/0, ::/0\n")
	fmt.Fprintf(&b, "PersistentKeepalive = 25\n")

	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-awg.conf"`, key.Name))
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(b.String()))
}

A  => internal/api/models.go +66 -0
@@ 1,66 @@
package api

type AccessKeyResponse struct {
	ID        string         `json:"id"`
	Name      string         `json:"name"`
	Password  string         `json:"password"`
	Port      int            `json:"port"`
	Method    string         `json:"method"`
	AccessURL string         `json:"accessUrl"`
	DataLimit *DataLimitJSON `json:"dataLimit,omitempty"`
	AWG       *AWGKeyResponse `json:"awg,omitempty"`
}

type AWGKeyResponse struct {
	PublicKey string `json:"publicKey"`
	AllowedIP string `json:"allowedIP"`
	Endpoint  string `json:"endpoint"`
}

type DataLimitJSON struct {
	Bytes int64 `json:"bytes"`
}

type ServerResponse struct {
	Name                string         `json:"name"`
	ServerID            string         `json:"serverId"`
	MetricsEnabled      bool           `json:"metricsEnabled"`
	CreatedTimestampMs  int64          `json:"createdTimestampMs"`
	Version             string         `json:"version"`
	HostnameForAccessKeys string       `json:"hostnameForAccessKeys"`
	PortForNewAccessKeys  int          `json:"portForNewAccessKeys"`
	AccessKeyDataLimit    *DataLimitJSON `json:"accessKeyDataLimit,omitempty"`
}

type CreateAccessKeyRequest struct {
	ID        string         `json:"id,omitempty"`
	Name      string         `json:"name,omitempty"`
	Method    string         `json:"method,omitempty"`
	Password  string         `json:"password,omitempty"`
	Port      int            `json:"port,omitempty"`
	DataLimit *DataLimitJSON `json:"limit,omitempty"`
}

type SetDataLimitRequest struct {
	Limit DataLimitJSON `json:"limit"`
}

type SetPortRequest struct {
	Port int `json:"port"`
}

type SetHostnameRequest struct {
	Hostname string `json:"hostname"`
}

type SetNameRequest struct {
	Name string `json:"name"`
}

type MetricsEnabledResponse struct {
	MetricsEnabled bool `json:"metricsEnabled"`
}

type TransferMetricsResponse struct {
	BytesTransferredByUserId map[string]int64 `json:"bytesTransferredByUserId"`
}

A  => internal/api/router.go +129 -0
@@ 1,129 @@
package api

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"

	"sourcecraft.dev/bigbes/outline-distro/internal/awgserver"
	"sourcecraft.dev/bigbes/outline-distro/internal/config"
	"sourcecraft.dev/bigbes/outline-distro/internal/metrics"
	"sourcecraft.dev/bigbes/outline-distro/internal/ssserver"
	"sourcecraft.dev/bigbes/outline-distro/internal/store"
)

type Handler struct {
	store     store.Store
	ssServer  *ssserver.Server
	awgServer *awgserver.Server
	awgConfig *config.AmneziaWGConfig
	tracker   *metrics.TransferTracker
	version   string
	logger    *slog.Logger
}

func NewHandler(s store.Store, ss *ssserver.Server, awg *awgserver.Server, awgCfg *config.AmneziaWGConfig, tracker *metrics.TransferTracker, version string, logger *slog.Logger) *Handler {
	return &Handler{
		store:     s,
		ssServer:  ss,
		awgServer: awg,
		awgConfig: awgCfg,
		tracker:   tracker,
		version:   version,
		logger:    logger,
	}
}

// NewRouter creates an HTTP mux with all API routes under the given secret prefix.
func NewRouter(secret string, h *Handler) http.Handler {
	mux := http.NewServeMux()
	prefix := "/" + secret

	// Server endpoints.
	mux.HandleFunc("GET "+prefix+"/server", h.getServer)
	mux.HandleFunc("PUT "+prefix+"/name", h.renameServer)

	// Access key endpoints.
	mux.HandleFunc("GET "+prefix+"/access-keys", h.listAccessKeys)
	mux.HandleFunc("POST "+prefix+"/access-keys", h.createAccessKey)
	mux.HandleFunc("GET "+prefix+"/access-keys/{id}", h.getAccessKey)
	mux.HandleFunc("PUT "+prefix+"/access-keys/{id}", h.createAccessKeyWithID)
	mux.HandleFunc("DELETE "+prefix+"/access-keys/{id}", h.deleteAccessKey)
	mux.HandleFunc("PUT "+prefix+"/access-keys/{id}/name", h.renameAccessKey)

	// Data limit endpoints.
	mux.HandleFunc("PUT "+prefix+"/access-keys/{id}/data-limit", h.setKeyDataLimit)
	mux.HandleFunc("DELETE "+prefix+"/access-keys/{id}/data-limit", h.removeKeyDataLimit)
	mux.HandleFunc("PUT "+prefix+"/server/access-key-data-limit", h.setDefaultDataLimit)
	mux.HandleFunc("DELETE "+prefix+"/server/access-key-data-limit", h.removeDefaultDataLimit)

	// Port and hostname.
	mux.HandleFunc("PUT "+prefix+"/server/port-for-new-access-keys", h.setDefaultPort)
	mux.HandleFunc("PUT "+prefix+"/server/hostname-for-access-keys", h.setHostname)

	// AmneziaWG client config download.
	mux.HandleFunc("GET "+prefix+"/access-keys/{id}/awg-config", h.getAWGConfig)

	// Metrics endpoints.
	mux.HandleFunc("GET "+prefix+"/metrics/enabled", h.getMetricsEnabled)
	mux.HandleFunc("PUT "+prefix+"/metrics/enabled", h.setMetricsEnabled)
	mux.HandleFunc("GET "+prefix+"/metrics/transfer", h.getTransferMetrics)

	return corsMiddleware(mux)
}

func corsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (h *Handler) syncKeys() error {
	keys := h.store.ListKeys()
	if err := h.ssServer.SyncKeys(keys); err != nil {
		return err
	}
	if h.awgServer != nil {
		if err := h.awgServer.SyncKeys(keys); err != nil {
			return err
		}
	}
	return nil
}

func (h *Handler) accessURL(key store.AccessKey) string {
	srv := h.store.GetServer()
	hostname := srv.Hostname
	if hostname == "" {
		hostname = "localhost"
	}
	// SIP002 URI format: ss://base64(method:password)@host:port#tag
	cred := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(
		[]byte(fmt.Sprintf("%s:%s", key.Method, key.Password)),
	)
	return fmt.Sprintf("ss://%s@%s:%d#%s", cred, hostname, key.Port, key.Name)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	http.Error(w, msg, status)
}

func readJSON(r *http.Request, v any) error {
	defer r.Body.Close()
	return json.NewDecoder(r.Body).Decode(v)
}

A  => internal/awgserver/detect.go +51 -0
@@ 1,51 @@
package awgserver

import "encoding/binary"

// WireGuard message sizes (from noise-protocol.go).
const (
	messageInitiationSize      = 148
	messageResponseSize        = 92
	messageCookieReplySize     = 64
	messageTransportHeaderSize = 16
)

// isAWGPacket checks if the packet matches any AmneziaWG message type
// using the S1-S4 size offsets and H1-H4 header ranges.
func isAWGPacket(pkt []byte, cfg *Config) bool {
	size := len(pkt)

	// Initiation: exact size S1+148, H1 at offset S1.
	if size == cfg.S1+messageInitiationSize && size >= cfg.S1+4 {
		hdr := binary.LittleEndian.Uint32(pkt[cfg.S1:])
		if cfg.H1.Contains(hdr) {
			return true
		}
	}

	// Response: exact size S2+92, H2 at offset S2.
	if size == cfg.S2+messageResponseSize && size >= cfg.S2+4 {
		hdr := binary.LittleEndian.Uint32(pkt[cfg.S2:])
		if cfg.H2.Contains(hdr) {
			return true
		}
	}

	// Cookie Reply: exact size S3+64, H3 at offset S3.
	if size == cfg.S3+messageCookieReplySize && size >= cfg.S3+4 {
		hdr := binary.LittleEndian.Uint32(pkt[cfg.S3:])
		if cfg.H3.Contains(hdr) {
			return true
		}
	}

	// Transport: size >= S4+16, H4 at offset S4.
	if size >= cfg.S4+messageTransportHeaderSize && size >= cfg.S4+4 {
		hdr := binary.LittleEndian.Uint32(pkt[cfg.S4:])
		if cfg.H4.Contains(hdr) {
			return true
		}
	}

	return false
}

A  => internal/awgserver/detect_test.go +71 -0
@@ 1,71 @@
package awgserver

import (
	"encoding/binary"
	"testing"
)

func testConfig() *Config {
	return &Config{
		S1: 10, H1: HeaderRange{Min: 1000, Max: 2000},
		S2: 10, H2: HeaderRange{Min: 3000, Max: 4000},
		S3: 10, H3: HeaderRange{Min: 5000, Max: 6000},
		S4: 10, H4: HeaderRange{Min: 7000, Max: 8000},
	}
}

func TestIsAWGPacket_Initiation(t *testing.T) {
	cfg := testConfig()

	// Build a packet: S1(10) padding + 148 bytes = 158 total.
	pkt := make([]byte, 10+messageInitiationSize)
	binary.LittleEndian.PutUint32(pkt[10:], 1500) // H1 value in range

	if !isAWGPacket(pkt, cfg) {
		t.Error("expected initiation packet to match")
	}

	// Wrong size — should not match initiation (and other checks also won't match).
	wrongSize := make([]byte, 10+messageInitiationSize+1)
	binary.LittleEndian.PutUint32(wrongSize[10:], 1500)
	if isAWGPacket(wrongSize, cfg) {
		t.Error("wrong size should not match")
	}

	// H1 out of range.
	pktOutOfRange := make([]byte, 10+messageInitiationSize)
	binary.LittleEndian.PutUint32(pktOutOfRange[10:], 999)
	if isAWGPacket(pktOutOfRange, cfg) {
		t.Error("out-of-range H1 should not match")
	}
}

func TestIsAWGPacket_Transport(t *testing.T) {
	cfg := testConfig()
	cfg.S4 = 5
	cfg.H4 = HeaderRange{Min: 500, Max: 600}

	// Transport: size >= S4+16.
	pkt := make([]byte, 5+messageTransportHeaderSize+100)
	binary.LittleEndian.PutUint32(pkt[5:], 550)

	if !isAWGPacket(pkt, cfg) {
		t.Error("expected transport packet to match")
	}

	// Too small.
	small := make([]byte, 5+messageTransportHeaderSize-1)
	if isAWGPacket(small, cfg) {
		t.Error("too-small packet should not match transport")
	}
}

func TestIsAWGPacket_NonAWG(t *testing.T) {
	cfg := testConfig()

	// Random packet that doesn't match any message type.
	pkt := make([]byte, 200)
	if isAWGPacket(pkt, cfg) {
		t.Error("random packet should not match")
	}
}

A  => internal/awgserver/filteredconn.go +101 -0
@@ 1,101 @@
package awgserver

import (
	"net"
	"time"
)

// FilteredConn implements net.PacketConn. It receives only non-AWG packets
// from MuxBind via a channel, and writes QUIC responses through the shared
// UDP socket.
type FilteredConn struct {
	quicCh  chan quicPacket
	udpConn *net.UDPConn
	closed  chan struct{}

	readDeadline  time.Time
	writeDeadline time.Time
}

func newFilteredConn(quicCh chan quicPacket, udpConn *net.UDPConn) *FilteredConn {
	return &FilteredConn{
		quicCh:  quicCh,
		udpConn: udpConn,
		closed:  make(chan struct{}),
	}
}

// ReadFrom blocks until a non-AWG packet arrives from MuxBind.
func (fc *FilteredConn) ReadFrom(p []byte) (int, net.Addr, error) {
	var timer <-chan time.Time
	if !fc.readDeadline.IsZero() {
		d := time.Until(fc.readDeadline)
		if d <= 0 {
			return 0, nil, &net.OpError{Op: "read", Err: errTimeout}
		}
		t := time.NewTimer(d)
		defer t.Stop()
		timer = t.C
	}

	select {
	case pkt := <-fc.quicCh:
		n := copy(p, pkt.data)
		return n, pkt.addr, nil
	case <-timer:
		return 0, nil, &net.OpError{Op: "read", Err: errTimeout}
	case <-fc.closed:
		return 0, nil, net.ErrClosed
	}
}

// WriteTo sends QUIC responses through the shared socket.
func (fc *FilteredConn) WriteTo(p []byte, addr net.Addr) (int, error) {
	udpAddr, ok := addr.(*net.UDPAddr)
	if !ok {
		return 0, &net.OpError{Op: "write", Err: net.ErrClosed}
	}
	return fc.udpConn.WriteToUDP(p, udpAddr)
}

func (fc *FilteredConn) Close() error {
	select {
	case <-fc.closed:
	default:
		close(fc.closed)
	}
	return nil
}

func (fc *FilteredConn) LocalAddr() net.Addr {
	return fc.udpConn.LocalAddr()
}

func (fc *FilteredConn) SetDeadline(t time.Time) error {
	fc.readDeadline = t
	fc.writeDeadline = t
	return nil
}

func (fc *FilteredConn) SetReadDeadline(t time.Time) error {
	fc.readDeadline = t
	return nil
}

func (fc *FilteredConn) SetWriteDeadline(t time.Time) error {
	fc.writeDeadline = t
	return nil
}

// SyscallConn is required by quic-go's OOBCapablePacketConn check.
func (fc *FilteredConn) SyscallConn() (interface{}, error) {
	return nil, &net.OpError{Op: "syscall", Err: net.ErrClosed}
}

type timeoutError struct{}

func (timeoutError) Error() string   { return "i/o timeout" }
func (timeoutError) Timeout() bool   { return true }
func (timeoutError) Temporary() bool { return true }

var errTimeout = timeoutError{}

A  => internal/awgserver/h3server.go +73 -0
@@ 1,73 @@
package awgserver

import (
	"crypto/tls"
	"embed"
	"fmt"
	"io/fs"
	"log/slog"
	"net/http"

	"github.com/quic-go/quic-go"
	"github.com/quic-go/quic-go/http3"
	"golang.org/x/crypto/acme/autocert"
)

//go:embed static/*
var staticFiles embed.FS

// startH3Server starts the HTTP/3 server using the FilteredConn from MuxBind.
// It handles Let's Encrypt cert issuance and serves embedded static files.
func startH3Server(fc *FilteredConn, cfg *Config, logger *slog.Logger) (*http3.Server, error) {
	certManager := &autocert.Manager{
		Prompt:     autocert.AcceptTOS,
		HostPolicy: autocert.HostWhitelist(cfg.Domain),
		Cache:      autocert.DirCache(cfg.CertCache),
	}

	if cfg.ACMEHTTPPort > 0 {
		go func() {
			addr := fmt.Sprintf(":%d", cfg.ACMEHTTPPort)
			logger.Info("ACME HTTP-01 challenge listener started.", "address", addr)
			if err := http.ListenAndServe(addr, certManager.HTTPHandler(nil)); err != nil {
				logger.Error("ACME HTTP listener failed.", "err", err)
			}
		}()
	}

	staticFS, err := fs.Sub(staticFiles, "static")
	if err != nil {
		return nil, fmt.Errorf("static files: %w", err)
	}
	handler := http.FileServer(http.FS(staticFS))

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Alt-Svc", fmt.Sprintf(`h3=":%d"; ma=86400`, cfg.ListenPort))
		handler.ServeHTTP(w, r)
	})

	transport := &quic.Transport{Conn: fc}

	h3srv := &http3.Server{
		Handler: mux,
		TLSConfig: &tls.Config{
			GetCertificate: certManager.TLSConfig().GetCertificate,
			NextProtos:     []string{"h3"},
		},
	}

	ln, err := transport.ListenEarly(h3srv.TLSConfig, &quic.Config{})
	if err != nil {
		return nil, fmt.Errorf("QUIC listen: %w", err)
	}

	logger.Info("HTTP/3 server started.", "domain", cfg.Domain, "port", cfg.ListenPort)
	go func() {
		if err := h3srv.ServeListener(ln); err != nil {
			logger.Error("HTTP/3 server failed.", "err", err)
		}
	}()

	return h3srv, nil
}

A  => internal/awgserver/headerrange.go +45 -0
@@ 1,45 @@
package awgserver

import (
	"fmt"
	"strconv"
	"strings"
)

// HeaderRange represents an H1-H4 header value range for AWG packet detection.
type HeaderRange struct {
	Min, Max uint32
}

// Contains checks if value v falls within the range [Min, Max].
func (h HeaderRange) Contains(v uint32) bool {
	return v >= h.Min && v <= h.Max
}

func (h HeaderRange) String() string {
	if h.Min == h.Max {
		return fmt.Sprintf("%d", h.Min)
	}
	return fmt.Sprintf("%d-%d", h.Min, h.Max)
}

// ParseHeaderRange parses a string like "50000-100000" or "50000" into a HeaderRange.
func ParseHeaderRange(s string) (HeaderRange, error) {
	if strings.Contains(s, "-") {
		parts := strings.SplitN(s, "-", 2)
		min, err := strconv.ParseUint(parts[0], 10, 32)
		if err != nil {
			return HeaderRange{}, fmt.Errorf("parse min: %w", err)
		}
		max, err := strconv.ParseUint(parts[1], 10, 32)
		if err != nil {
			return HeaderRange{}, fmt.Errorf("parse max: %w", err)
		}
		return HeaderRange{Min: uint32(min), Max: uint32(max)}, nil
	}
	v, err := strconv.ParseUint(s, 10, 32)
	if err != nil {
		return HeaderRange{}, fmt.Errorf("parse value: %w", err)
	}
	return HeaderRange{Min: uint32(v), Max: uint32(v)}, nil
}

A  => internal/awgserver/headerrange_test.go +51 -0
@@ 1,51 @@
package awgserver

import "testing"

func TestParseHeaderRange_Range(t *testing.T) {
	hr, err := ParseHeaderRange("50000-100000")
	if err != nil {
		t.Fatalf("ParseHeaderRange() error: %v", err)
	}
	if hr.Min != 50000 || hr.Max != 100000 {
		t.Errorf("got {%d, %d}, want {50000, 100000}", hr.Min, hr.Max)
	}
}

func TestParseHeaderRange_Single(t *testing.T) {
	hr, err := ParseHeaderRange("42")
	if err != nil {
		t.Fatalf("ParseHeaderRange() error: %v", err)
	}
	if hr.Min != 42 || hr.Max != 42 {
		t.Errorf("got {%d, %d}, want {42, 42}", hr.Min, hr.Max)
	}
}

func TestHeaderRange_Contains(t *testing.T) {
	hr := HeaderRange{Min: 100, Max: 200}
	tests := []struct {
		v    uint32
		want bool
	}{
		{99, false},
		{100, true},
		{150, true},
		{200, true},
		{201, false},
	}
	for _, tt := range tests {
		if got := hr.Contains(tt.v); got != tt.want {
			t.Errorf("Contains(%d) = %v, want %v", tt.v, got, tt.want)
		}
	}
}

func TestHeaderRange_String(t *testing.T) {
	if s := (HeaderRange{Min: 50000, Max: 100000}).String(); s != "50000-100000" {
		t.Errorf("String() = %q, want %q", s, "50000-100000")
	}
	if s := (HeaderRange{Min: 42, Max: 42}).String(); s != "42" {
		t.Errorf("String() = %q, want %q", s, "42")
	}
}

A  => internal/awgserver/ipalloc.go +55 -0
@@ 1,55 @@
package awgserver

import (
	"fmt"
	"net/netip"
)

// IPAllocator assigns IPs from a CIDR subnet for WireGuard peers.
type IPAllocator struct {
	prefix netip.Prefix
}

// NewIPAllocator creates an allocator for the given CIDR (e.g., "10.14.0.0/24").
func NewIPAllocator(cidr string) (*IPAllocator, error) {
	prefix, err := netip.ParsePrefix(cidr)
	if err != nil {
		return nil, fmt.Errorf("parse CIDR %q: %w", cidr, err)
	}
	prefix = prefix.Masked()
	return &IPAllocator{prefix: prefix}, nil
}

// ServerIP returns the first usable IP in the subnet (network + 1), used as the server address.
func (a *IPAllocator) ServerIP() netip.Addr {
	return a.prefix.Addr().Next()
}

// Allocate finds the next free IP in the subnet, skipping the network address,
// server address (.1), broadcast, and any IPs in the used set.
func (a *IPAllocator) Allocate(used []string) (string, error) {
	usedSet := make(map[netip.Addr]struct{}, len(used))
	for _, u := range used {
		p, err := netip.ParsePrefix(u)
		if err != nil {
			continue
		}
		usedSet[p.Addr()] = struct{}{}
	}

	// Start from network + 2 (skip network addr and server addr).
	addr := a.prefix.Addr().Next().Next()
	for a.prefix.Contains(addr) {
		next := addr.Next()
		// Skip if next would be outside prefix (broadcast equivalent).
		if !a.prefix.Contains(next) {
			break
		}
		if _, taken := usedSet[addr]; !taken {
			return fmt.Sprintf("%s/32", addr.String()), nil
		}
		addr = next
	}

	return "", fmt.Errorf("no free IPs in %s", a.prefix.String())
}

A  => internal/awgserver/ipalloc_test.go +73 -0
@@ 1,73 @@
package awgserver

import "testing"

func TestIPAllocator_Basic(t *testing.T) {
	alloc, err := NewIPAllocator("10.14.0.0/24")
	if err != nil {
		t.Fatalf("NewIPAllocator() error: %v", err)
	}

	if got := alloc.ServerIP().String(); got != "10.14.0.1" {
		t.Errorf("ServerIP() = %s, want 10.14.0.1", got)
	}

	ip, err := alloc.Allocate(nil)
	if err != nil {
		t.Fatalf("Allocate() error: %v", err)
	}
	if ip != "10.14.0.2/32" {
		t.Errorf("first allocation = %s, want 10.14.0.2/32", ip)
	}
}

func TestIPAllocator_SkipsUsed(t *testing.T) {
	alloc, err := NewIPAllocator("10.14.0.0/24")
	if err != nil {
		t.Fatalf("NewIPAllocator() error: %v", err)
	}

	used := []string{"10.14.0.2/32", "10.14.0.3/32"}
	ip, err := alloc.Allocate(used)
	if err != nil {
		t.Fatalf("Allocate() error: %v", err)
	}
	if ip != "10.14.0.4/32" {
		t.Errorf("allocation = %s, want 10.14.0.4/32", ip)
	}
}

func TestIPAllocator_Exhaustion(t *testing.T) {
	alloc, err := NewIPAllocator("10.14.0.0/30") // Only 4 IPs: .0 (network), .1 (server), .2 (peer), .3 (broadcast)
	if err != nil {
		t.Fatalf("NewIPAllocator() error: %v", err)
	}

	ip, err := alloc.Allocate(nil)
	if err != nil {
		t.Fatalf("first Allocate() error: %v", err)
	}
	if ip != "10.14.0.2/32" {
		t.Errorf("allocation = %s, want 10.14.0.2/32", ip)
	}

	_, err = alloc.Allocate([]string{"10.14.0.2/32"})
	if err == nil {
		t.Errorf("expected error on exhausted subnet, got nil")
	}
}

func TestIPAllocator_16Subnet(t *testing.T) {
	alloc, err := NewIPAllocator("10.14.0.0/16")
	if err != nil {
		t.Fatalf("NewIPAllocator() error: %v", err)
	}

	ip, err := alloc.Allocate(nil)
	if err != nil {
		t.Fatalf("Allocate() error: %v", err)
	}
	if ip != "10.14.0.2/32" {
		t.Errorf("allocation = %s, want 10.14.0.2/32", ip)
	}
}

A  => internal/awgserver/keygen.go +50 -0
@@ 1,50 @@
package awgserver

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"fmt"

	"golang.org/x/crypto/curve25519"
)

// GeneratePrivateKey generates a new Curve25519 private key with WireGuard clamping.
func GeneratePrivateKey() (string, error) {
	var key [32]byte
	if _, err := rand.Read(key[:]); err != nil {
		return "", fmt.Errorf("generating random key: %w", err)
	}
	// WireGuard clamping.
	key[0] &= 248
	key[31] = (key[31] & 127) | 64
	return base64.StdEncoding.EncodeToString(key[:]), nil
}

// PublicKeyFromPrivate derives the Curve25519 public key from a base64-encoded private key.
func PublicKeyFromPrivate(privBase64 string) (string, error) {
	privBytes, err := base64.StdEncoding.DecodeString(privBase64)
	if err != nil {
		return "", fmt.Errorf("decoding private key: %w", err)
	}
	if len(privBytes) != 32 {
		return "", fmt.Errorf("private key must be 32 bytes, got %d", len(privBytes))
	}
	pub, err := curve25519.X25519(privBytes, curve25519.Basepoint)
	if err != nil {
		return "", fmt.Errorf("computing public key: %w", err)
	}
	return base64.StdEncoding.EncodeToString(pub), nil
}

// base64ToHex converts a base64-encoded key to hex (UAPI format).
func base64ToHex(b64 string) (string, error) {
	raw, err := base64.StdEncoding.DecodeString(b64)
	if err != nil {
		return "", fmt.Errorf("decode base64 key: %w", err)
	}
	if len(raw) != 32 {
		return "", fmt.Errorf("key must be 32 bytes, got %d", len(raw))
	}
	return hex.EncodeToString(raw), nil
}

A  => internal/awgserver/keygen_test.go +64 -0
@@ 1,64 @@
package awgserver

import (
	"encoding/base64"
	"testing"
)

func TestGeneratePrivateKey(t *testing.T) {
	key, err := GeneratePrivateKey()
	if err != nil {
		t.Fatalf("GeneratePrivateKey() error: %v", err)
	}
	raw, err := base64.StdEncoding.DecodeString(key)
	if err != nil {
		t.Fatalf("invalid base64: %v", err)
	}
	if len(raw) != 32 {
		t.Fatalf("key length = %d, want 32", len(raw))
	}
	// Check WireGuard clamping.
	if raw[0]&7 != 0 {
		t.Errorf("bits 0-2 of byte 0 should be cleared")
	}
	if raw[31]&128 != 0 {
		t.Errorf("bit 7 of byte 31 should be cleared")
	}
	if raw[31]&64 == 0 {
		t.Errorf("bit 6 of byte 31 should be set")
	}
}

func TestPublicKeyFromPrivate(t *testing.T) {
	priv, err := GeneratePrivateKey()
	if err != nil {
		t.Fatalf("GeneratePrivateKey() error: %v", err)
	}
	pub, err := PublicKeyFromPrivate(priv)
	if err != nil {
		t.Fatalf("PublicKeyFromPrivate() error: %v", err)
	}
	raw, err := base64.StdEncoding.DecodeString(pub)
	if err != nil {
		t.Fatalf("invalid base64: %v", err)
	}
	if len(raw) != 32 {
		t.Fatalf("public key length = %d, want 32", len(raw))
	}
	// Derive again — should be deterministic.
	pub2, err := PublicKeyFromPrivate(priv)
	if err != nil {
		t.Fatalf("PublicKeyFromPrivate() second call error: %v", err)
	}
	if pub != pub2 {
		t.Errorf("public key derivation not deterministic")
	}
}

func TestGeneratePrivateKey_Uniqueness(t *testing.T) {
	k1, _ := GeneratePrivateKey()
	k2, _ := GeneratePrivateKey()
	if k1 == k2 {
		t.Errorf("two generated keys should not be equal")
	}
}

A  => internal/awgserver/muxbind.go +180 -0
@@ 1,180 @@
package awgserver

import (
	"fmt"
	"net"
	"net/netip"
	"sync"

	"github.com/amnezia-vpn/amneziawg-go/conn"
)

// quicPacket holds a non-AWG packet to be delivered to the QUIC server.
type quicPacket struct {
	data []byte
	addr *net.UDPAddr
}

// MuxBind implements conn.Bind. It owns a single *net.UDPConn on the listen port.
// Its ReceiveFunc checks each incoming packet against AWG signatures:
//   - AWG packets are delivered to device.Device via the ReceiveFunc return.
//   - Non-AWG packets are sent to a channel that feeds FilteredConn → QUIC server.
type MuxBind struct {
	mu   sync.Mutex
	conn *net.UDPConn
	cfg  *Config

	quicCh chan quicPacket
	closed chan struct{}
}

// NewMuxBind creates a MuxBind. Call Open() to start listening.
func NewMuxBind(cfg *Config, quicCh chan quicPacket) *MuxBind {
	return &MuxBind{
		cfg:    cfg,
		quicCh: quicCh,
		closed: make(chan struct{}),
	}
}

// FilteredConn returns a net.PacketConn that receives only non-AWG packets.
func (b *MuxBind) FilteredConn() *FilteredConn {
	b.mu.Lock()
	defer b.mu.Unlock()
	return newFilteredConn(b.quicCh, b.conn)
}

// Open creates the shared UDP socket and returns a ReceiveFunc that
// demultiplexes AWG vs non-AWG traffic.
func (b *MuxBind) Open(port uint16) ([]conn.ReceiveFunc, uint16, error) {
	b.mu.Lock()
	defer b.mu.Unlock()

	if b.conn != nil {
		return nil, 0, fmt.Errorf("bind already open")
	}

	laddr := &net.UDPAddr{Port: int(port)}
	c, err := net.ListenUDP("udp", laddr)
	if err != nil {
		return nil, 0, fmt.Errorf("listen UDP :%d: %w", port, err)
	}
	b.conn = c

	actualPort := uint16(c.LocalAddr().(*net.UDPAddr).Port)

	recv := func(packets [][]byte, sizes []int, eps []conn.Endpoint) (int, error) {
		for {
			select {
			case <-b.closed:
				return 0, net.ErrClosed
			default:
			}

			buf := packets[0]
			n, addr, err := b.conn.ReadFromUDP(buf)
			if err != nil {
				return 0, err
			}

			pkt := buf[:n]

			if isAWGPacket(pkt, b.cfg) {
				sizes[0] = n
				eps[0] = &MuxEndpoint{addr: addr}
				return 1, nil
			}

			// Non-AWG packet → send to QUIC server via channel.
			pktCopy := make([]byte, n)
			copy(pktCopy, pkt)
			select {
			case b.quicCh <- quicPacket{data: pktCopy, addr: addr}:
			case <-b.closed:
				return 0, net.ErrClosed
			}
		}
	}

	return []conn.ReceiveFunc{recv}, actualPort, nil
}

// Close shuts down the bind.
func (b *MuxBind) Close() error {
	b.mu.Lock()
	defer b.mu.Unlock()

	select {
	case <-b.closed:
	default:
		close(b.closed)
	}

	if b.conn != nil {
		err := b.conn.Close()
		b.conn = nil
		return err
	}
	return nil
}

// SetMark sets the socket mark. No-op on platforms that don't support it.
func (b *MuxBind) SetMark(mark uint32) error {
	return nil
}

// Send writes AWG response packets through the shared socket.
func (b *MuxBind) Send(bufs [][]byte, ep conn.Endpoint) error {
	b.mu.Lock()
	c := b.conn
	b.mu.Unlock()

	if c == nil {
		return net.ErrClosed
	}

	mep := ep.(*MuxEndpoint)
	for _, buf := range bufs {
		if _, err := c.WriteToUDP(buf, mep.addr); err != nil {
			return err
		}
	}
	return nil
}

// ParseEndpoint creates a MuxEndpoint from a string like "1.2.3.4:51820".
func (b *MuxBind) ParseEndpoint(s string) (conn.Endpoint, error) {
	addr, err := net.ResolveUDPAddr("udp", s)
	if err != nil {
		return nil, err
	}
	return &MuxEndpoint{addr: addr}, nil
}

// BatchSize returns 1 (no batching).
func (b *MuxBind) BatchSize() int {
	return 1
}

// MuxEndpoint implements conn.Endpoint for the multiplexed socket.
type MuxEndpoint struct {
	addr *net.UDPAddr
}

func (e *MuxEndpoint) ClearSrc()           {}
func (e *MuxEndpoint) SrcToString() string { return "" }
func (e *MuxEndpoint) DstToString() string { return e.addr.String() }

func (e *MuxEndpoint) DstToBytes() []byte {
	ap := e.addr.AddrPort()
	b, _ := ap.MarshalBinary()
	return b
}

func (e *MuxEndpoint) DstIP() netip.Addr {
	return e.addr.AddrPort().Addr()
}

func (e *MuxEndpoint) SrcIP() netip.Addr {
	return netip.Addr{}
}

A  => internal/awgserver/server.go +226 -0
@@ 1,226 @@
package awgserver

import (
	"fmt"
	"log/slog"
	"strings"
	"sync"

	"github.com/amnezia-vpn/amneziawg-go/device"
	"github.com/amnezia-vpn/amneziawg-go/ipc"
	"github.com/amnezia-vpn/amneziawg-go/tun"
	"github.com/quic-go/quic-go/http3"

	"sourcecraft.dev/bigbes/outline-distro/internal/store"
)

// Config holds all settings for the AmneziaWG server.
type Config struct {
	ListenPort   int
	TUNName      string
	Address      string // server CIDR, e.g. "10.14.0.1/24"
	MTU          int
	PrivateKey   string // server interface key, base64
	Domain       string
	CertCache    string
	ACMEHTTPPort int

	// Obfuscation parameters.
	Jc, Jmin, Jmax int
	S1, S2, S3, S4 int
	H1, H2, H3, H4 HeaderRange
}

// Server wraps an AmneziaWG device with MuxBind + HTTP/3 multiplexing.
type Server struct {
	cfg     Config
	dev     *device.Device
	tunDev  tun.Device
	muxBind *MuxBind
	h3srv   *http3.Server
	logger  *slog.Logger

	mu sync.Mutex
}

// New creates a new AWG server (not yet started).
func New(cfg Config, logger *slog.Logger) *Server {
	return &Server{
		cfg:    cfg,
		logger: logger,
	}
}

// Start creates the TUN device, AWG device with MuxBind, and HTTP/3 server.
func (s *Server) Start() error {
	s.mu.Lock()
	defer s.mu.Unlock()

	// 1. Create TUN device.
	tunDev, err := tun.CreateTUN(s.cfg.TUNName, s.cfg.MTU)
	if err != nil {
		return fmt.Errorf("create TUN %q: %w", s.cfg.TUNName, err)
	}
	s.tunDev = tunDev
	s.logger.Info("TUN device created.", "name", s.cfg.TUNName, "mtu", s.cfg.MTU)

	// 2. Create MuxBind.
	quicCh := make(chan quicPacket, 1024)
	s.muxBind = NewMuxBind(&s.cfg, quicCh)

	// 3. Create AWG device.
	logLevel := device.LogLevelError
	devLogger := device.NewLogger(logLevel, "[awg] ")
	s.dev = device.NewDevice(tunDev, s.muxBind, devLogger)

	// 4. Configure via UAPI (interface only, peers added by SyncKeys).
	ifaceConf, err := s.buildInterfaceConfig()
	if err != nil {
		s.dev.Close()
		tunDev.Close()
		return fmt.Errorf("build UAPI interface config: %w", err)
	}
	if err := s.dev.IpcSet(ifaceConf); err != nil {
		s.dev.Close()
		tunDev.Close()
		return fmt.Errorf("IpcSet interface config: %w", err)
	}
	s.logger.Info("AWG device configured.")

	// 5. Bring up — triggers MuxBind.Open(), starts listening.
	s.dev.Up()
	s.logger.Info("AWG device is up.", "port", s.cfg.ListenPort)

	// 6. Set up UAPI socket for awg show/set.
	go s.setupUAPI()

	// 7. Get FilteredConn and start HTTP/3 server.
	fc := s.muxBind.FilteredConn()
	if s.cfg.Domain != "" {
		h3srv, err := startH3Server(fc, &s.cfg, s.logger)
		if err != nil {
			s.logger.Warn("HTTP/3 server failed to start (non-fatal).", "err", err)
		} else {
			s.h3srv = h3srv
		}
	}

	// 8. Configure TUN address.
	if s.cfg.Address != "" {
		if err := setTUNAddress(s.cfg.TUNName, s.cfg.Address, s.logger); err != nil {
			s.logger.Warn("Failed to configure TUN address (non-fatal).", "err", err)
		}
	}

	return nil
}

// SyncKeys rebuilds the AWG peer list from access keys that have AWG data.
func (s *Server) SyncKeys(keys []store.AccessKey) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.dev == nil {
		return fmt.Errorf("AWG device not started")
	}

	conf := s.buildPeerConfig(keys)
	if err := s.dev.IpcSet(conf); err != nil {
		return fmt.Errorf("IpcSet peers: %w", err)
	}

	count := 0
	for _, k := range keys {
		if k.AWG != nil {
			count++
		}
	}
	s.logger.Info("AWG peers synced.", "count", count)
	return nil
}

// Stop shuts down the AWG device, TUN, and HTTP/3 server.
func (s *Server) Stop() error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.h3srv != nil {
		s.h3srv.Close()
	}
	if s.dev != nil {
		s.dev.Close()
	}
	if s.tunDev != nil {
		s.tunDev.Close()
	}
	s.logger.Info("AWG server stopped.")
	return nil
}

func (s *Server) buildInterfaceConfig() (string, error) {
	privHex, err := base64ToHex(s.cfg.PrivateKey)
	if err != nil {
		return "", fmt.Errorf("PrivateKey: %w", err)
	}

	var b strings.Builder
	fmt.Fprintf(&b, "private_key=%s\n", privHex)
	fmt.Fprintf(&b, "listen_port=%d\n", s.cfg.ListenPort)

	if s.cfg.Jc > 0 {
		fmt.Fprintf(&b, "jc=%d\n", s.cfg.Jc)
		fmt.Fprintf(&b, "jmin=%d\n", s.cfg.Jmin)
		fmt.Fprintf(&b, "jmax=%d\n", s.cfg.Jmax)
	}
	fmt.Fprintf(&b, "s1=%d\n", s.cfg.S1)
	fmt.Fprintf(&b, "s2=%d\n", s.cfg.S2)
	fmt.Fprintf(&b, "s3=%d\n", s.cfg.S3)
	fmt.Fprintf(&b, "s4=%d\n", s.cfg.S4)
	fmt.Fprintf(&b, "h1=%s\n", s.cfg.H1)
	fmt.Fprintf(&b, "h2=%s\n", s.cfg.H2)
	fmt.Fprintf(&b, "h3=%s\n", s.cfg.H3)
	fmt.Fprintf(&b, "h4=%s\n", s.cfg.H4)

	return b.String(), nil
}

func (s *Server) buildPeerConfig(keys []store.AccessKey) string {
	var b strings.Builder
	b.WriteString("replace_peers=true\n")

	for _, k := range keys {
		if k.AWG == nil {
			continue
		}
		pubHex, err := base64ToHex(k.AWG.PublicKey)
		if err != nil {
			s.logger.Warn("Skipping AWG peer: bad public key.", "keyID", k.ID, "err", err)
			continue
		}
		fmt.Fprintf(&b, "public_key=%s\n", pubHex)
		fmt.Fprintf(&b, "allowed_ip=%s\n", k.AWG.AllowedIP)
	}

	return b.String()
}

func (s *Server) setupUAPI() {
	fileUAPI, err := ipc.UAPIOpen(s.cfg.TUNName)
	if err != nil {
		s.logger.Debug("UAPI open (non-fatal).", "err", err)
		return
	}
	uapiListener, err := ipc.UAPIListen(s.cfg.TUNName, fileUAPI)
	if err != nil {
		s.logger.Debug("UAPI listen (non-fatal).", "err", err)
		return
	}
	s.logger.Info("UAPI listening.", "interface", s.cfg.TUNName)
	for {
		conn, err := uapiListener.Accept()
		if err != nil {
			return
		}
		go s.dev.IpcHandle(conn)
	}
}

A  => internal/awgserver/static/index.html +16 -0
@@ 1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome</title>
    <style>
        body { font-family: system-ui, sans-serif; margin: 2em; color: #333; }
        h1 { color: #555; }
    </style>
</head>
<body>
    <h1>Welcome</h1>
    <p>This server is running.</p>
</body>
</html>

A  => internal/awgserver/static/robots.txt +2 -0
@@ 1,2 @@
User-agent: *
Disallow: /

A  => internal/awgserver/tun_darwin.go +23 -0
@@ 1,23 @@
//go:build darwin

package awgserver

import (
	"fmt"
	"log/slog"
	"net"
	"os/exec"
)

func setTUNAddress(tunName, address string, logger *slog.Logger) error {
	ip, _, err := net.ParseCIDR(address)
	if err != nil {
		return fmt.Errorf("parse CIDR: %w", err)
	}
	cmd := exec.Command("ifconfig", tunName, "inet", ip.String(), ip.String(), "up")
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("ifconfig: %s: %w", string(out), err)
	}
	logger.Info("TUN configured.", "name", tunName, "address", address)
	return nil
}

A  => internal/awgserver/tun_linux.go +22 -0
@@ 1,22 @@
//go:build linux

package awgserver

import (
	"fmt"
	"log/slog"
	"os/exec"
)

func setTUNAddress(tunName, address string, logger *slog.Logger) error {
	cmd := exec.Command("ip", "addr", "add", address, "dev", tunName)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("ip addr add: %s: %w", string(out), err)
	}
	cmd = exec.Command("ip", "link", "set", "up", "dev", tunName)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("ip link set up: %s: %w", string(out), err)
	}
	logger.Info("TUN configured.", "name", tunName, "address", address)
	return nil
}

A  => internal/config/config.go +177 -0
@@ 1,177 @@
package config

import (
	"fmt"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"
)

type Config struct {
	// Server identification and display.
	Server ServerConfig `yaml:"server"`
	// REST API settings.
	API APIConfig `yaml:"api"`
	// Prometheus metrics endpoint settings.
	Metrics MetricsConfig `yaml:"metrics"`
	// Shadowsocks proxy settings.
	Shadowsocks ShadowsocksConfig `yaml:"shadowsocks"`
	// AmneziaWG VPN settings.
	AmneziaWG AmneziaWGConfig `yaml:"amneziawg"`
	// Path to persistent state file.
	// Defaults to "state.yaml" in the same directory as the config file.
	State string `yaml:"state_file"`

	// configDir is the directory containing the config file, used for resolving relative paths.
	configDir string
}

type ServerConfig struct {
	// Human-readable server name.
	Name string `yaml:"name"`
	// Unique server identifier (auto-generated if empty).
	ID string `yaml:"id"`
	// Hostname or IP used in access key URLs.
	Hostname string `yaml:"hostname"`
}

type APIConfig struct {
	// Listen address for the management API (e.g., ":8081").
	ListenAddr string `yaml:"listen_addr"`
	// Secret prefix for URL-based authentication (e.g., "SecretPath").
	// API will be served at /<secret>/...
	Secret string `yaml:"secret"`
	// Path to TLS certificate file (optional, used for certSha256 in access.yaml).
	CertFile string `yaml:"cert_file"`
}

type MetricsConfig struct {
	// Listen address for the Prometheus /metrics and /healthz endpoint.
	// Defaults to "127.0.0.1:8081".
	ListenAddr string `yaml:"listen_addr"`
	// Node exporter collectors to enable (e.g., [cpu, meminfo, diskstats]).
	// Empty means no node_exporter collectors.
	NodeExporterCollectors []string `yaml:"node_exporter_collectors"`
}

type AmneziaWGConfig struct {
	// Enable AmneziaWG protocol.
	Enabled bool `yaml:"enabled"`
	// UDP listen port for AWG + HTTP/3 mux.
	ListenPort int `yaml:"listen_port"`
	// TUN device name.
	TUNName string `yaml:"tun_name"`
	// Server subnet in CIDR notation (e.g., "10.14.0.0/24").
	// Server takes the first usable IP (.1), peers are allocated from .2 onwards.
	Address string `yaml:"address"`
	// TUN MTU.
	MTU int `yaml:"mtu"`
	// Server interface private key (base64). Auto-generated if empty.
	PrivateKey string `yaml:"private_key"`
	// DNS server for client configs.
	DNS string `yaml:"dns"`

	// HTTP/3 cover for DPI resistance.
	// Domain for Let's Encrypt certificate. If empty, HTTP/3 server is disabled.
	Domain string `yaml:"domain"`
	// Directory for cached TLS certificates.
	CertCache string `yaml:"cert_cache"`
	// HTTP port for ACME HTTP-01 challenges.
	ACMEHTTPPort int `yaml:"acme_http_port"`

	// Obfuscation parameters.
	Jc   int `yaml:"jc"`
	Jmin int `yaml:"jmin"`
	Jmax int `yaml:"jmax"`
	S1   int `yaml:"s1"`
	S2   int `yaml:"s2"`
	S3   int `yaml:"s3"`
	S4   int `yaml:"s4"`
	H1   string `yaml:"h1"` // "min-max" or single value
	H2   string `yaml:"h2"`
	H3   string `yaml:"h3"`
	H4   string `yaml:"h4"`
}

type ShadowsocksConfig struct {
	// Default port for new access keys. 0 means pick a random unused port on first start.
	DefaultPort int `yaml:"default_port"`
	// Default cipher for new access keys.
	DefaultCipher string `yaml:"default_cipher"`
	// UDP NAT timeout as a duration string (e.g., "5m").
	NATTimeout string `yaml:"nat_timeout"`
	// Replay protection history size (0 = disabled).
	ReplayHistory int `yaml:"replay_history"`
	// Path to IP-to-country MaxMind MMDB file.
	IPCountryDB string `yaml:"ip_country_db"`
	// Path to IP-to-ASN MaxMind MMDB file.
	IPASNDB string `yaml:"ip_asn_db"`
}

func Load(filename string) (*Config, error) {
	absPath, err := filepath.Abs(filename)
	if err != nil {
		return nil, fmt.Errorf("resolving config path: %w", err)
	}

	data, err := os.ReadFile(absPath)
	if err != nil {
		return nil, fmt.Errorf("reading config file: %w", err)
	}
	cfg := &Config{
		configDir: filepath.Dir(absPath),
	}
	if err := yaml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parsing config: %w", err)
	}
	cfg.setDefaults()
	return cfg, nil
}

func (c *Config) setDefaults() {
	if c.API.ListenAddr == "" {
		c.API.ListenAddr = ":8081"
	}
	if c.Metrics.ListenAddr == "" {
		c.Metrics.ListenAddr = "127.0.0.1:8081"
	}
	if c.Shadowsocks.DefaultCipher == "" {
		c.Shadowsocks.DefaultCipher = "chacha20-ietf-poly1305"
	}
	if c.Shadowsocks.NATTimeout == "" {
		c.Shadowsocks.NATTimeout = "5m"
	}
	if c.Server.Name == "" {
		c.Server.Name = "Outline Server"
	}
	if c.State == "" {
		c.State = "state.yaml"
	}
	// AmneziaWG defaults.
	if c.AmneziaWG.Enabled {
		if c.AmneziaWG.ListenPort == 0 {
			c.AmneziaWG.ListenPort = 443
		}
		if c.AmneziaWG.TUNName == "" {
			c.AmneziaWG.TUNName = "awg0"
		}
		if c.AmneziaWG.MTU == 0 {
			c.AmneziaWG.MTU = 1420
		}
		if c.AmneziaWG.CertCache == "" {
			c.AmneziaWG.CertCache = "/var/lib/outline-distro/certs"
		}
		if c.AmneziaWG.ACMEHTTPPort == 0 {
			c.AmneziaWG.ACMEHTTPPort = 80
		}
	}
}

// StateFile returns the absolute path to the state file.
func (c *Config) StateFile() string {
	if filepath.IsAbs(c.State) {
		return c.State
	}
	return filepath.Join(c.configDir, c.State)
}

A  => internal/metrics/collector.go +172 -0
@@ 1,172 @@
package metrics

import (
	"log/slog"
	"net"
	"sync"
	"time"

	"github.com/Jigsaw-Code/outline-ss-server/ipinfo"
	outline_prometheus "github.com/Jigsaw-Code/outline-ss-server/prometheus"
	"github.com/Jigsaw-Code/outline-ss-server/service"
	svcmetrics "github.com/Jigsaw-Code/outline-ss-server/service/metrics"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/collectors"
)

// ServerMetrics tracks server-level Prometheus metrics.
type ServerMetrics struct {
	buildInfo         *prometheus.GaugeVec
	accessKeys        prometheus.Gauge
	ports             prometheus.Gauge
	addedNatEntries   prometheus.Counter
	removedNatEntries prometheus.Counter
}

var _ prometheus.Collector = (*ServerMetrics)(nil)
var _ service.NATMetrics = (*ServerMetrics)(nil)

func NewServerMetrics() *ServerMetrics {
	return &ServerMetrics{
		buildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
			Name: "build_info",
			Help: "Information on the outline-distro build",
		}, []string{"version"}),
		accessKeys: prometheus.NewGauge(prometheus.GaugeOpts{
			Name: "keys",
			Help: "Count of access keys",
		}),
		ports: prometheus.NewGauge(prometheus.GaugeOpts{
			Name: "ports",
			Help: "Count of open ports",
		}),
		addedNatEntries: prometheus.NewCounter(prometheus.CounterOpts{
			Subsystem: "udp",
			Name:      "nat_entries_added",
			Help:      "Entries added to the UDP NAT table",
		}),
		removedNatEntries: prometheus.NewCounter(prometheus.CounterOpts{
			Subsystem: "udp",
			Name:      "nat_entries_removed",
			Help:      "Entries removed from the UDP NAT table",
		}),
	}
}

func (m *ServerMetrics) Describe(ch chan<- *prometheus.Desc) {
	m.buildInfo.Describe(ch)
	m.accessKeys.Describe(ch)
	m.ports.Describe(ch)
	m.addedNatEntries.Describe(ch)
	m.removedNatEntries.Describe(ch)
}

func (m *ServerMetrics) Collect(ch chan<- prometheus.Metric) {
	m.buildInfo.Collect(ch)
	m.accessKeys.Collect(ch)
	m.ports.Collect(ch)
	m.addedNatEntries.Collect(ch)
	m.removedNatEntries.Collect(ch)
}

func (m *ServerMetrics) SetVersion(version string) {
	m.buildInfo.WithLabelValues(version).Set(1)
}

func (m *ServerMetrics) SetNumAccessKeys(numKeys int, ports int) {
	m.accessKeys.Set(float64(numKeys))
	m.ports.Set(float64(ports))
}

func (m *ServerMetrics) AddNATEntry()    { m.addedNatEntries.Inc() }
func (m *ServerMetrics) RemoveNATEntry() { m.removedNatEntries.Inc() }

// TransferTracker wraps ServiceMetrics to also track per-key byte transfer
// for the REST API's /metrics/transfer endpoint.
type TransferTracker struct {
	service.ServiceMetrics
	mu       sync.Mutex
	transfer map[string]int64 // key ID -> cumulative outbound bytes
}

func NewTransferTracker(inner service.ServiceMetrics) *TransferTracker {
	return &TransferTracker{
		ServiceMetrics: inner,
		transfer:       make(map[string]int64),
	}
}

func (t *TransferTracker) AddOpenTCPConnection(conn net.Conn) service.TCPConnMetrics {
	inner := t.ServiceMetrics.AddOpenTCPConnection(conn)
	return &trackingTCPConnMetrics{TCPConnMetrics: inner, tracker: t}
}

// GetTransferByKey returns cumulative byte transfer per key and resets counters.
func (t *TransferTracker) GetTransferByKey() map[string]int64 {
	t.mu.Lock()
	defer t.mu.Unlock()
	result := t.transfer
	t.transfer = make(map[string]int64)
	return result
}

func (t *TransferTracker) addBytes(keyID string, bytes int64) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.transfer[keyID] += bytes
}

type trackingTCPConnMetrics struct {
	service.TCPConnMetrics
	tracker   *TransferTracker
	accessKey string
}

func (m *trackingTCPConnMetrics) AddAuthentication(accessKey string) {
	m.accessKey = accessKey
	m.TCPConnMetrics.AddAuthentication(accessKey)
}

func (m *trackingTCPConnMetrics) AddClose(status string, data svcmetrics.ProxyMetrics, duration time.Duration) {
	if m.accessKey != "" {
		m.tracker.addBytes(m.accessKey, data.ProxyTarget+data.TargetProxy)
	}
	m.TCPConnMetrics.AddClose(status, data, duration)
}

// SetupRegistry creates a Prometheus registry with all metric collectors.
func SetupRegistry(
	ip2info ipinfo.IPInfoMap,
	serverMetrics *ServerMetrics,
	version string,
	nodeCollectors []string,
	logger *slog.Logger,
) (*prometheus.Registry, *TransferTracker, *ServerMetrics, error) {
	registry := prometheus.NewRegistry()

	// Go runtime metrics.
	registry.MustRegister(collectors.NewGoCollector())
	registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))

	// Shadowsocks service metrics.
	serviceMetrics, err := outline_prometheus.NewServiceMetrics(ip2info)
	if err != nil {
		return nil, nil, nil, err
	}

	serverMetrics.SetVersion(version)

	r := prometheus.WrapRegistererWithPrefix("shadowsocks_", registry)
	r.MustRegister(serverMetrics, serviceMetrics)

	transferTracker := NewTransferTracker(serviceMetrics)

	// Node exporter collectors.
	if len(nodeCollectors) > 0 {
		if err := registerNodeCollectors(registry, logger, nodeCollectors); err != nil {
			logger.Warn("Failed to register node_exporter collectors, continuing without them.", "err", err)
		}
	}

	return registry, transferTracker, serverMetrics, nil
}

A  => internal/metrics/nodeexporter.go +20 -0
@@ 1,20 @@
package metrics

import (
	"fmt"
	"log/slog"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/node_exporter/collector"
)

func registerNodeCollectors(registry *prometheus.Registry, logger *slog.Logger, collectorNames []string) error {
	nc, err := collector.NewNodeCollector(logger, collectorNames...)
	if err != nil {
		return fmt.Errorf("creating node collector: %w", err)
	}
	if err := registry.Register(nc); err != nil {
		return fmt.Errorf("registering node collector: %w", err)
	}
	return nil
}

A  => internal/ssserver/server.go +208 -0
@@ 1,208 @@
package ssserver

import (
	"container/list"
	"context"
	"fmt"
	"log/slog"
	"net"
	"sync"
	"time"

	"github.com/Jigsaw-Code/outline-sdk/transport"
	"github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks"

	onet "github.com/Jigsaw-Code/outline-ss-server/net"
	"github.com/Jigsaw-Code/outline-ss-server/service"

	"sourcecraft.dev/bigbes/outline-distro/internal/metrics"
	"sourcecraft.dev/bigbes/outline-distro/internal/store"
)

const tcpReadTimeout = 59 * time.Second

type Server struct {
	lnManager      service.ListenerManager
	natTimeout     time.Duration
	replayCache    service.ReplayCache
	serverMetrics  *metrics.ServerMetrics
	serviceMetrics *metrics.TransferTracker
	logger         *slog.Logger

	mu       sync.Mutex
	stopFunc func() error
}

func New(
	natTimeout time.Duration,
	replayHistory int,
	serverMetrics *metrics.ServerMetrics,
	serviceMetrics *metrics.TransferTracker,
	logger *slog.Logger,
) *Server {
	return &Server{
		lnManager:      service.NewListenerManager(),
		natTimeout:     natTimeout,
		replayCache:    service.NewReplayCache(replayHistory),
		serverMetrics:  serverMetrics,
		serviceMetrics: serviceMetrics,
		logger:         logger,
	}
}

// SyncKeys rebuilds the Shadowsocks service with the given access keys.
// It starts new listeners before closing old ones for zero-downtime reload.
func (s *Server) SyncKeys(keys []store.AccessKey) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	// Group keys by port.
	portCiphers := make(map[int]*list.List)
	for _, key := range keys {
		cl, ok := portCiphers[key.Port]
		if !ok {
			cl = list.New()
			portCiphers[key.Port] = cl
		}
		cryptoKey, err := shadowsocks.NewEncryptionKey(key.Method, key.Password)
		if err != nil {
			return fmt.Errorf("creating encryption key for %s: %w", key.ID, err)
		}
		entry := service.MakeCipherEntry(key.ID, cryptoKey, key.Password)
		cl.PushBack(&entry)
	}

	// Start new listeners.
	startErrCh := make(chan error, 1)
	stopErrCh := make(chan error, 1)
	stopCh := make(chan struct{})

	go func() {
		lnSet := &listenerSet{
			manager:    s.lnManager,
			closeFuncs: make(map[string]func() error),
		}
		defer func() {
			stopErrCh <- lnSet.Close()
		}()

		startErrCh <- func() error {
			for portNum, cipherEntries := range portCiphers {
				addr := fmt.Sprintf(":%d", portNum)

				ciphers := service.NewCipherList()
				ciphers.Update(cipherEntries)

				streamHandler, associationHandler := service.NewShadowsocksHandlers(
					service.WithCiphers(ciphers),
					service.WithMetrics(s.serviceMetrics),
					service.WithReplayCache(&s.replayCache),
					service.WithStreamDialer(service.MakeValidatingTCPStreamDialer(onet.RequirePublicIP, 0)),
					service.WithPacketListener(service.MakeTargetUDPListener(s.natTimeout, 0)),
					service.WithLogger(s.logger),
				)

				ln, err := lnSet.ListenStream(addr)
				if err != nil {
					return fmt.Errorf("listen TCP %s: %w", addr, err)
				}
				s.logger.Info("TCP service started.", "address", ln.Addr().String())
				go service.StreamServe(ln.AcceptStream, func(ctx context.Context, conn transport.StreamConn) {
					streamHandler.HandleStream(ctx, conn, s.serviceMetrics.AddOpenTCPConnection(conn))
				})

				pc, err := lnSet.ListenPacket(addr)
				if err != nil {
					return fmt.Errorf("listen UDP %s: %w", addr, err)
				}
				s.logger.Info("UDP service started.", "address", pc.LocalAddr().String())
				go service.PacketServe(pc, func(ctx context.Context, conn net.Conn) {
					associationHandler.HandleAssociation(ctx, conn, s.serviceMetrics.AddOpenUDPAssociation(conn))
				}, s.serverMetrics)
			}

			s.serverMetrics.SetNumAccessKeys(len(keys), len(portCiphers))
			return nil
		}()

		<-stopCh
	}()

	if err := <-startErrCh; err != nil {
		return err
	}

	// Stop old listeners.
	if s.stopFunc != nil {
		if err := s.stopFunc(); err != nil {
			s.logger.Warn("Failed to stop old listeners.", "err", err)
		}
	}

	s.stopFunc = func() error {
		stopCh <- struct{}{}
		return <-stopErrCh
	}

	return nil
}

func (s *Server) Stop() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.stopFunc != nil {
		return s.stopFunc()
	}
	return nil
}

type listenerSet struct {
	manager    service.ListenerManager
	closeFuncs map[string]func() error
	mu         sync.Mutex
}

func (ls *listenerSet) ListenStream(addr string) (service.StreamListener, error) {
	ls.mu.Lock()
	defer ls.mu.Unlock()

	key := "stream/" + addr
	if _, exists := ls.closeFuncs[key]; exists {
		return nil, fmt.Errorf("stream listener for %s already exists", addr)
	}
	ln, err := ls.manager.ListenStream(addr)
	if err != nil {
		return nil, err
	}
	ls.closeFuncs[key] = ln.Close
	return ln, nil
}

func (ls *listenerSet) ListenPacket(addr string) (net.PacketConn, error) {
	ls.mu.Lock()
	defer ls.mu.Unlock()

	key := "packet/" + addr
	if _, exists := ls.closeFuncs[key]; exists {
		return nil, fmt.Errorf("packet listener for %s already exists", addr)
	}
	pc, err := ls.manager.ListenPacket(addr)
	if err != nil {
		return nil, err
	}
	ls.closeFuncs[key] = pc.Close
	return pc, nil
}

func (ls *listenerSet) Close() error {
	ls.mu.Lock()
	defer ls.mu.Unlock()

	for addr, closeFunc := range ls.closeFuncs {
		if err := closeFunc(); err != nil {
			return fmt.Errorf("listener %s failed to close: %w", addr, err)
		}
	}
	ls.closeFuncs = nil
	return nil
}

A  => internal/store/store.go +61 -0
@@ 1,61 @@
package store

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
)

type AccessKey struct {
	ID        string     `yaml:"id"`
	Name      string     `yaml:"name"`
	Password  string     `yaml:"password"`
	Port      int        `yaml:"port"`
	Method    string     `yaml:"method"`
	DataLimit *DataLimit `yaml:"data_limit,omitempty"`
	AWG       *AWGKeyData `yaml:"awg,omitempty"`
}

type AWGKeyData struct {
	PrivateKey string `yaml:"private_key"`
	PublicKey  string `yaml:"public_key"`
	AllowedIP  string `yaml:"allowed_ip"`
}

type DataLimit struct {
	Bytes int64 `yaml:"bytes"`
}

type ServerState struct {
	ID                   string     `yaml:"server_id"`
	Name                 string     `yaml:"name"`
	Hostname             string     `yaml:"hostname"`
	DefaultPort          int        `yaml:"default_port"`
	DefaultCipher        string     `yaml:"default_cipher"`
	AccessKeyDataLimit   *DataLimit `yaml:"access_key_data_limit,omitempty"`
	MetricsEnabled       bool       `yaml:"metrics_enabled"`
	NextID               int        `yaml:"next_id"`
	CreatedTimestampMs   int64      `yaml:"created_timestamp_ms"`
	PortForNewAccessKeys int        `yaml:"port_for_new_access_keys"`
	AWGPrivateKey        string     `yaml:"awg_private_key,omitempty"`
	AWGPublicKey         string     `yaml:"awg_public_key,omitempty"`
}

type Store interface {
	ListKeys() []AccessKey
	GetKey(id string) (AccessKey, bool)
	CreateKey(ak AccessKey) error
	UpdateKey(id string, fn func(*AccessKey)) error
	DeleteKey(id string) error

	GetServer() ServerState
	UpdateServer(fn func(*ServerState)) error
}

func GeneratePassword() (string, error) {
	buf := make([]byte, 16)
	if _, err := rand.Read(buf); err != nil {
		return "", fmt.Errorf("generating random password: %w", err)
	}
	return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(buf), nil
}

A  => internal/store/yamlfile.go +138 -0
@@ 1,138 @@
package store

import (
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"time"

	"gopkg.in/yaml.v3"
)

type persistedData struct {
	Server ServerState `yaml:"server"`
	Keys   []AccessKey `yaml:"access_keys"`
}

type YAMLFileStore struct {
	mu       sync.RWMutex
	filePath string
	data     persistedData
}

func NewYAMLFileStore(filePath string, serverID, serverName, hostname, defaultCipher string, defaultPort int) (*YAMLFileStore, error) {
	s := &YAMLFileStore{filePath: filePath}

	if err := os.MkdirAll(filepath.Dir(filePath), 0700); err != nil {
		return nil, fmt.Errorf("creating state directory: %w", err)
	}

	data, err := os.ReadFile(filePath)
	if err != nil {
		if !os.IsNotExist(err) {
			return nil, fmt.Errorf("reading state file: %w", err)
		}
		// Initialize with defaults.
		s.data = persistedData{
			Server: ServerState{
				ID:                   serverID,
				Name:                 serverName,
				Hostname:             hostname,
				DefaultPort:          defaultPort,
				DefaultCipher:        defaultCipher,
				PortForNewAccessKeys: defaultPort,
				CreatedTimestampMs:   time.Now().UnixMilli(),
			},
		}
		return s, s.persist()
	}

	if err := yaml.Unmarshal(data, &s.data); err != nil {
		return nil, fmt.Errorf("parsing state file: %w", err)
	}
	return s, nil
}

func (s *YAMLFileStore) ListKeys() []AccessKey {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]AccessKey, len(s.data.Keys))
	copy(out, s.data.Keys)
	return out
}

func (s *YAMLFileStore) GetKey(id string) (AccessKey, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, k := range s.data.Keys {
		if k.ID == id {
			return k, true
		}
	}
	return AccessKey{}, false
}

func (s *YAMLFileStore) CreateKey(ak AccessKey) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for _, k := range s.data.Keys {
		if k.ID == ak.ID {
			return fmt.Errorf("key %s already exists", ak.ID)
		}
	}
	s.data.Keys = append(s.data.Keys, ak)
	return s.persist()
}

func (s *YAMLFileStore) UpdateKey(id string, fn func(*AccessKey)) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.data.Keys {
		if s.data.Keys[i].ID == id {
			fn(&s.data.Keys[i])
			return s.persist()
		}
	}
	return fmt.Errorf("key %s not found", id)
}

func (s *YAMLFileStore) DeleteKey(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i, k := range s.data.Keys {
		if k.ID == id {
			s.data.Keys = append(s.data.Keys[:i], s.data.Keys[i+1:]...)
			return s.persist()
		}
	}
	return fmt.Errorf("key %s not found", id)
}

func (s *YAMLFileStore) GetServer() ServerState {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.data.Server
}

func (s *YAMLFileStore) UpdateServer(fn func(*ServerState)) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	fn(&s.data.Server)
	return s.persist()
}

func (s *YAMLFileStore) persist() error {
	data, err := yaml.Marshal(s.data)
	if err != nil {
		return fmt.Errorf("marshaling state: %w", err)
	}
	tmpPath := s.filePath + ".tmp"
	if err := os.WriteFile(tmpPath, data, 0600); err != nil {
		return fmt.Errorf("writing temp state file: %w", err)
	}
	if err := os.Rename(tmpPath, s.filePath); err != nil {
		return fmt.Errorf("renaming state file: %w", err)
	}
	return nil
}