From ca7efb0f8ff6e45da70363dcde1608ac92cd46d4 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 18 Mar 2026 23:53:42 +0300 Subject: [PATCH] Initial commit: Shadowsocks + AmneziaWG VPN server Single-binary Go replacement for the Outline VPN server stack. Supports Shadowsocks (via outline-ss-server library) and AmneziaWG with HTTP/3 mux for DPI resistance (MuxBind shares UDP port between AWG and a real QUIC/HTTP/3 server with Let's Encrypt). Includes systemd unit, install script for Ubuntu, and REST API compatible with the Outline Manager. false --- .gitignore | 13 + cmd/outline-distro/main.go | 722 +++++++++++++++++++++++++ config.example.yaml | 54 ++ dist/outline-distro.service | 59 ++ go.mod | 71 +++ go.sum | 188 +++++++ install.sh | 374 +++++++++++++ internal/api/handlers.go | 415 ++++++++++++++ internal/api/models.go | 66 +++ internal/api/router.go | 129 +++++ internal/awgserver/detect.go | 51 ++ internal/awgserver/detect_test.go | 71 +++ internal/awgserver/filteredconn.go | 101 ++++ internal/awgserver/h3server.go | 73 +++ internal/awgserver/headerrange.go | 45 ++ internal/awgserver/headerrange_test.go | 51 ++ internal/awgserver/ipalloc.go | 55 ++ internal/awgserver/ipalloc_test.go | 73 +++ internal/awgserver/keygen.go | 50 ++ internal/awgserver/keygen_test.go | 64 +++ internal/awgserver/muxbind.go | 180 ++++++ internal/awgserver/server.go | 226 ++++++++ internal/awgserver/static/index.html | 16 + internal/awgserver/static/robots.txt | 2 + internal/awgserver/tun_darwin.go | 23 + internal/awgserver/tun_linux.go | 22 + internal/config/config.go | 177 ++++++ internal/metrics/collector.go | 172 ++++++ internal/metrics/nodeexporter.go | 20 + internal/ssserver/server.go | 208 +++++++ internal/store/store.go | 61 +++ internal/store/yamlfile.go | 138 +++++ 32 files changed, 3970 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/outline-distro/main.go create mode 100644 config.example.yaml create mode 100644 dist/outline-distro.service create mode 100644 go.mod create mode 100644 go.sum create mode 100755 install.sh create mode 100644 internal/api/handlers.go create mode 100644 internal/api/models.go create mode 100644 internal/api/router.go create mode 100644 internal/awgserver/detect.go create mode 100644 internal/awgserver/detect_test.go create mode 100644 internal/awgserver/filteredconn.go create mode 100644 internal/awgserver/h3server.go create mode 100644 internal/awgserver/headerrange.go create mode 100644 internal/awgserver/headerrange_test.go create mode 100644 internal/awgserver/ipalloc.go create mode 100644 internal/awgserver/ipalloc_test.go create mode 100644 internal/awgserver/keygen.go create mode 100644 internal/awgserver/keygen_test.go create mode 100644 internal/awgserver/muxbind.go create mode 100644 internal/awgserver/server.go create mode 100644 internal/awgserver/static/index.html create mode 100644 internal/awgserver/static/robots.txt create mode 100644 internal/awgserver/tun_darwin.go create mode 100644 internal/awgserver/tun_linux.go create mode 100644 internal/config/config.go create mode 100644 internal/metrics/collector.go create mode 100644 internal/metrics/nodeexporter.go create mode 100644 internal/ssserver/server.go create mode 100644 internal/store/store.go create mode 100644 internal/store/yamlfile.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6e6012e4a117b0130a27d67975da8a60bdd58bf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Binary (root only, not cmd/outline-distro/) +/outline-distro + +# State/runtime +state.yaml +access.yaml +*.tmp + +# IDE +.idea/ +.vscode/ +*.swp +*~ diff --git a/cmd/outline-distro/main.go b/cmd/outline-distro/main.go new file mode 100644 index 0000000000000000000000000000000000000000..2b9b14ff3e85bfc34368f369502cd92c657db01c --- /dev/null +++ b/cmd/outline-distro/main.go @@ -0,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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000000000000000000000000000000000000..52a67cc1132925ba77187769a64fa5ac86bdad2d --- /dev/null +++ b/config.example.yaml @@ -0,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 diff --git a/dist/outline-distro.service b/dist/outline-distro.service new file mode 100644 index 0000000000000000000000000000000000000000..a91945fcc25183554a0b0223af7bd8ec738e5c6c --- /dev/null +++ b/dist/outline-distro.service @@ -0,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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..1cc36232b2e27f233b4c0101986006f0d437657e --- /dev/null +++ b/go.mod @@ -0,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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..94985955d921c4c2907f59eb8385d4e72f2a7610 --- /dev/null +++ b/go.sum @@ -0,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= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000000000000000000000000000000000000..6346502cd8912fa23ca7fc9194c42f17db210664 --- /dev/null +++ b/install.sh @@ -0,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 /install.sh | bash # SS only +# curl -sSL /install.sh | bash -s -- --awg # SS + AmneziaWG +# curl -sSL /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" < "$UNIT_FILE" </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 "") + +cat </awg-config + +EOF +fi + +echo " Open the Shadowsocks port once keys are created (see 'key list')." +echo "" diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000000000000000000000000000000000000..591062d77d67138f6743b2396d50cf6c76a876df --- /dev/null +++ b/internal/api/handlers.go @@ -0,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())) +} diff --git a/internal/api/models.go b/internal/api/models.go new file mode 100644 index 0000000000000000000000000000000000000000..eed1dd438b54e4de5715b0dbd7f8b9e82ddc6663 --- /dev/null +++ b/internal/api/models.go @@ -0,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"` +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000000000000000000000000000000000000..8a603b8f391f7f23447bf03e8ca067e726b12653 --- /dev/null +++ b/internal/api/router.go @@ -0,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) +} diff --git a/internal/awgserver/detect.go b/internal/awgserver/detect.go new file mode 100644 index 0000000000000000000000000000000000000000..088795065f3fa949872914056b7e3e143b2b35dd --- /dev/null +++ b/internal/awgserver/detect.go @@ -0,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 +} diff --git a/internal/awgserver/detect_test.go b/internal/awgserver/detect_test.go new file mode 100644 index 0000000000000000000000000000000000000000..caed66f25eafa5efe2aeeba767767cfdae13b8d1 --- /dev/null +++ b/internal/awgserver/detect_test.go @@ -0,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") + } +} diff --git a/internal/awgserver/filteredconn.go b/internal/awgserver/filteredconn.go new file mode 100644 index 0000000000000000000000000000000000000000..594592a54b9f46617bac7056fb5e9f3ef022e60e --- /dev/null +++ b/internal/awgserver/filteredconn.go @@ -0,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{} diff --git a/internal/awgserver/h3server.go b/internal/awgserver/h3server.go new file mode 100644 index 0000000000000000000000000000000000000000..90687898014be56d59cca8d565210efd56dbef4e --- /dev/null +++ b/internal/awgserver/h3server.go @@ -0,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 +} diff --git a/internal/awgserver/headerrange.go b/internal/awgserver/headerrange.go new file mode 100644 index 0000000000000000000000000000000000000000..72404d3ecdf1e2700e3b30e1cec6d182138e3781 --- /dev/null +++ b/internal/awgserver/headerrange.go @@ -0,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 +} diff --git a/internal/awgserver/headerrange_test.go b/internal/awgserver/headerrange_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f3f03b9d9e14772a6668c6977912a23794f338fe --- /dev/null +++ b/internal/awgserver/headerrange_test.go @@ -0,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") + } +} diff --git a/internal/awgserver/ipalloc.go b/internal/awgserver/ipalloc.go new file mode 100644 index 0000000000000000000000000000000000000000..e05c590f81337ac86857d38cf62a50eaced35ed9 --- /dev/null +++ b/internal/awgserver/ipalloc.go @@ -0,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()) +} diff --git a/internal/awgserver/ipalloc_test.go b/internal/awgserver/ipalloc_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f180d458eea7de44249c604f4aa226c54a543891 --- /dev/null +++ b/internal/awgserver/ipalloc_test.go @@ -0,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) + } +} diff --git a/internal/awgserver/keygen.go b/internal/awgserver/keygen.go new file mode 100644 index 0000000000000000000000000000000000000000..e46e0729577246ceb09136a25be0ca870e6f853f --- /dev/null +++ b/internal/awgserver/keygen.go @@ -0,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 +} diff --git a/internal/awgserver/keygen_test.go b/internal/awgserver/keygen_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2c295295aa0455a8018c1493e6d2ee7496e8b8bf --- /dev/null +++ b/internal/awgserver/keygen_test.go @@ -0,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") + } +} diff --git a/internal/awgserver/muxbind.go b/internal/awgserver/muxbind.go new file mode 100644 index 0000000000000000000000000000000000000000..eb38e8574951883b5b00c77be4dcbeb64411c155 --- /dev/null +++ b/internal/awgserver/muxbind.go @@ -0,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{} +} diff --git a/internal/awgserver/server.go b/internal/awgserver/server.go new file mode 100644 index 0000000000000000000000000000000000000000..3ccc8b890804af1e06bef01c31515c9343230511 --- /dev/null +++ b/internal/awgserver/server.go @@ -0,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) + } +} diff --git a/internal/awgserver/static/index.html b/internal/awgserver/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..88e57a20bffe7b01dec0a82e1a13b222af27adfe --- /dev/null +++ b/internal/awgserver/static/index.html @@ -0,0 +1,16 @@ + + + + + + Welcome + + + +

Welcome

+

This server is running.

+ + diff --git a/internal/awgserver/static/robots.txt b/internal/awgserver/static/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..1f53798bb4fe33c86020be7f10c44f29486fd190 --- /dev/null +++ b/internal/awgserver/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/internal/awgserver/tun_darwin.go b/internal/awgserver/tun_darwin.go new file mode 100644 index 0000000000000000000000000000000000000000..f2f205594ea9248d74f5f9938cf19a255a90d485 --- /dev/null +++ b/internal/awgserver/tun_darwin.go @@ -0,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 +} diff --git a/internal/awgserver/tun_linux.go b/internal/awgserver/tun_linux.go new file mode 100644 index 0000000000000000000000000000000000000000..f75770ff936ff386d521b22db6ef521f23eb6678 --- /dev/null +++ b/internal/awgserver/tun_linux.go @@ -0,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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..01fe462911808cbf616af09db86459863d3fd83c --- /dev/null +++ b/internal/config/config.go @@ -0,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 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) +} diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go new file mode 100644 index 0000000000000000000000000000000000000000..8d33eea07ffd629c434c40bc1981be6c4d0aa808 --- /dev/null +++ b/internal/metrics/collector.go @@ -0,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 +} diff --git a/internal/metrics/nodeexporter.go b/internal/metrics/nodeexporter.go new file mode 100644 index 0000000000000000000000000000000000000000..59f4a98533af90e81d3434494d506c385e154761 --- /dev/null +++ b/internal/metrics/nodeexporter.go @@ -0,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 +} diff --git a/internal/ssserver/server.go b/internal/ssserver/server.go new file mode 100644 index 0000000000000000000000000000000000000000..326c1af502713d0ef0487102d71a693cc9f772b5 --- /dev/null +++ b/internal/ssserver/server.go @@ -0,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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000000000000000000000000000000000000..d626a7d1062d1425a505bae08e2e5217760a57ba --- /dev/null +++ b/internal/store/store.go @@ -0,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 +} diff --git a/internal/store/yamlfile.go b/internal/store/yamlfile.go new file mode 100644 index 0000000000000000000000000000000000000000..922faf31ac9803d6016210f9a283a7bd212ee9b5 --- /dev/null +++ b/internal/store/yamlfile.go @@ -0,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 +}