~bigbes/shroud

f351e0e2219a80b39e0a2f018ccdeb4c059ad133 — Eugene Blikh 2 months ago ca7efb0
refactor: rename project to shroud

Rename project from outline-distro to shroud for better branding.

- Update module path in go.mod from outline-distro to shroud.
- Rename binary, service file, and all installation paths.
- Update all import paths across internal packages.
- Add comprehensive README.md with usage and architecture docs.
- Update install script and systemd unit file for new naming.
- Update config example with new cert cache path.
M .gitignore => .gitignore +2 -2
@@ 1,5 1,5 @@
# Binary (root only, not cmd/outline-distro/)
/outline-distro
# Binary (root only, not cmd/shroud/)
/shroud

# State/runtime
state.yaml

A README.md => README.md +157 -0
@@ 0,0 1,157 @@
# 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.

Optionally includes [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) support with HTTP/3 QUIC cover traffic on port 443.

## Features

- **Single binary** — no Node.js, no child processes, no sidecar Prometheus
- **Shadowbox-compatible REST API** — drop-in replacement for existing Outline clients
- **Hot-reload** — adding/removing keys swaps cipher lists and listeners without downtime
- **AmneziaWG** — obfuscated WireGuard VPN with automatic TLS certificates and HTTP/3 cover server
- **Built-in metrics** — Prometheus endpoint with optional node_exporter collectors (Linux)
- **CLI management** — manage keys and server config without a running server
- **Atomic state persistence** — single YAML file with safe write-via-rename

## Quick Start

### Build from source

```bash
go build ./cmd/shroud/
```

### Run

```bash
# Start the server
./shroud -c config.example.yaml

# With verbose logging
./shroud -c config.yaml -v
```

### Install on Ubuntu/Debian

```bash
curl -fsSL https://sourcecraft.dev/bigbes/shroud/raw/branch/main/install.sh | sudo bash
```

The installer builds from source, creates a systemd service, configures firewall rules, and prints the management API URL on completion.

## CLI Usage

```bash
# Server management (no running server required)
./shroud server info -c config.yaml
./shroud server set-port 50000 -c config.yaml
./shroud server set-hostname vpn.example.com -c config.yaml
./shroud server set-name "My VPN" -c config.yaml

# Access key management (no running server required)
./shroud key list -c config.yaml
./shroud key add -n "user1" -c config.yaml
./shroud key add -n "user2" -p 51234 --cipher chacha20-ietf-poly1305 -c config.yaml
./shroud key remove 1 -c config.yaml
./shroud key rename 1 "new-name" -c config.yaml

# Shell completions
./shroud completion bash
./shroud completion zsh
```

## Configuration

See [`config.example.yaml`](config.example.yaml) for the full configuration reference.

```yaml
server:
  name: "My Outline Server"
  hostname: "example.com"

api:
  listen_addr: ":8081"

metrics:
  listen_addr: "127.0.0.1:8081"
  node_exporter_collectors:
    - cpu
    - meminfo
    - loadavg
    - filesystem
    - diskstats
    - netdev

shadowsocks:
  default_port: 0                      # 0 = pick random available port
  default_cipher: chacha20-ietf-poly1305
  replay_history: 10000

amneziawg:
  enabled: false
  listen_port: 443
  address: "10.14.0.0/24"
  domain: "vpn.example.com"            # For automatic Let's Encrypt certs

state_file: state.yaml
```

## REST API

