From 5afb3bad1be8eb82352dde56e6836a0a8ea4ef7f Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 19 Mar 2026 09:45:56 +0300 Subject: [PATCH] feat: add optional shadowsocks and outline smart dialer config Add support for optional Shadowsocks server and Outline Smart Dialer configuration. - Make Shadowsocks server optional with enabled flag in config. - Add Outline Smart Dialer YAML config endpoint for AWG fallback transport. - Migrate to new outline import paths (golang.getoutline.org). - Refactor ACME settings into separate config section. - Add AWG hostname and mux_enabled options for flexible deployment. - Generate random AWG obfuscation parameters in install script. - Update README with dependencies table and new API endpoint. --- README.md | 17 ++++- cmd/shroud/main.go | 76 ++++++++++++---------- config.example.yaml | 29 +++++---- go.mod | 4 +- go.sum | 8 +-- install.sh | 56 ++++++++++++---- internal/api/handlers.go | 116 +++++++++++++++++++++++++++++++--- internal/api/router.go | 19 +++++- internal/awgserver/server.go | 3 +- internal/config/config.go | 68 ++++++++++++++++---- internal/metrics/collector.go | 8 +-- internal/ssserver/server.go | 10 +-- 12 files changed, 316 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 8e56b3078ecb37edfdf3378ec38e96a0198b0171..70d9a25c5f530393511ad9d545389bb585090e88 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # shroud -Single-binary Go replacement for the [Outline VPN](https://getoutline.org/) server stack. Replaces the original 3-process deployment (Node.js shadowbox + outline-ss-server + Prometheus) with one Go binary that uses [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server) as a library. +Single-binary Go replacement for the [Outline VPN](https://getoutline.org/) server stack. Replaces the original 3-process deployment (Node.js shadowbox + outline-ss-server + Prometheus) with one Go binary that uses [outline-ss-server](https://golang.getoutline.org/tunnel-server) as a library. Optionally includes [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) support with HTTP/3 QUIC cover traffic on port 443. @@ -71,10 +71,10 @@ server: hostname: "example.com" api: - listen_addr: ":8081" + listen_addr: ":443" metrics: - listen_addr: "127.0.0.1:8081" + listen_addr: "127.0.0.1:8082" node_exporter_collectors: - cpu - meminfo @@ -121,6 +121,7 @@ All management endpoints live under `//` prefix. The secret is auto-gene | `PUT` | `//metrics/enabled` | Toggle metrics | | `GET` | `//metrics/transfer` | Per-key byte transfer | | `GET` | `//access-keys/{id}/awg-config` | Download AmneziaWG config | +| `GET` | `//access-keys/{id}/outline-config` | Download Outline Smart Dialer config | Public endpoints (no auth): @@ -144,6 +145,16 @@ cmd/shroud/main.go CLI entry point, cobra commands, signal handling The Shadowsocks proxy is imported as a Go library — not run as a subprocess. When keys change, `SyncKeys()` rebuilds cipher lists and hot-swaps listeners. The integration surface is ~100 lines in `internal/ssserver/server.go`. +## Main Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| [outline-ss-server](https://golang.getoutline.org/tunnel-server) | v1.9.3-rc2 | Shadowsocks proxy (used as library) | +| [outline-sdk](https://golang.getoutline.org/sdk) | v0.0.21 | Outline transport primitives | +| [amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) | v1.0.4 | DPI-resistant WireGuard implementation | +| [prometheus/client_golang](https://github.com/prometheus/client_golang) | v1.23.2 | Go Prometheus client and registry | +| [prometheus/node_exporter](https://github.com/prometheus/node_exporter) | v1.10.2 | OS-level metrics collectors (Linux) | + ## Building with version info ```bash diff --git a/cmd/shroud/main.go b/cmd/shroud/main.go index 33d35ff8d750653aca2b325009f73e1e1824062d..5315f5d3668272081b297fa60a4527ca59415c5e 100644 --- a/cmd/shroud/main.go +++ b/cmd/shroud/main.go @@ -17,7 +17,7 @@ import ( "syscall" "time" - "github.com/Jigsaw-Code/outline-ss-server/ipinfo" + "golang.getoutline.org/tunnel-server/ipinfo" "github.com/google/uuid" "github.com/lmittmann/tint" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -490,23 +490,6 @@ func runServe(configFile string, verbose bool) error { 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 != "" { @@ -538,27 +521,47 @@ func runServe(configFile string, verbose bool) error { cfg.Server.Name, cfg.Server.Hostname, cfg.Shadowsocks.DefaultCipher, - ssDefaultPort, + cfg.Shadowsocks.DefaultPort, ) if err != nil { return fmt.Errorf("initializing store: %w", err) } - // Set up Shadowsocks server. - ss := ssserver.New( - natTimeout, - cfg.Shadowsocks.ReplayHistory, - serverMetrics, - transferTracker, - logger, - ) + // Set up Shadowsocks server (optional). + var ss *ssserver.Server + if *cfg.Shadowsocks.Enabled { + 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) + } + + 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) + // 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)) } - logger.Info("Loaded existing access keys.", "count", len(keys)) + logger.Info("Shadowsocks server enabled.") } // Set up AmneziaWG server (optional). @@ -616,9 +619,10 @@ func runServe(configFile string, verbose bool) error { Address: serverAddr, MTU: cfg.AmneziaWG.MTU, PrivateKey: srv.AWGPrivateKey, + MuxEnabled: cfg.AmneziaWG.AWGMuxEnabled(), Domain: cfg.AmneziaWG.Domain, - CertCache: cfg.AmneziaWG.CertCache, - ACMEHTTPPort: cfg.AmneziaWG.ACMEHTTPPort, + CertCache: cfg.ACME.CertCache, + ACMEHTTPPort: cfg.ACME.HTTPPort, Jc: cfg.AmneziaWG.Jc, Jmin: cfg.AmneziaWG.Jmin, Jmax: cfg.AmneziaWG.Jmax, @@ -704,7 +708,9 @@ func runServe(configFile string, verbose bool) error { if awg != nil { awg.Stop() } - ss.Stop() + if ss != nil { + ss.Stop() + } apiServer.Close() metricsServer.Close() diff --git a/config.example.yaml b/config.example.yaml index 1ba58b1d733ba499e50a126e5a49decfa37587a0..65ef279195375135427a52e505d4d85aa66a67f7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -19,25 +19,26 @@ metrics: - netdev shadowsocks: - default_port: 0 # 0 = pick random unused port on first start + enabled: true # set to false to disable Shadowsocks + default_port: 0 # 0 = pick random unused port on first start + # Supported: chacha20-ietf-poly1305, aes-256-gcm, aes-192-gcm, aes-128-gcm default_cipher: chacha20-ietf-poly1305 nat_timeout: 5m replay_history: 10000 - ip_country_db: "" - ip_asn_db: "" + ip_country_db: "" # optional: MaxMind GeoLite2-Country.mmdb for per-country metrics + ip_asn_db: "" # optional: MaxMind GeoLite2-ASN.mmdb for per-ASN metrics amneziawg: - enabled: false - listen_port: 443 # shared UDP port for AWG + HTTP/3 + enabled: true + 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+ + address: "10.14.0.0/24" # server gets .1, peers get .2+ mtu: 1420 - private_key: "" # auto-generated if empty + 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/shroud/certs - acme_http_port: 80 + hostname: "" # AWG endpoint hostname; falls back to server.hostname when mux is enabled + mux_enabled: true # null = auto (on when domain is set), true/false = force + domain: "" # defaults to server.hostname; HTTP/3 cover disabled if empty # Obfuscation parameters (must match client config) jc: 4 jmin: 64 @@ -51,4 +52,10 @@ amneziawg: h3: "250000-300000" h4: "350000-400000" +# ACME (Let's Encrypt) certificate settings. +# Used by AmneziaWG HTTP/3 cover server for DPI resistance. +acme: + cert_cache: /var/lib/shroud/certs + http_port: 80 # port for ACME HTTP-01 challenges + state_file: state.yaml diff --git a/go.mod b/go.mod index 4485bab4f8d1437b26a0555b0bb51ff6ae4ddf1f..3bc08fbd93edc2e4196148a3f63aff40b537ec9a 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,6 @@ module sourcecraft.dev/bigbes/shroud 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 @@ -12,6 +10,8 @@ require ( 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.getoutline.org/sdk v0.0.21 + golang.getoutline.org/tunnel-server v1.9.3-rc2 golang.org/x/crypto v0.42.0 golang.org/x/term v0.35.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 94985955d921c4c2907f59eb8385d4e72f2a7610..0bcf495508fe3911c55bc113de9525dee82498b6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,3 @@ -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= @@ -146,6 +142,10 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV 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.getoutline.org/sdk v0.0.21 h1:zgtenz5DMbnIPOsuAOHNiWdrri81fHyBxhSfRi6Dk8s= +golang.getoutline.org/sdk v0.0.21/go.mod h1:raUAs4PYbEaT/cLTK6PviiKSh7gjEj7JJczFFFr41zc= +golang.getoutline.org/tunnel-server v1.9.3-rc2 h1:y1veksOBtBjxbY60MnDOrWcxspmeUV8ArNd1oVV+btE= +golang.getoutline.org/tunnel-server v1.9.3-rc2/go.mod h1:Z9i9A//RXuFZSDE7QDZCC6GI0uqh21juWMPHpagaEFI= 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= diff --git a/install.sh b/install.sh index d34deb91126fa8a01f1c9eed7044e6654b846ff1..e98e69a4d6fe3f5bd3de3e86c4f3f8656956af4d 100755 --- a/install.sh +++ b/install.sh @@ -156,6 +156,35 @@ else API_SECRET=$(head -c 16 /dev/urandom | xxd -p) + # Generate random AmneziaWG obfuscation parameters. + # Each install gets unique values to resist DPI fingerprinting. + AWG_JC=$(( RANDOM % 8 + 3 )) # 3..10 + AWG_JMIN=$(( RANDOM % 100 + 50 )) # 50..149 + AWG_JMAX=$(( AWG_JMIN + RANDOM % 500 + 100 )) # jmin+100..jmin+599 + AWG_S1=$(( RANDOM % 500 + 10 )) # 10..509 + # Ensure 148+s1 != 92+s2 (packet sizes must differ). + while true; do + AWG_S2=$(( RANDOM % 500 + 10 )) + [[ $(( 148 + AWG_S1 )) -ne $(( 92 + AWG_S2 )) ]] && break + done + AWG_S3=$(( RANDOM % 500 + 10 )) + AWG_S4=$(( RANDOM % 500 + 10 )) + # Generate 4 unique random header magic values (>4, full uint32 range). + rand_u32() { od -An -tu4 -N4 /dev/urandom | tr -d ' '; } + declare -A _hdr_seen=() + AWG_HEADERS=() + while [[ ${#AWG_HEADERS[@]} -lt 4 ]]; do + v=$(rand_u32) + [[ $v -le 4 ]] && continue + [[ -n "${_hdr_seen[$v]:-}" ]] && continue + _hdr_seen[$v]=1 + AWG_HEADERS+=("$v") + done + AWG_H1=${AWG_HEADERS[0]} + AWG_H2=${AWG_HEADERS[1]} + AWG_H3=${AWG_HEADERS[2]} + AWG_H4=${AWG_HEADERS[3]} + cat > "$CONFIG_FILE" <