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 => +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 => +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
+}