All management endpoints live under `/<secret>/` prefix. The secret is auto-generated on first run and printed to the log.

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/<secret>/server` | Server info |
| `PUT` | `/<secret>/name` | Rename server |
| `GET` | `/<secret>/access-keys` | List all keys |
| `POST` | `/<secret>/access-keys` | Create key |
| `GET` | `/<secret>/access-keys/{id}` | Get key |
| `PUT` | `/<secret>/access-keys/{id}` | Upsert key |
| `DELETE` | `/<secret>/access-keys/{id}` | Delete key |
| `PUT` | `/<secret>/access-keys/{id}/name` | Rename key |
| `PUT` | `/<secret>/access-keys/{id}/data-limit` | Set data limit |
| `DELETE` | `/<secret>/access-keys/{id}/data-limit` | Remove data limit |
| `PUT` | `/<secret>/server/port-for-new-access-keys` | Set default port |
| `PUT` | `/<secret>/server/hostname-for-access-keys` | Set hostname |
| `PUT` | `/<secret>/server/access-key-data-limit` | Set default data limit |
| `DELETE` | `/<secret>/server/access-key-data-limit` | Remove default data limit |
| `GET` | `/<secret>/metrics/enabled` | Check metrics status |
| `PUT` | `/<secret>/metrics/enabled` | Toggle metrics |
| `GET` | `/<secret>/metrics/transfer` | Per-key byte transfer |
| `GET` | `/<secret>/access-keys/{id}/awg-config` | Download AmneziaWG config |

Public endpoints (no auth):

| Method | Endpoint | Server | Description |
|--------|----------|--------|-------------|
| `GET` | `/metrics` | Metrics | Prometheus scrape target |
| `GET` | `/healthz` | Metrics | Health check |

## Architecture

```
cmd/shroud/main.go     CLI entry point, cobra commands, signal handling
        |
        +-> internal/config/    YAML config loading and validation
        +-> internal/store/     YAML file persistence (atomic write via rename)
        +-> internal/ssserver/  Wraps outline-ss-server as a library
        +-> internal/metrics/   Prometheus registry + TransferTracker
        +-> internal/api/       REST API handlers (shadowbox-compatible)
        +-> internal/awgserver/ AmneziaWG server + HTTP/3 QUIC cover
```

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`.

## Building with version info

```bash
go build -ldflags='-X main.version=1.0.0' ./cmd/shroud/
```

## Testing

```bash
go test ./...
```

R cmd/outline-distro/main.go => cmd/shroud/main.go +17 -17
@@ 25,12 25,12 @@ import (
	"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"
	"sourcecraft.dev/bigbes/shroud/internal/api"
	"sourcecraft.dev/bigbes/shroud/internal/awgserver"
	"sourcecraft.dev/bigbes/shroud/internal/config"
	"sourcecraft.dev/bigbes/shroud/internal/metrics"
	"sourcecraft.dev/bigbes/shroud/internal/ssserver"
	"sourcecraft.dev/bigbes/shroud/internal/store"
)

var version = "dev"


