M README.md => README.md +14 -3
@@ 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 `/<secret>/` prefix. The secret is auto-gene
| `PUT` | `/<secret>/metrics/enabled` | Toggle metrics |
| `GET` | `/<secret>/metrics/transfer` | Per-key byte transfer |
| `GET` | `/<secret>/access-keys/{id}/awg-config` | Download AmneziaWG config |
+| `GET` | `/<secret>/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
M cmd/shroud/main.go => cmd/shroud/main.go +41 -35
@@ 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()
M config.example.yaml => config.example.yaml +18 -11
@@ 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
M go.mod => go.mod +2 -2
@@ 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
M go.sum => go.sum +4 -4
@@ 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=
M install.sh => install.sh +44 -12
@@ 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" <<YAML
server:
name: "${SERVER_NAME}"
@@ 189,20 218,23 @@ amneziawg:
address: "${AWG_SUBNET}"
mtu: ${AWG_MTU}
dns: "${AWG_DNS}"
+ hostname: ""
domain: "${AWG_DOMAIN}"
+ jc: ${AWG_JC}
+ jmin: ${AWG_JMIN}
+ jmax: ${AWG_JMAX}
+ s1: ${AWG_S1}
+ s2: ${AWG_S2}
+ s3: ${AWG_S3}
+ s4: ${AWG_S4}
+ h1: "${AWG_H1}"
+ h2: "${AWG_H2}"
+ h3: "${AWG_H3}"
+ h4: "${AWG_H4}"
+
+acme:
cert_cache: ${DATA_DIR}/certs
- acme_http_port: 80
- jc: 4
- jmin: 64
- jmax: 256
- s1: 32
- s2: 28
- s3: 20
- s4: 25
- h1: "50000-100000"
- h2: "150000-200000"
- h3: "250000-300000"
- h4: "350000-400000"
+ http_port: 80
state_file: ${DATA_DIR}/state.yaml
YAML
M internal/api/handlers.go => internal/api/handlers.go +108 -8
@@ 4,8 4,10 @@ import (
"fmt"
"net/http"
"strings"
+ "text/template"
"sourcecraft.dev/bigbes/shroud/internal/awgserver"
+ "sourcecraft.dev/bigbes/shroud/internal/config"
"sourcecraft.dev/bigbes/shroud/internal/store"
)
@@ 307,10 309,7 @@ func (h *Handler) keyToResponse(k store.AccessKey) AccessKeyResponse {
}
if k.AWG != nil && h.awgConfig != nil {
srv := h.store.GetServer()
- hostname := srv.Hostname
- if hostname == "" {
- hostname = "localhost"
- }
+ hostname := h.awgHostname(srv.Hostname)
resp.AWG = &AWGKeyResponse{
PublicKey: k.AWG.PublicKey,
AllowedIP: k.AWG.AllowedIP,
@@ 373,10 372,7 @@ func (h *Handler) getAWGConfig(w http.ResponseWriter, r *http.Request) {
}
srv := h.store.GetServer()
- hostname := srv.Hostname
- if hostname == "" {
- hostname = "localhost"
- }
+ hostname := h.awgHostname(srv.Hostname)
var b strings.Builder
fmt.Fprintf(&b, "[Interface]\n")
@@ 413,3 409,107 @@ func (h *Handler) getAWGConfig(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(b.String()))
}
+
+var outlineConfigTmpl = template.Must(template.New("outline").Parse(`dns:
+ - {system: {}}
+tls:
+ - ""
+fallback:
+ - awg:
+ address: [{{.AllowedIP}}]
+ dns: [{{.DNS}}]
+ private_key: {{.PrivateKey}}
+ mtu: {{.MTU}}
+{{- if gt .Jc 0}}
+ jc: {{.Jc}}
+ jmin: {{.Jmin}}
+ jmax: {{.Jmax}}
+{{- end}}
+{{- if ne .S1 0}}
+ s1: {{.S1}}
+{{- end}}
+{{- if ne .S2 0}}
+ s2: {{.S2}}
+{{- end}}
+{{- if ne .S3 0}}
+ s3: {{.S3}}
+{{- end}}
+{{- if ne .S4 0}}
+ s4: {{.S4}}
+{{- end}}
+{{- if .H1}}
+ h1: {{.H1}}
+{{- end}}
+{{- if .H2}}
+ h2: {{.H2}}
+{{- end}}
+{{- if .H3}}
+ h3: {{.H3}}
+{{- end}}
+{{- if .H4}}
+ h4: {{.H4}}
+{{- end}}
+ peers:
+ - public_key: {{.ServerPublicKey}}
+ endpoint: {{.Endpoint}}
+ allowed_ips: [0.0.0.0/0, "::/0"]
+ persistent_keepalive_interval: 25
+`))
+
+type outlineConfigData struct {
+ config.AmneziaWGConfig
+ AllowedIP string
+ PrivateKey string
+ ServerPublicKey string
+ Endpoint string
+}
+
+// getOutlineConfig returns a Smart Dialer YAML config for the Outline SDK client.
+// The config includes AWG as a fallback transport, compatible with the outline glue
+// in github.com/amnezia-vpn/amneziawg-go/outline.
+func (h *Handler) getOutlineConfig(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 := h.awgHostname(srv.Hostname)
+
+ dns := h.awgConfig.DNS
+ if dns == "" {
+ dns = "8.8.8.8, 8.8.4.4"
+ }
+
+ data := outlineConfigData{
+ AmneziaWGConfig: *h.awgConfig,
+ AllowedIP: key.AWG.AllowedIP,
+ PrivateKey: key.AWG.PrivateKey,
+ ServerPublicKey: srv.AWGPublicKey,
+ Endpoint: fmt.Sprintf("%s:%d", hostname, h.awgConfig.ListenPort),
+ }
+ data.AmneziaWGConfig.DNS = dns
+
+ var b strings.Builder
+ if err := outlineConfigTmpl.Execute(&b, data); err != nil {
+ h.logger.Error("Failed to render outline config.", "err", err)
+ writeError(w, http.StatusInternalServerError, "failed to render config")
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-outline.yaml"`, key.Name))
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(b.String()))
+}
M internal/api/router.go => internal/api/router.go +17 -2
@@ 65,6 65,8 @@ func NewRouter(secret string, h *Handler) http.Handler {
// AmneziaWG client config download.
mux.HandleFunc("GET "+prefix+"/access-keys/{id}/awg-config", h.getAWGConfig)
+ // Outline Smart Dialer config (AWG as fallback transport).
+ mux.HandleFunc("GET "+prefix+"/access-keys/{id}/outline-config", h.getOutlineConfig)
// Metrics endpoints.
mux.HandleFunc("GET "+prefix+"/metrics/enabled", h.getMetricsEnabled)
@@ 89,8 91,10 @@ func corsMiddleware(next http.Handler) http.Handler {
func (h *Handler) syncKeys() error {
keys := h.store.ListKeys()
- if err := h.ssServer.SyncKeys(keys); err != nil {
- return err
+ if h.ssServer != nil {
+ if err := h.ssServer.SyncKeys(keys); err != nil {
+ return err
+ }
}
if h.awgServer != nil {
if err := h.awgServer.SyncKeys(keys); err != nil {
@@ 113,6 117,17 @@ func (h *Handler) accessURL(key store.AccessKey) string {
return fmt.Sprintf("ss://%s@%s:%d#%s", cred, hostname, key.Port, key.Name)
}
+// awgHostname resolves the hostname for AWG client endpoints.
+// Uses awg.hostname if set; falls back to serverHostname when muxer is enabled;
+// falls back to "localhost" if still empty.
+func (h *Handler) awgHostname(serverHostname string) string {
+ hostname := h.awgConfig.AWGHostname(serverHostname)
+ if hostname == "" {
+ hostname = "localhost"
+ }
+ return hostname
+}
+
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
M internal/awgserver/server.go => internal/awgserver/server.go +2 -1
@@ 21,6 21,7 @@ type Config struct {
Address string // server CIDR, e.g. "10.14.0.1/24"
MTU int
PrivateKey string // server interface key, base64
+ MuxEnabled bool // enable AWG+HTTP/3 muxer on the same port
Domain string
CertCache string
ACMEHTTPPort int
@@ 96,7 97,7 @@ func (s *Server) Start() error {
// 7. Get FilteredConn and start HTTP/3 server.
fc := s.muxBind.FilteredConn()
- if s.cfg.Domain != "" {
+ if s.cfg.MuxEnabled && 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)
M internal/config/config.go => internal/config/config.go +57 -11
@@ 19,6 19,8 @@ type Config struct {
Shadowsocks ShadowsocksConfig `yaml:"shadowsocks"`
// AmneziaWG VPN settings.
AmneziaWG AmneziaWGConfig `yaml:"amneziawg"`
+ // ACME (Let's Encrypt) certificate settings.
+ ACME ACMEConfig `yaml:"acme"`
// Path to persistent state file.
// Defaults to "state.yaml" in the same directory as the config file.
State string `yaml:"state_file"`
@@ 71,14 73,18 @@ type AmneziaWGConfig struct {
PrivateKey string `yaml:"private_key"`
// DNS server for client configs.
DNS string `yaml:"dns"`
+ // Hostname for AWG client endpoint. If empty and muxer is enabled, falls back to server.hostname.
+ Hostname string `yaml:"hostname"`
+
+ // HTTP/3 + AWG muxer on the same UDP port.
+ // When enabled, AWG and QUIC/HTTP3 share the listen port.
+ // nil = auto (enabled when domain is set), true = force on, false = force off.
+ MuxEnabled *bool `yaml:"mux_enabled"`
// HTTP/3 cover for DPI resistance.
- // Domain for Let's Encrypt certificate. If empty, HTTP/3 server is disabled.
+ // Domain for Let's Encrypt certificate. Defaults to server.hostname if empty.
+ // If still empty after defaulting, 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"`
@@ 94,7 100,16 @@ type AmneziaWGConfig struct {
H4 string `yaml:"h4"`
}
+type ACMEConfig struct {
+ // Directory for cached TLS certificates.
+ CertCache string `yaml:"cert_cache"`
+ // HTTP port for ACME HTTP-01 challenges.
+ HTTPPort int `yaml:"http_port"`
+}
+
type ShadowsocksConfig struct {
+ // Enable Shadowsocks protocol. Defaults to true.
+ Enabled *bool `yaml:"enabled"`
// 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.
@@ 136,6 151,10 @@ func (c *Config) setDefaults() {
if c.Metrics.ListenAddr == "" {
c.Metrics.ListenAddr = "127.0.0.1:8081"
}
+ if c.Shadowsocks.Enabled == nil {
+ t := true
+ c.Shadowsocks.Enabled = &t
+ }
if c.Shadowsocks.DefaultCipher == "" {
c.Shadowsocks.DefaultCipher = "chacha20-ietf-poly1305"
}
@@ 159,13 178,40 @@ func (c *Config) setDefaults() {
if c.AmneziaWG.MTU == 0 {
c.AmneziaWG.MTU = 1420
}
- if c.AmneziaWG.CertCache == "" {
- c.AmneziaWG.CertCache = "/var/lib/shroud/certs"
- }
- if c.AmneziaWG.ACMEHTTPPort == 0 {
- c.AmneziaWG.ACMEHTTPPort = 80
- }
}
+ // AmneziaWG domain defaults to server hostname.
+ if c.AmneziaWG.Enabled && c.AmneziaWG.Domain == "" {
+ c.AmneziaWG.Domain = c.Server.Hostname
+ }
+ // ACME defaults.
+ if c.ACME.CertCache == "" {
+ c.ACME.CertCache = "/var/lib/shroud/certs"
+ }
+ if c.ACME.HTTPPort == 0 {
+ c.ACME.HTTPPort = 80
+ }
+}
+
+// AWGMuxEnabled returns whether the AWG+HTTP/3 muxer is enabled.
+// If MuxEnabled is explicitly set, that value is used.
+// Otherwise, muxer is enabled when a domain is configured.
+func (c *AmneziaWGConfig) AWGMuxEnabled() bool {
+ if c.MuxEnabled != nil {
+ return *c.MuxEnabled
+ }
+ return c.Domain != ""
+}
+
+// AWGHostname returns the hostname for AWG client endpoints.
+// Uses awg.hostname if set; falls back to serverHostname when muxer is enabled.
+func (c *AmneziaWGConfig) AWGHostname(serverHostname string) string {
+ if c.Hostname != "" {
+ return c.Hostname
+ }
+ if c.AWGMuxEnabled() {
+ return serverHostname
+ }
+ return ""
}
// StateFile returns the absolute path to the state file.
M internal/metrics/collector.go => internal/metrics/collector.go +4 -4
@@ 6,10 6,10 @@ import (
"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"
+ "golang.getoutline.org/tunnel-server/ipinfo"
+ outline_prometheus "golang.getoutline.org/tunnel-server/prometheus"
+ "golang.getoutline.org/tunnel-server/service"
+ svcmetrics "golang.getoutline.org/tunnel-server/service/metrics"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
M internal/ssserver/server.go => internal/ssserver/server.go +5 -5
@@ 9,11 9,11 @@ import (
"sync"
"time"
- "github.com/Jigsaw-Code/outline-sdk/transport"
- "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks"
+ "golang.getoutline.org/sdk/transport"
+ "golang.getoutline.org/sdk/transport/shadowsocks"
- onet "github.com/Jigsaw-Code/outline-ss-server/net"
- "github.com/Jigsaw-Code/outline-ss-server/service"
+ onet "golang.getoutline.org/tunnel-server/net"
+ "golang.getoutline.org/tunnel-server/service"
"sourcecraft.dev/bigbes/shroud/internal/metrics"
"sourcecraft.dev/bigbes/shroud/internal/store"
@@ 98,7 98,7 @@ func (s *Server) SyncKeys(keys []store.AccessKey) error {
service.WithMetrics(s.serviceMetrics),
service.WithReplayCache(&s.replayCache),
service.WithStreamDialer(service.MakeValidatingTCPStreamDialer(onet.RequirePublicIP, 0)),
- service.WithPacketListener(service.MakeTargetUDPListener(s.natTimeout, 0)),
+ service.WithPacketListener(service.MakeTargetUDPListener(onet.RequirePublicIP, s.natTimeout, 0)),
service.WithLogger(s.logger),
)