~bigbes/shroud

5afb3bad1be8eb82352dde56e6836a0a8ea4ef7f — Eugene Blikh 2 months ago f351e0e
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.
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),
				)