@@ 48,8 48,8 @@ func newRootCmd() *cobra.Command {
	)

	root := &cobra.Command{
		Use:     "outline-distro",
		Short:   "Single-binary Outline Shadowsocks server with Prometheus metrics",
		Use:     "shroud",
		Short:   "Shadowsocks + AmneziaWG VPN server",
		Version: version,
		RunE: func(cmd *cobra.Command, args []string) error {
			return runServe(configFile, verbose)


@@ 77,27 77,27 @@ 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.
		Long: `Generate shell completion scripts for shroud.

To load completions:

Bash:
  $ source <(outline-distro completion bash)
  $ source <(shroud completion bash)
  # To install permanently:
  $ outline-distro completion bash > /etc/bash_completion.d/outline-distro
  $ shroud completion bash > /etc/bash_completion.d/shroud

Zsh:
  $ source <(outline-distro completion zsh)
  $ source <(shroud completion zsh)
  # To install permanently:
  $ outline-distro completion zsh > "${fpath[1]}/_outline-distro"
  $ shroud completion zsh > "${fpath[1]}/_shroud"

Fish:
  $ outline-distro completion fish | source
  $ shroud completion fish | source
  # To install permanently:
  $ outline-distro completion fish > ~/.config/fish/completions/outline-distro.fish
  $ shroud completion fish > ~/.config/fish/completions/shroud.fish

PowerShell:
  PS> outline-distro completion powershell | Out-String | Invoke-Expression
  PS> shroud completion powershell | Out-String | Invoke-Expression
`,
		DisableFlagsInUseLine: true,
		ValidArgs:             []string{"bash", "zsh", "fish", "powershell"},


@@ 689,7 689,7 @@ func runServe(configFile string, verbose bool) error {
		logger.Info("Access info written.", "file", infoFile)
	}

	logger.Info("outline-distro started.",
	logger.Info("shroud started.",
		"version", version,
		"api", cfg.API.ListenAddr,
		"metrics", cfg.Metrics.ListenAddr,

M config.example.yaml => config.example.yaml +1 -1
@@ 36,7 36,7 @@ amneziawg:
  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
  cert_cache: /var/lib/shroud/certs
  acme_http_port: 80
  # Obfuscation parameters (must match client config)
  jc: 4

R dist/outline-distro.service => dist/shroud.service +9 -9
@@ 1,29 1,29 @@
[Unit]
Description=Outline Distro — Shadowsocks + AmneziaWG VPN Server
Documentation=https://sourcecraft.dev/bigbes/outline-distro
Description=Shroud — Shadowsocks + AmneziaWG VPN Server
Documentation=https://sourcecraft.dev/bigbes/shroud
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
ExecStart=/usr/local/bin/shroud -c /etc/shroud/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
# View with: journalctl -u shroud -f
# Filter errors: journalctl -u shroud -p err
StandardOutput=journal
StandardError=journal
SyslogIdentifier=outline-distro
SyslogIdentifier=shroud

# File descriptors
LimitNOFILE=65536

# Run as dedicated user (created by install script)
User=outline-distro
Group=outline-distro
User=shroud
Group=shroud

# Capabilities — needed for:
#   CAP_NET_BIND_SERVICE  — bind to ports < 1024 (AWG on 443, ACME on 80)


@@ 50,7 50,7 @@ RemoveIPC=yes
SystemCallArchitectures=native

# Writable paths for state, certs, and TUN device
ReadWritePaths=/var/lib/outline-distro /etc/outline-distro /dev/net/tun
ReadWritePaths=/var/lib/shroud /etc/shroud /dev/net/tun

# Allow /dev/net/tun access for AWG
DeviceAllow=/dev/net/tun rw

M go.mod => go.mod +1 -1
@@ 1,4 1,4 @@
module sourcecraft.dev/bigbes/outline-distro
module sourcecraft.dev/bigbes/shroud

go 1.26.1


M install.sh => install.sh +19 -19
@@ 1,8 1,8 @@
#!/usr/bin/env bash
#
# outline-distro installer (Ubuntu)
# shroud installer (Ubuntu)
#
# Builds from source and installs outline-distro as a systemd service
# Builds from source and installs shroud as a systemd service
# with Shadowsocks + optional AmneziaWG (HTTP/3 mux for DPI resistance).
#
# Usage:


@@ 16,14 16,14 @@ 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"
INSTALL_DIR="/opt/shroud"
CONFIG_DIR="/etc/shroud"
DATA_DIR="/var/lib/shroud"
BINARY="/usr/local/bin/shroud"
SERVICE_NAME="shroud"
SERVICE_USER="shroud"
UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
REPO_URL="https://sourcecraft.dev/bigbes/outline-distro.git"
REPO_URL="https://sourcecraft.dev/bigbes/shroud.git"

# --- Config defaults ---



@@ 110,7 110,7 @@ fi
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"
        --comment "Shroud VPN" "$SERVICE_USER"
    ok "User $SERVICE_USER created"
fi



@@ 129,7 129,7 @@ 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/
    -o "$BINARY" ./cmd/shroud/
ok "Binary: $BINARY ($VERSION)"

# --- Directories ---


@@ 217,14 217,14 @@ fi
info "Installing systemd unit..."

# Use the bundled unit file if present, otherwise generate one.
BUNDLED_UNIT="$INSTALL_DIR/dist/outline-distro.service"
BUNDLED_UNIT="$INSTALL_DIR/dist/shroud.service"
if [[ -f "$BUNDLED_UNIT" ]]; then
    cp "$BUNDLED_UNIT" "$UNIT_FILE"
else
    cat > "$UNIT_FILE" <<UNIT
[Unit]
Description=Outline Distro — Shadowsocks + AmneziaWG VPN Server
Documentation=https://sourcecraft.dev/bigbes/outline-distro
Description=Shroud — Shadowsocks + AmneziaWG VPN Server
Documentation=https://sourcecraft.dev/bigbes/shroud
After=network-online.target nss-lookup.target
Wants=network-online.target



@@ 273,10 273,10 @@ ok "Systemd unit installed and enabled"

if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "active"; then
    info "Configuring ufw firewall rules..."
    ufw allow "$API_PORT"/tcp comment "outline-distro API" >/dev/null 2>&1 || true
    ufw allow "$API_PORT"/tcp comment "shroud 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
        ufw allow "$AWG_PORT"/udp comment "shroud AWG+HTTP3" >/dev/null 2>&1 || true
        ufw allow 80/tcp comment "shroud ACME" >/dev/null 2>&1 || true
    fi
    ok "Firewall rules added"
else


@@ 291,7 291,7 @@ 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'
    cat > /etc/sysctl.d/99-shroud.conf <<'SYSCTL'
# Enable IPv4 forwarding for AmneziaWG VPN
net.ipv4.ip_forward=1
SYSCTL


@@ 330,7 330,7 @@ API_SECRET_DISPLAY=$(grep -oP 'secret:\s*"\K[^"]+' "$CONFIG_FILE" 2>/dev/null ||
cat <<EOF

=============================================
  outline-distro installed successfully
  shroud installed successfully
=============================================

  Config:    ${CONFIG_FILE}

M internal/api/handlers.go => internal/api/handlers.go +2 -2
@@ 5,8 5,8 @@ import (
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/outline-distro/internal/awgserver"
	"sourcecraft.dev/bigbes/outline-distro/internal/store"
	"sourcecraft.dev/bigbes/shroud/internal/awgserver"
	"sourcecraft.dev/bigbes/shroud/internal/store"
)

func (h *Handler) getServer(w http.ResponseWriter, r *http.Request) {

M internal/api/router.go => internal/api/router.go +5 -5
@@ 7,11 7,11 @@ import (
	"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"
	"sourcecraft.dev/bigbes/shroud/internal/awgserver"
	"sourcecraft.dev/bigbes/shroud/internal/config"
	"sourcecraft.dev/bigbes/shroud/internal/metrics"
	"sourcecraft.dev/bigbes/shroud/internal/ssserver"
	"sourcecraft.dev/bigbes/shroud/internal/store"
)

type Handler struct {

M internal/awgserver/server.go => internal/awgserver/server.go +1 -1
@@ 11,7 11,7 @@ import (
	"github.com/amnezia-vpn/amneziawg-go/tun"
	"github.com/quic-go/quic-go/http3"

	"sourcecraft.dev/bigbes/outline-distro/internal/store"
	"sourcecraft.dev/bigbes/shroud/internal/store"
)

// Config holds all settings for the AmneziaWG server.

M internal/config/config.go => internal/config/config.go +1 -1
@@ 160,7 160,7 @@ func (c *Config) setDefaults() {
			c.AmneziaWG.MTU = 1420
		}
		if c.AmneziaWG.CertCache == "" {
			c.AmneziaWG.CertCache = "/var/lib/outline-distro/certs"
			c.AmneziaWG.CertCache = "/var/lib/shroud/certs"
		}
		if c.AmneziaWG.ACMEHTTPPort == 0 {
			c.AmneziaWG.ACMEHTTPPort = 80

M internal/metrics/collector.go => internal/metrics/collector.go +1 -1
@@ 30,7 30,7 @@ func NewServerMetrics() *ServerMetrics {
	return &ServerMetrics{
		buildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
			Name: "build_info",
			Help: "Information on the outline-distro build",
			Help: "Information on the shroud build",
		}, []string{"version"}),
		accessKeys: prometheus.NewGauge(prometheus.GaugeOpts{
			Name: "keys",

M internal/ssserver/server.go => internal/ssserver/server.go +2 -2
@@ 15,8 15,8 @@ import (
	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"
	"sourcecraft.dev/bigbes/shroud/internal/metrics"
	"sourcecraft.dev/bigbes/shroud/internal/store"
)

const tcpReadTimeout = 59 * time.Second