outline-ws-rust is a production-oriented Rust proxy that accepts local SOCKS5 traffic and forwards it to Outline-compatible WebSocket transports over HTTP/1.1, HTTP/2, or HTTP/3, to direct Shadowsocks socket uplinks, to VLESS-over-WebSocket uplinks, or to raw QUIC uplinks (Shadowsocks / VLESS framed directly over QUIC streams and datagrams).
It supports:
- SOCKS5
CONNECT - SOCKS5
UDP ASSOCIATEandhev-socks5UDP-in-TCP(CMD=0x05) - multi-uplink failover and load balancing
- WebSocket-over-HTTP/1.1, RFC 8441 (
ws-over-h2), and RFC 9220 (ws-over-h3) - raw QUIC transport (per-ALPN:
vless,ss,h3) — VLESS / Shadowsocks framed directly over QUIC bidi streams and datagrams (RFC 9221), no WebSocket / no HTTP/3 - VLESS-over-WebSocket uplinks (UUID auth, shared WSS dial path, per-destination UDP session-mux)
- one-line VLESS uplink config via
vless://UUID@HOST:PORT?...#NAMEshare-link URIs (TOMLlink = "...", CLI--vless-link, control-planelinkpayload field) - direct Shadowsocks TCP/UDP socket uplinks
- Prometheus metrics, built-in multi-instance dashboard, and packaged Grafana dashboards
- existing TUN device integration for
tun2udp - stateful
tun2tcprelay with production-oriented guardrails
Русская версия: README.ru.md
At a high level, the process does five jobs:
- Accepts local SOCKS5 and optional TUN traffic.
- Selects the best available uplink using health probes, EWMA RTT scoring, sticky routing, hysteresis, penalties, and warm standby.
- Connects to an Outline WebSocket transport using the requested mode (
http1,h2, orh3) with automatic fallback, to a raw QUIC uplink (quic; pairs with the matching ALPN-keyed listener on the server, falls back to WS over H2 → H1 on dial / handshake failure), or to a direct Shadowsocks socket / VLESS-over-WebSocket uplink. - Encrypts payloads using Shadowsocks AEAD, or frames them as VLESS with UUID auth, before sending them upstream.
- Exposes Prometheus metrics for runtime, uplink, probe, TUN, and
tun2tcpbehavior.
flowchart LR
subgraph LocalHost["Local host"]
C["SOCKS5 clients"]
T["Existing TUN device"]
P["outline-ws-rust"]
M["/metrics endpoint"]
C --> P
T --> P
P --> M
end
subgraph Runtime["Proxy runtime"]
S["SOCKS5 TCP + UDP handlers"]
U["Uplink manager"]
LB["Scoring and routing
EWMA RTT + weight + penalty
sticky + hysteresis"]
WS["WS transport connectors
HTTP/1.1 / HTTP/2 / HTTP/3"]
QC["Raw QUIC connectors
ALPN: vless / ss"]
DS["Direct Shadowsocks
TCP / UDP socket"]
SS["Shadowsocks AEAD"]
VL["VLESS framing
UUID auth"]
TT["TUN engines
tun2udp + tun2tcp"]
end
P --> S
P --> TT
S --> U
TT --> U
U --> LB
LB -->|"*_ws_mode = http1/h2/h3"| WS
LB -->|"*_ws_mode = quic"| QC
LB -->|"transport = shadowsocks"| DS
WS -->|"outline"| SS
WS -->|"vless"| VL
QC -->|"transport = websocket"| SS
QC -->|"transport = vless"| VL
subgraph Upstream["Upstream uplinks"]
O1["outline-over-ws (A/B)"]
O2["raw-quic edge (vless / ss)"]
O3["direct shadowsocks edge"]
O4["vless-over-ws edge"]
end
SS --> O1
SS --> O2
DS --> O3
VL --> O4
VL --> O2
subgraph Observability["Observability"]
PR["Prometheus"]
DASH["Built-in dashboard"]
GD["Grafana dashboards"]
AL["Alert rules"]
end
M --> PR
P --> DASH
PR --> GD
PR --> AL
- No-auth SOCKS5
- Optional username/password auth (
RFC 1929) - TCP
CONNECT - UDP
ASSOCIATE hev-socks5FWD UDP/UDP-in-TCP(CMD=0x05)- pipelined SOCKS5 handshake compatibility for
hev-socks5-tunnel - SOCKS5 UDP fragmentation reassembly on inbound client traffic
- IPv4, IPv6, and domain-name targets
- declarative policy routing by destination CIDR with per-rule file-backed lists (hot-reloaded), per-rule fallback (
fallback_via/fallback_direct/fallback_drop), and adirect/dropbuilt-in targets for bypass or policy blocks - group-level tunnel bypass (
bypass_when_downon the uplink group): while a group has no healthy uplink, traffic routed to it dispatchesdirectinstead of failing, and returns to the tunnel as soon as any uplink recovers
ws://andwss://- HTTP/1.1 Upgrade
- RFC 8441 WebSocket over HTTP/2
- RFC 9220 WebSocket over HTTP/3 / QUIC
- raw QUIC (per-ALPN, no WebSocket / no HTTP/3): selected via
*_ws_mode = "quic". ALPNvlesscarries VLESS-TCP (one bidi per session) and VLESS-UDP (per-target control bidi + datagrams demuxed by 4-byte server-allocatedsession_id). ALPNsscarries Shadowsocks-TCP (one bidi per session) and Shadowsocks-UDP (1 datagram = 1 SS-AEAD packet, RFC 9221). Multiple sessions of the same ALPN to the samehost:portshare one cached QUIC connection. Auxiliary ALPNsvless-mtu/ss-mtucarry oversized UDP packets that exceed the QUIC datagram limit on a server-initiated bidi. On dial / handshake failure, raw-QUIC paths fall back to WS over H2 (then H1) and open the H3-downgrade window so subsequent dials skip QUIC until the recovery probe confirms QUIC is reachable again. - VLESS-over-XHTTP (
vless_mode = "xhttp_h1","xhttp_h2"or"xhttp_h3"): pairs with thexhttp_path_vlesslistener on outline-ss-rust. The dial URLvless_xhttp_urlselects the wire mode through its query string — bare URL or?mode=packet-upruns the GET + sequenced POSTs pair,?mode=stream-oneruns a single bidirectional POST (h2 / h3 only; the h1 carrier supports packet-up only and bails on stream-one). Useful when WebSocket upgrades are blocked on the path (Cloudflare-style CDNs, captive-portal middleboxes). - direct Shadowsocks TCP/UDP socket uplinks
- VLESS-over-WebSocket uplinks (
transport = "vless", UUID auth, shared WSS dial path withwebsocket, per-destination UDP session-mux bounded byvless_udp_max_sessions) - transport fallback:
h3 -> h2 -> http1h2 -> http1quic -> h2 -> http1on dial / handshake failure, with a per-uplink mode-downgrade window (governed byh3_downgrade_secs, also accepted asmode_downgrade_secs) so subsequent dials skip QUIC until the recovery probe confirms it againxhttp_h3 -> xhttp_h2 -> xhttp_h1on dial failure (packet-up only — stream-one stops at h2), carrying the sameX-Outline-Resumetoken across each carrier switch so a feature-enabled outline-ss-rust server re-attaches the parked VLESS upstream instead of opening a fresh one. The h1 step is the last-resort fallback for paths blocking both QUIC and h2 ALPN; it dials two keep-alive sockets per session (long-lived GET + serialised POSTs) since h1 cannot multiplex.- per-uplink fallback transports via
[[outline.uplinks.fallbacks]]— each uplink may declare additional wire shapes (differenttransport+ URL/addr) that the dial loop tries when the primary fails on this uplink. Supportsvless → ws,vless → ss,ws → vless, etc. Afterprobe.min_failuresconsecutive dial failures the active wire becomes sticky formode_downgrade_secs(one knob, two uses) and new sessions start at the fallback; auto-failback snaps back to primary on pin expiry. Resume tokens (X-Outline-Resume) ride through the wire switch via the identity-level resume cache, so handover-via-resume acrossvless ↔ wsis seamless on a feature-enabled server. The chunk-0 failover loop also tries every other wire on the same uplink before jumping to a different uplink (handover within uplink). Optional per-uplinkshuffle_wires = truereshuffles the chain at process startup (collision-free permutations within an[[uplink_group]]) and surrenders to uplink-failover after one full forward pass without any wire success (round resets on any-wire success);shuffle_timer = "1h"rerollsactive_wireon a per-uplink interval (30s/5m/1h30m/2d/ bare seconds) and suppresses probe-driven early-failback to primary so the reroll stays visible.carrier_downgrade = falsecollapses the vertical carrier cascade so failures roll over wire-to-wire directly (useful against DPI that drops the whole upstream regardless of HTTP version). See docs/UPLINK-CONFIGURATIONS.md "Per-uplink fallback transports".
- cross-transport client-side session resumption: WebSocket Upgrades carry
X-Outline-Resume-Capable: 1; the server-issuedX-Outline-SessionID is cached per uplink (and per (uplink, target) inside the VLESS UDP mux) and presented asX-Outline-Resume: <hex>on the next on-demand dial so a feature-enabled outline-ss-rust server can re-attach the parked upstream and skip the connect-to-target. Covers TCP-WS, SS-UDP-WS, VLESS-TCP raw QUIC (via Addons opcodes), VLESS-UDP raw QUIC, and VLESS-over-XHTTP (h1 / h2 / h3, packet-up and stream-one alike — the token round-trips on the same response that delivers the first downlink chunk). Opt-in on the wire and zero-overhead when the server doesn't support it.
chacha20-ietf-poly1305aes-128-gcmaes-256-gcm2022-blake3-aes-128-gcm2022-blake3-aes-256-gcm2022-blake3-chacha20-poly1305
- multiple uplinks
- fastest-first selection
- selection mode:
active_active: new flows can use different uplinks based on score, stickiness, and failoveractive_passive: keep the current selected uplink until it becomes unhealthy or enters cooldown. A manual control-plane switch or probe-driven failover that moves the active uplink off an in-flight SOCKS5 session now tears that TCP session down with RST (SO_LINGER {l_onoff=1, l_linger=0}) so the client reconnects through the new active uplink instead of egressing through the now-passive one (different source IP / ASN). UDP-side downlink loop wakes on the samesubscribe_active_uplinkswatch. Counter:outline_ws_rust_socks_tcp_strict_aborts_total.active_activeis unaffected — the watcher never arms there.
- routing scope:
per_flow: decisions are made independently per routing key / targetper_uplink: one active uplink is shared process-wide per transport (tcpandudp); inactive_passivemode the pinned TCP and UDP uplinks do not expire withsticky_ttl, established SOCKS TCP tunnels stay pinned to the uplink that completed setup while non-migratable flows that still depend on the older active uplink may be reselected or closed after a switch, and penalty history is not folded into the strict per-transport scoreglobal: one shared process-wide active uplink is used for new user traffic across bothtcpandudp; selection is intentionally biased toward TCP score, but a UDP-capable active uplink is considered failed when its UDP probe marks it unhealthy or its UDP runtime cooldown is active, the active global uplink does not expire withsticky_ttl, penalty history is not folded into the strict global score, and TUN flows that remain pinned to an older uplink after a global switch are actively closed so they reconnect through the new global uplink
- per-uplink static
weight - RTT EWMA scoring
- failure penalty model with decay
- sticky routing with TTL
- hysteresis to avoid unnecessary churn
- runtime failover
- auto-failback disabled by default (
auto_failback = false): switches only on failure, never proactively back to a recovered primary - warm-standby WebSocket pools for TCP and UDP
- active-uplink selection persisted across restarts (TOML state file, debounced async writes)
- WebSocket connectivity probes (TCP+TLS+WS handshake; no ping/pong — servers rarely respond to WebSocket ping control frames)
- real HTTP probes over
websocket-stream - real DNS probes over
websocket-packet - TLS handshake-only probes against a real product SNI through the tunnel — reproduces the user-flow chunk-0 failure pattern when upstream filtering silently drops
ServerHellorecords, which the plain HTTP probe cannot see - probe concurrency limits
- separate probe dial isolation
- immediate probe wakeup on runtime failure to accelerate detection
- consecutive-success counter for stable auto-failback gating
- existing TUN device integration only
tun2udpwith flow lifecycle management, IPv4/IPv6 IP fragment reassembly, and local ICMP echo replies- stateful
tun2tcprelay with retransmit, zero-window persist/backoff, SACK-aware receive/send behavior, adaptive RTO, and bounded buffering
- Prometheus metrics
- built-in multi-instance dashboard
- packaged Grafana dashboards
- proactive uplink TLS certificate-expiry monitoring (dashboard chip + Prometheus gauge
outline_ws_rust_uplink_cert_expiry_timestamp_seconds) - hardened systemd unit
- Linux
fwmark/SO_MARK - IPv6-capable listeners, upstreams, probes, and SOCKS5 targets
The project is intentionally practical, but there are still boundaries:
tun2tcpis production-oriented but still not a kernel-equivalent TCP stack.- Non-echo ICMP traffic on TUN is not supported.
probe.httpsupportshttp://only — for HTTPS use the dedicated[outline.probe.tls]block, which drives a TLS handshake (no HTTP exchange) against a configured SNI.probe.tcpshould target a speak-first TCP service such as SSH or SMTP, not a typical HTTP/HTTPS port.- TCP failover is safe before useful payload exchange; live established TCP tunnels cannot be migrated transparently between uplinks.
config.toml- example configurationconfig-router.toml- example router configurationsystemd/outline-ws-rust.service- hardened systemd unitgrafana/outline-ws-rust-dashboard.json- main operational dashboardgrafana/outline-ws-rust-hang-diagnostics.json- situational hang diagnosticssrc/bootstrap/- startup sequence: listener binding and persistent state storesrc/config/- configuration loading, schema, and validated typessrc/proxy/- SOCKS5 TCP/UDP ingress handlers (dispatcher, TCP failover, UDP relay)crates/outline-uplink/- uplink selection, probing, failover, and standby managementcrates/outline-transport/- WebSocket / HTTP-2 / HTTP-3 / raw-QUIC / VLESS / direct-Shadowsocks transports + the cross-transportResumeCachecrates/outline-net/- DNS cache and shared net plumbing extracted fromoutline-transportcrates/outline-ss2022/- Shadowsocks 2022 framing helperscrates/outline-tun/- stateful TUN relay engines (TCP and UDP)crates/shadowsocks-crypto/- AEAD crypto helpers for Shadowsockscrates/outline-metrics/- Prometheus metrics registration and session/transport snapshotscrates/outline-routing/- CIDR routing tablecrates/socks5-proto/- SOCKS5 protocol primitivesPATCHES.md- local vendored patch inventory
- Rust toolchain (stable):
rustup update stable - For cross-compilation:
cargo-zigbuild— wraps the Zig C compiler to eliminate the need for a dedicated cross-linker per target.
cargo install cargo-zigbuildShortcuts available in this repository:
cargo release-musl-x86_64
cargo release-musl-aarch64
cargo release-router-musl-arm
cargo release-router-musl-armv7
cargo release-router-musl-aarch64- Every push to
maintriggers theNightly Releaseworkflow. - That workflow moves the rolling tag
nightlyto the currentmaincommit and republishes theNightlyGitHub prerelease. - Nightly publishes server
releaseartifacts forx86_64-unknown-linux-muslandaarch64-unknown-linux-musl, routerrelease-routerartifacts forx86_64-unknown-linux-muslandaarch64-unknown-linux-musl, plusSHA256SUMS.txt. - Nightly server archives are named
outline-ws-rust-vnightly-<full-commit-sha>-<target>.tar.gz; router archives useoutline-ws-rust-router-vnightly-<full-commit-sha>-<target>.tar.gz. - To cut a stable release, run the manual
Releaseworkflow and passmajor_minorsuch as1.7. - CI finds the latest
v1.7.*tag, increments the patch automatically, updatesCargo.tomlandCargo.lock, creates a release commit, and pushes that commit tomain. - After the release commit lands on
main, create and push a signed tag locally; the tag push triggers theTag Releaseworkflow, which builds and publishes the GitHub Release. - The stable release includes server
releaseassets forx86_64-unknown-linux-muslandaarch64-unknown-linux-musl, and routerrelease-routerassets forx86_64-unknown-linux-muslandaarch64-unknown-linux-musl. - Router archives are named
outline-ws-rust-router-v<version>-<target>.tar.gzso they are distinct from the regular server assets. - Pushing a tag like
v1.2.3manually still triggers theTag Releaseworkflow as a separate external tag-driven path.
Install the required Rust targets:
# VMs / servers
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
# Routers (ARM, e.g. Raspberry Pi, many modern home routers)
rustup target add armv7-unknown-linux-musleabihf
# Routers (AArch64, e.g. newer Raspberry Pi, Banana Pi, routers with Cortex-A53+)
rustup target add aarch64-unknown-linux-muslCurrent stable Rust no longer ships mips-unknown-linux-musl or mipsel-unknown-linux-musl as downloadable rust-std targets, so local shortcuts only cover the targets still available on stable. Legacy MIPS builds now require a pinned older toolchain or a custom build-std flow; official stable release assets for those targets are produced in the Release CI workflow.
The binary is controlled by Cargo feature flags. Mix and match as needed:
| Feature | Default | Effect |
|---|---|---|
h3 |
✓ | H3/QUIC transport (pulls in quinn + sockudo-ws/http3) |
metrics |
✓ | Prometheus metrics endpoint; also enables transport-layer metrics (pulls in prometheus + serde_json) |
tun |
✓ | TUN device support (tun2udp + tun2tcp engines); remove to exclude all TUN code |
mimalloc |
✓ | Replace the system allocator with mimalloc; reduces RSS fragmentation under connection churn. A background thread periodically runs mi_collect to hand freed memory back to the OS after traffic bursts |
env-filter |
✓ | Dynamic RUST_LOG parsing; disable to hardcode log level at WARN and save ~300 KB on MIPS |
multi-thread |
✓ | Tokio work-stealing scheduler; disable to force current_thread and save ~100–200 KB |
router |
— | Convenience alias for --no-default-features --features router (disables all defaults above) |
Why disable for routers:
h3/QUIC adds ~1–2 MB of binary size and runtime overhead on MIPS/ARM.metricsadds prometheus + serde_json and a background sampling task. Therouterfeature removes both at once.
Native build for the current machine (fastest, uses all CPU features):
cargo build --releaseStatic x86-64 binary (runs on any Linux x86-64 without glibc dependency):
cargo zigbuild --release --target x86_64-unknown-linux-musl
# or shorter
cargo release-musl-x86_64Static AArch64 binary (ARM64 servers, AWS Graviton, Ampere):
cargo zigbuild --release --target aarch64-unknown-linux-musl
# or shorter
cargo release-musl-aarch64To disable only one feature while keeping others (e.g. strip metrics but keep H3):
cargo zigbuild --release --no-default-features --features h3 --target x86_64-unknown-linux-muslAll router builds use musl libc for a fully static binary with no runtime dependencies.
Use config-router.toml on the device — see Router Configuration.
All router builds use --no-default-features --features router which disables:
h3→ removes quinn, h3, h3-quinn, sockudo-ws/http3 (~1–2 MB smaller on MIPS)metrics→ removes prometheus, serde_json, background process sampler
Router builds use the release-router cargo profile (opt-level = "z") which prioritises binary size over throughput. The default release profile uses opt-level = 3 (maximum speed) and is the right choice for VMs.
ARM soft-float (minimal ARM routers without FPU, e.g. older D-Link DIR, Linksys WRT):
cargo zigbuild --profile release-router --no-default-features --features router --target arm-unknown-linux-musleabi
# or shorter
cargo release-router-musl-armARMv7 hard-float (Raspberry Pi 2/3 in 32-bit mode, many mid-range routers):
cargo zigbuild --profile release-router --no-default-features --features router --target armv7-unknown-linux-musleabihf
# or shorter
cargo release-router-musl-armv7AArch64 / ARM64 (Raspberry Pi 3/4/5 in 64-bit mode, Banana Pi R3/R4, NanoPi R5S, routers with MT7986/MT7988, IPQ8074):
cargo zigbuild --profile release-router --no-default-features --features router --target aarch64-unknown-linux-musl
# or shorter
cargo release-router-musl-aarch64The compiled binary is placed in target/<target>/release-router/outline-ws-rust.
Copy it to the router and make it executable:
scp target/armv7-unknown-linux-musleabihf/release-router/outline-ws-rust root@192.168.1.1:/usr/local/bin/
ssh root@192.168.1.1 chmod +x /usr/local/bin/outline-ws-rustThe
routerfeature is a convenience alias — it sets no flags itself; it just exists so--features routeris a memorable shorthand for--no-default-features.
Stable Rust no longer provides prebuilt rust-std for mips-unknown-linux-musl / mipsel-unknown-linux-musl, so these builds now need nightly plus build-std. For local builds you still need a working MIPS musl-capable C toolchain (or equivalent Zig wrapper setup); the easiest reliable path for official stable artifacts is the Release CI flow.
Local example, assuming you already have a working MIPS musl C toolchain:
rustup toolchain install nightly --component rust-src
cargo +nightly build -Z build-std=std,panic_abort --profile release-router --no-default-features --features router --target mipsel-unknown-linux-muslCI / release example:
- Run the manual
Releaseworkflow for the normal stable release, or push a tag likev1.2.3for the external tag-driven path. - The
Releaseworkflow publishes one GitHub Release for both server and router assets. - For
aarch64-unknown-linux-musl, router binaries are built withcargo-zigbuild. - For
mipsandmipsel, CI uses nightlybuild-std, Zig, and generated compiler wrapper scripts mapped to Zig's musl EABI targets instead of downloading an external toolchain archive. - The published router assets are named
outline-ws-rust-router-v<version>-<target>.tar.gz.
Use config-router.toml as a starting point for memory-constrained devices.
Key differences from the default VM config:
Compile-time (feature flags):
| Feature | VM default | Router (--no-default-features --features router) |
|---|---|---|
h3 |
✓ enabled | ✗ → H3 silently falls back to H2 |
metrics |
✓ enabled | ✗ → all metrics calls are no-ops, no /metrics endpoint |
env-filter |
✓ enabled | ✗ → log level hardcoded to WARN (saves ~300 KB, no regex) |
multi-thread |
✓ enabled | ✗ → always current_thread scheduler (saves ~100–200 KB) |
Runtime (config / CLI):
| Parameter | VM default | Router example |
|---|---|---|
RUST_LOG env |
configurable (default: info,outline_ws_rust=debug) |
hardcoded WARN (no regex) |
--worker-threads |
CPU count | N/A (always current_thread) |
--thread-stack-size-kb |
2048 KiB | N/A (multi-thread disabled) |
udp_recv_buf_bytes |
kernel default | e.g. 212992 (208 KiB) |
udp_send_buf_bytes |
kernel default | e.g. 212992 (208 KiB) |
tun.max_flows |
4096 | 128 |
tun.defrag_max_fragment_sets |
1024 | 64 |
tun.defrag_max_fragments_per_set |
64 | 16 |
tun.defrag_max_total_bytes |
16 MiB | 2 MiB |
tun.defrag_max_bytes_per_set |
128 KiB | 16 KiB |
tun.tcp.max_pending_server_bytes |
4 MiB | 64 KiB |
tun.tcp.max_buffered_client_bytes |
256 KiB | 64 KiB |
[h2] initial_stream_window_size |
1 MiB | 256 KiB |
[h2] initial_connection_window_size |
2 MiB | 512 KiB |
| Warm standby | 1 TCP + 1 UDP | disabled |
| Load balancing mode | active_active |
active_passive |
| Transport mode | h3 |
h2 (QUIC is heavy on MIPS/ARM) |
state_path |
config dir (.state.toml) |
point to writable path, e.g. /var/lib/outline-ws-rust/state.toml |
Run with the router config:
outline-ws-rust --config /etc/outline-ws-rust/config-router.toml --worker-threads 1Or via environment variables:
PROXY_CONFIG=/etc/outline-ws-rust/config-router.toml WORKER_THREADS=1 outline-ws-rustRouter builds log at
WARNlevel unconditionally —RUST_LOGis ignored. To get dynamic log levels, add--features env-filterto the build command (at the cost of ~300 KB on MIPS).
Minimal local run using config.toml:
cargo run --releaseExample one-shot CLI override:
cargo run --release -- \
--listen [::]:1080 \
--tcp-ws-url wss://example.com/SECRET/tcp \
--tcp-mode h3 \
--udp-ws-url wss://example.com/SECRET/udp \
--udp-mode h3 \
--method chacha20-ietf-poly1305 \
--password 'Secret0'Example client settings:
- SOCKS5 host:
::1or127.0.0.1 - SOCKS5 port:
1080
For listen = "[::]:1080", many systems create a dual-stack listener. If your platform does not map IPv4 to IPv6 sockets, bind an additional IPv4 listener instead.
outline-ws-rust accepts both UDP relay modes used by hev-socks5-tunnel:
socks5:
address: '127.0.0.1'
port: 1080
udp: 'udp' # standard SOCKS5 UDP ASSOCIATE
# udp: 'tcp' # hev FWD UDP / UDP-in-TCP (CMD=0x05)
# pipeline: trueudp: 'udp'uses standard SOCKS5UDP ASSOCIATE.udp: 'tcp'uses the proprietaryhev-socks5TCP-carried UDP relay (CMD=0x05), which is also supported.pipeline: trueis accepted, including when username/password auth is enabled.
By default the process reads config.toml.
Example:
[socks5]
# Optional. If omitted, the SOCKS5 listener is disabled.
listen = "[::]:1080"
# Optional local SOCKS5 auth for clients.
#
# [[socks5.users]]
# username = "alice"
# password = "secret1"
#
# [[socks5.users]]
# username = "bob"
# password = "secret2"
[metrics]
listen = "[::1]:9090"
# Control plane (mutating endpoints, e.g. /switch). Must be bound on a
# separate socket from [metrics] and is always gated by a bearer token.
# Omit the section entirely to disable mutating endpoints.
# [control]
# listen = "127.0.0.1:9091"
# token = "long-random-secret"
# # Or read the token from a sidecar file (path resolved relative to this
# # config). Use this when secrets must not live in the config itself.
# # token_file = "/etc/outline-ws/control.token"
# Built-in multi-instance dashboard. Open http://LISTEN/dashboard.
# Secrets stay in the dashboard process config and are never sent to the
# browser. Each instance must expose its own [control] listener.
# [dashboard]
# listen = "127.0.0.1:9092"
# refresh_interval_secs = 5
# # Per-instance control HTTP request timeout (default 5s).
# request_timeout_secs = 5
#
# [[dashboard.instances]]
# name = "inst-01"
# # http:// or https:// — TLS control endpoints are supported.
# control_url = "http://127.0.0.1:9091"
# token_file = "/etc/outline-ws/inst-01.control.token"
#
# [[dashboard.instances]]
# name = "inst-02"
# control_url = "https://10.0.0.12:9091"
# token = "long-random-secret"
[tun]
# Existing TUN device path. Creation, IP addresses and routes stay outside the app.
# Linux example:
# path = "/dev/net/tun"
# name = "tun0"
# macOS / BSD example:
# path = "/dev/tun0"
# mtu = 1500
# Per-table cap on concurrent UDP flows. Applies independently to tunnelled
# flows and to direct (`via = "direct"`) flows; when a table is full the
# least-recently-seen flow in it is evicted. Default 4096.
# max_flows = 4096
# idle_timeout_secs = 300
# Built-in IKE / IPsec NAT-T bypass — see "TUN Mode" section below.
# ipsec_bypass = false
# Allow ICMP PTBs that advertise a path MTU below QUIC's Initial-datagram
# minimum (1200 v4 / 1280 v6) on TUN UDP oversize drops. Default false
# protects QUIC clients from being evicted to TCP; set true for VoWiFi /
# IKE-only setups (`docs/TUN-PMTUD.md`).
# pmtud_emit_below_quic_initial = false
# [tun.tcp]
# connect_timeout_secs = 10
# handshake_timeout_secs = 15
# half_close_timeout_secs = 60
# max_pending_server_bytes = 4194304
# backlog_abort_grace_secs = 3
# backlog_hard_limit_multiplier = 2
# backlog_no_progress_abort_secs = 8
# max_buffered_client_segments = 4096
# max_buffered_client_bytes = 262144
# max_retransmits = 12
# [outline.probe] acts as a template inherited by every [[uplink_group]].
# Individual groups can override any field via [uplink_group.probe].
[outline.probe]
interval_secs = 30
timeout_secs = 10
max_concurrent = 4
max_dials = 2
min_failures = 1
[outline.probe.ws]
enabled = true
[outline.probe.http]
# Single URL:
# url = "http://example.com/"
# Or a rotation list — one URL per probe call, advancing through the list.
# Spreading probe load across multiple endpoints surfaces per-site outages
# instead of masking them behind one always-reachable target. The cursor is
# shared across all uplinks in the group, so consecutive probe calls within
# a cycle hit consecutive entries.
urls = [
"http://example.com/",
"http://www.iana.org/",
]
# `outline.probe.http` sends an HTTP `HEAD` request (not `GET`), so health
# checks do not download response bodies through the uplink. Any HTTP status
# in the `200..400` range counts as success — `301`/`302` redirects are fine.
# Optional: TLS handshake-only probe. Reproduces the user-flow chunk-0
# pattern when upstream filtering silently drops `ServerHello` records for
# specific SNIs — invisible to `[outline.probe.http]` because plain HTTP
# never exercises TLS. Mutually exclusive with `[outline.probe.http]` /
# `[outline.probe.tcp]` (priority: tls → http → tcp). Metrics emit
# `probe="tls"`. See docs/UPLINK-CONFIGURATIONS.md "TLS handshake-only
# data-path probe" for target selection guidance.
# [outline.probe.tls]
# targets = [
# "www.youtube.com:443",
# "www.instagram.com", # bare host → port 443
# ]
[outline.probe.dns]
server = "1.1.1.1"
port = 53
name = "example.com"
# Each uplink group is an isolated UplinkManager with its own probe loop,
# standby pool, sticky-routes store, active-uplink state, and LB policy.
# Note: [[uplink_group]] stays at the top level, not under [outline].
[[uplink_group]]
name = "main"
mode = "active_active"
routing_scope = "per_flow"
warm_standby_tcp = 1
warm_standby_udp = 1
sticky_ttl_secs = 300
hysteresis_ms = 50
failure_cooldown_secs = 10
tcp_chunk0_failover_timeout_secs = 10
rtt_ewma_alpha = 0.3
failure_penalty_ms = 500
failure_penalty_max_ms = 30000
failure_penalty_halflife_secs = 60
h3_downgrade_secs = 60
# auto_failback = false
# VLESS UDP session-mux bounds (only used by transport = "vless" uplinks).
# vless_udp_max_sessions = 256 # LRU-evict beyond this many targets
# vless_udp_session_idle_secs = 60 # 0 disables idle eviction
# vless_udp_janitor_interval_secs = 15
# Uplinks live under [outline]. Each [[outline.uplinks]] entry must declare
# `group = "..."` matching an [[uplink_group]].name above.
[[outline.uplinks]]
name = "primary"
group = "main"
transport = "websocket"
tcp_ws_url = "wss://example.com/SECRET/tcp"
weight = 1.0
tcp_mode = "h3"
# fwmark = 100
# ipv6_first = true
udp_ws_url = "wss://example.com/SECRET/udp"
udp_mode = "h3"
method = "chacha20-ietf-poly1305"
password = "Secret0"
[[outline.uplinks]]
name = "backup"
group = "main"
transport = "websocket"
tcp_ws_url = "wss://backup.example.com/SECRET/tcp"
weight = 0.8
tcp_mode = "h2"
udp_ws_url = "wss://backup.example.com/SECRET/udp"
udp_mode = "h2"
method = "chacha20-ietf-poly1305"
password = "Secret0"
# VLESS-over-WebSocket uplink. Shares the WSS dial path with the "websocket"
# transport; `vless_id` replaces the Shadowsocks cipher/password. The VLESS
# server exposes one WS path (`ws_path_vless`) shared by TCP and UDP, so the
# client takes a single `vless_ws_url`/`vless_mode` pair — using
# `tcp_ws_url`/`udp_ws_url` with `transport = "vless"` is rejected at parse
# time.
[[outline.uplinks]]
name = "vless-edge"
group = "main"
transport = "vless"
vless_ws_url = "wss://vless.example.com/SECRET/vless"
vless_mode = "h2"
vless_id = "11111111-2222-3333-4444-555555555555"
weight = 0.5
# Same VLESS uplink configured from a single share-link URI. The loader
# expands the URI into the matching `vless_id` / `vless_*_url` /
# `vless_mode` triple at startup; mixing `link` with the explicit fields
# is rejected. Recognises the standard Xray / V2Ray query parameters
# (`type`, `security`, `path`, `alpn`, `mode`). See
# docs/UPLINK-CONFIGURATIONS.md "VLESS share-link URIs" for the full
# parameter table.
[[outline.uplinks]]
name = "vless-share"
group = "main"
link = "vless://11111111-2222-3333-4444-555555555555@vless.example.com:443?type=ws&security=tls&path=%2FSECRET%2Fvless&alpn=h2#vless-edge"
weight = 0.5
# VLESS over raw QUIC (ALPN = "vless"). Set vless_mode = "quic" to
# bypass the WebSocket layer entirely and ride VLESS framing directly on
# QUIC bidi streams (TCP) and datagrams (UDP, prefixed with the
# server-allocated 4-byte session_id). Only host:port from the URL is
# used. On dial / handshake failure raw-QUIC paths fall back to WS over
# H2 (then H1) and open the H3-downgrade window so subsequent dials skip
# QUIC until the recovery probe confirms it again.
[[outline.uplinks]]
name = "vless-quic"
group = "main"
transport = "vless"
vless_ws_url = "https://vless.example.com:443"
vless_mode = "quic"
vless_id = "11111111-2222-3333-4444-555555555555"
weight = 1.0
# Shadowsocks over raw QUIC (ALPN = "ss"). One QUIC bidi per SS-TCP
# session; SS-UDP rides QUIC datagrams 1:1 with SS-AEAD packets. Same
# cipher / password as the WS path. transport = "websocket" + tcp_mode =
# "quic" selects this path; transport = "vless" + tcp_mode = "quic"
# selects the VLESS branch above.
[[outline.uplinks]]
name = "ss-quic"
group = "main"
transport = "websocket"
tcp_ws_url = "https://ss.example.com:443"
udp_ws_url = "https://ss.example.com:443"
tcp_mode = "quic"
udp_mode = "quic"
method = "chacha20-ietf-poly1305"
password = "Secret0"
weight = 1.0
# Optional policy routing — first-match-wins by destination CIDR.
# `via` accepts a group name or the reserved `direct` / `drop` targets.
# Omit [[route]] entirely to send everything through the first group.
[[route]]
prefixes = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"]
via = "direct"
[[route]]
default = true
via = "main"transportacceptswebsocket(default),shadowsocks, orvless. VLESS shares the WSS dial path withwebsocket(sametcp_ws_url/udp_ws_url/tcp_mode/udp_mode/ipv6_first/fwmarkfields) but authenticates with a singlevless_idinstead of a Shadowsocksmethod+password. VLESS UDP opens one WSS session per destination inside the uplink (bounded by[outline.load_balancing] vless_udp_max_sessions, LRU-evicted, with idle eviction controlled byvless_udp_session_idle_secs).link = "vless://UUID@HOST:PORT?type=...&security=...&alpn=...#NAME"configures a VLESS uplink from a single share-link URI in lieu of the explicitvless_id/vless_*_url/vless_modefields;transport = "vless"is implied. The same value is accepted via the--vless-linkCLI flag (OUTLINE_VLESS_LINK) and the/control/uplinksREST payload (link, aliasshare_link). Mixinglinkwith the explicit fields is rejected. See docs/UPLINK-CONFIGURATIONS.md for the recognised query-parameter table and constraints.- At least one ingress must be configured:
--listen/[socks5].listenand/or[tun]. If neither is present, the process exits with an error instead of silently binding127.0.0.1:1080. tcp_mode/udp_mode(transport = "ws") andvless_mode(transport = "vless") pick the per-direction transport carrier:ws_h1/ws_h2/ws_h3(WebSocket Upgrade),quic(raw QUIC framing on ALPNvless/ss), orxhttp_h1/xhttp_h2/xhttp_h3(VLESS-only XHTTP packet-up). See docs/UPLINK-CONFIGURATIONS.md for per-shape config blocks, dial-time fallback chains, and resume behaviour.tcp_addr/udp_addrare used withtransport = "shadowsocks"and accepthost:portor[ipv6]:port.ipv6_first(defaultfalse) changes resolved-address preference for that uplink from IPv4-first to IPv6-first for TCP, UDP, H1, H2, and H3 connections.methodalso accepts2022-blake3-aes-128-gcm,2022-blake3-aes-256-gcm, and2022-blake3-chacha20-poly1305; for these methodspasswordmust be a base64-encoded PSK of the exact cipher key length.[[socks5.users]]enables local SOCKS5 username/password auth for multiple users. Each entry must include bothusernameandpassword.[socks5] username+passwordis still accepted as a shorthand for a single user.- CLI/env equivalents
--socks5-username/SOCKS5_USERNAMEand--socks5-password/SOCKS5_PASSWORDalso configure a single user. - The same SOCKS5 listener accepts both standard
UDP ASSOCIATEandhev-socks5UDP-in-TCP(CMD=0x05); no extra config switch is required on the server. [outline.probe] min_failures(default1): consecutive probe failures required before an uplink is declared unhealthy. Increase to2or3to tolerate intermittent probe blips without triggering failover. The same value also sets the consecutive-success stability threshold forauto_failback.[outline.load_balancing] tcp_chunk0_failover_timeout_secs(default10): how long the proxy waits for the first upstream response bytes after the most recent client request activity before allowing TCP chunk-0 failover to another uplink. Increase this if links still switch on slow first-byte responses. (Applies to single-group configs; for multi-group setups the same field lives on each[[uplink_group]].)[outline.load_balancing] auto_failback(defaultfalse): controls whether the proxy proactively returns traffic to a recovered higher-priority uplink.false(default): the active uplink is replaced only when it fails. Once on a backup, the proxy stays there until the backup itself fails — no automatic return to primary. Recommended for production use to prevent unnecessary connection disruption.true: when the current active is healthy and a candidate with a higherweight(or equal weight and lower config index) exists, the proxy may return traffic to that candidate — but only after the candidate has accumulatedmin_failuresconsecutive successful probe cycles. Priority is determined byweight, not EWMA RTT: this prevents spurious switches under load, when the active uplink's EWMA temporarily inflates due to slow connections while an idle backup looks better by latency. Failback always moves toward higher weight (1.0 → 1.5 → 2.0): switching to a lower-weight uplink via auto_failback is not possible — that requires a probe-confirmed failover.
h3_downgrade_secs(per-group, default60, also accepted asmode_downgrade_secs): how long an uplink that experienced a failure on its advanced mode — H3 application-level error (e.g.H3_INTERNAL_ERROR) or raw-QUIC dial / handshake failure — stays in H2 fallback mode before the original mode is retried. Applies to bothtransport = "ws"andtransport = "vless". Set to0to disable automatic downgrade.state_path(optional): path to a TOML file where the active-uplink selection is persisted across restarts. Defaults to the config file path with the extension replaced by.state.toml(e.g.config.toml→config.state.toml). If the file cannot be written (e.g. config lives in a read-only/etc/directory underProtectSystem=strict), the process logs a warning at startup and continues without persistence. The bundled systemd units setSTATE_PATH=/var/lib/outline-ws-rust/state.tomlso the state lands in the writable state directory. Only the active-uplink selection is persisted (by uplink name); EWMA and penalty values are not — they are re-established within one probe cycle after restart.- Uplink groups (
[[uplink_group]]) each hold their own probe loop, standby pool, sticky-routes store, active-uplink state, and load-balancing policy — groups are fully isolated at runtime. [outline.probe]acts as a template: each group inherits it, and[uplink_group.probe]overrides individual fields per group. Probe sub-tables (ws/http/dns/tcp/tls) are replaced wholesale — if a group sets[uplink_group.probe.http], the template's[outline.probe.http]is dropped for that group. Application-level probes (http/tcp/tls) are mutually exclusive: one runs per cycle, prioritytls → http → tcp.- Uplink names must be globally unique across all groups (Prometheus labels currently use
uplink="..."without a group qualifier). - The legacy
[bypass]section has been removed. Migrate bypass prefixes to a[[route]]withvia = "direct". Loading a config that still has a[bypass]table fails with an explicit migration error. - Uplinks, the probe template, and load-balancing settings all live under
[outline]([[outline.uplinks]],[outline.probe],[outline.load_balancing]). The older flat layout with top-leveltcp_ws_url/[probe]/[[uplinks]]/[load_balancing]is still accepted for backwards compatibility and logs a deprecation warning on startup — migrate to the[outline]section. Without any[[uplinks]]entry, top-leveltcp_ws_url/password/ CLI flags (--tcp-ws-url,--password, …) synthesise a single-uplinkdefaultgroup as a shorthand. - CLI flags and environment variables can override file settings.
--metrics-listencan enable metrics even if[metrics]is not present.--control-listen/CONTROL_LISTENand--control-token/CONTROL_TOKENcan enable the control plane without[control]in the config. Both must be supplied together; either alone is rejected at startup.--tun-pathcan enable TUN even if[tun]is not present.direct_fwmark(optional, top-level):SO_MARKvalue applied to TCP and UDP sockets opened fordirect-routed connections. Use when bypass traffic must be tagged for OS-level policy routing to avoid loops (e.g. the bypass route must itself not be intercepted by the TUN interface).- SOCKS5 → upstream TCP sessions are subject to a 5-minute bidirectional idle timeout. If no bytes flow in either direction for 300 seconds, the tunnel is closed and FDs are reclaimed. Any data activity in either direction resets the timer. This prevents FD accumulation from abandoned connections, particularly under TUN interceptors that open many TCP sessions and release them without FIN.
- Half-open TCP sessions (client sent EOF, proxy is waiting for upstream FIN) are closed after 30 seconds. This prevents sockets from staying half-open indefinitely when the upstream does not acknowledge the client's disconnect.
--config/PROXY_CONFIG--listen/SOCKS5_LISTEN--socks5-username/SOCKS5_USERNAME--socks5-password/SOCKS5_PASSWORD--tcp-ws-url/OUTLINE_TCP_WS_URL--tcp-mode/OUTLINE_TCP_MODE--udp-ws-url/OUTLINE_UDP_WS_URL--udp-mode/OUTLINE_UDP_MODE--method/SHADOWSOCKS_METHOD--password/SHADOWSOCKS_PASSWORD--metrics-listen/METRICS_LISTEN--tun-path/TUN_PATH--tun-name/TUN_NAME--tun-mtu/TUN_MTU--fwmark/OUTLINE_FWMARK--state-path/STATE_PATH
Declarative routing by destination CIDR, evaluated first-match-wins with an explicit default = true rule. Each rule picks one of three targets via via = "...":
- a group name (one of the declared
[[uplink_group]]s) — the connection goes through that group's uplink manager; direct— forwarded outside any uplink (equivalent to the old[bypass]behaviour);drop— SOCKS5REP=0x02 (connection not allowed)for TCP, silent drop for UDP.
Matching is done on resolved IP addresses; domain-name targets never match a rule and fall through to the default.
# RFC 1918 / ULA / loopback — never through a tunnel.
[[route]]
prefixes = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "127.0.0.0/8", "::1/128"]
via = "direct"
# Country or GeoIP list loaded from a file and hot-reloaded on mtime change.
[[route]]
file = "/etc/outline-ws-rust/geoip-cn.list"
file_poll_secs = 60
via = "backup"
fallback_via = "main" # try "main" if "backup" has no healthy uplinks
# Multiple files merged into one rule — e.g. split IPv4 / IPv6 lists that
# come from separate upstream feeds. All listed files are watched and
# reloaded independently; inline `prefixes` may still be combined with them.
[[route]]
files = [
"/etc/outline-ws-rust/geoip-cn-v4.list",
"/etc/outline-ws-rust/geoip-cn-v6.list",
]
file_poll_secs = 60
via = "backup"
# Block a specific range.
[[route]]
prefixes = ["198.51.100.0/24"]
via = "drop"
# Explicit default — matches everything not caught above.
[[route]]
default = true
via = "main"
fallback_direct = true # or: fallback_drop = true / fallback_via = "backup"Rule fields:
prefixes/file/files: inline list and/or one or more paths to files (one CIDR per line,#comments and blank lines ignored). All sources are merged into the rule's CIDR set.fileis a convenience shorthand for a single-entryfiles; both may be combined.file_poll_secs: how often (in seconds) tostateach file and reload its CIDRs on mtime change. Default60. Applies to every path infiles.via: target for matching traffic. Required (except ondefault = truerules, where it picks the fallthrough target).fallback_via/fallback_direct/fallback_drop: mutually exclusive; consulted when the primaryviais a group that has zero healthy uplinks at dispatch time.default = true: exactly one rule must carry this; it matches everything not caught by the previous rules. Thedefaultrule must not setprefixes,file, orfiles.
Internally each rule's inline + file prefixes are merged into a CidrSet — sorted [start, end] ranges (IPv4 as u32, IPv6 as u128) with overlapping and adjacent ranges merged. Lookup uses partition_point (binary search), O(log n) per rule.
Every rule with at least one file / files entry gets a background tokio task that polls mtime of every listed path every file_poll_secs seconds. When any of them changes, the rule's CIDR set is rebuilt from its inline prefixes plus all reloaded files and swapped atomically (Arc<RwLock<CidrSet>>) — other rules and the table shape are unaffected. Parse or read errors on reload leave the previous CIDR set in place and log a warning.
direct connections are subject to a 2-minute bidirectional idle timeout. If no bytes flow in either direction for 120 seconds, both sockets are closed and FDs reclaimed. This prevents unbounded FD accumulation from clients that open TCP connections (e.g. DNS-over-HTTPS, DNS-over-TLS) and abandon them without sending FIN — leaving the server half open indefinitely. Any data activity in either direction resets the timer, so legitimate long-lived push-notification and keepalive connections are unaffected.
When the primary via resolves to a group with no currently-healthy uplinks, the rule's fallback target is tried instead (one level, no recursion). Health is checked non-side-effectingly at dispatch time via UplinkManager::has_any_healthy(transport); this is cheaper than building a candidate list and does not touch sticky-routes state. If the primary group recovers mid-session, future connections go through it normally — fallback is only consulted at dispatch.
Instead of declaring fallback_direct = true on every route, a group can opt into the bypass itself: with bypass_when_down = true on the [[uplink_group]], traffic routed to the group dispatches direct while the group has no healthy uplink — including the implicit "everything to the default group" dispatch when no [[route]] is configured. An explicit route fallback still takes precedence; the bypass then applies (one level deep) to the group the fallback lands on. Like route fallbacks, the decision is re-evaluated live per dial/datagram/flow, so traffic returns to the tunnel as soon as any uplink recovers. On hosts where TUN holds the default route, set direct_fwmark so bypassed sockets escape the TUN routing loop. See "Bypass on a fully-down group" in docs/UPLINK-CONFIGURATIONS.md for details.
The six supported uplink shapes (native SS, SS/QUIC, SS/WS/H3, VLESS/QUIC, VLESS/WS/H3, VLESS/XHTTP/H3) are documented separately, with TOML examples and full dial-time fallback chains, in docs/UPLINK-CONFIGURATIONS.md. The notes below cover only the operator-facing runtime details that aren't part of that reference.
Recommended operator stance:
- prefer
ws_h1as a conservative baseline - enable
ws_h2only when the reverse proxy and origin are known-good for RFC 8441 - enable
ws_h3only when QUIC is explicitly supported and reachable - enable
quiconly when the matching outline-ss-rust raw-QUIC listener is reachable end to end - enable
xhttp_h2/xhttp_h3when WebSocket Upgrade is blocked on the network path; the dispatcher falls through toxhttp_h1automatically when both QUIC and h2 ALPN are also blocked (the h1 step is throughput-limited but keeps the wire URL identical to xray)
Shared QUIC endpoint: H3 and raw-QUIC connections that do not use a per-uplink fwmark share a single UDP socket per address family (one for IPv4, one for IPv6). This means N warm-standby connections do not open N UDP sockets. Connections that require a specific fwmark still use their own dedicated socket because the mark must be applied before the first sendmsg.
QUIC keep-alive pings are sent every 10 seconds to prevent NAT mapping expiry and to allow the server to detect dead connections without waiting for the full idle timeout.
Mode downgrade window: the per-uplink window that gates re-attempts of an "advanced mode" (H3 / QUIC / xhttp_h3) after a failure is configured by h3_downgrade_secs (default: 60s; also accepted as mode_downgrade_secs). Set to 0 to disable. The same window is also opened by TCP probe failures on H3 / QUIC uplinks — without that, intermittent advanced-mode probe pass/fail alternation would cause a failover switch every probe cycle in active_passive + global mode. See docs/UPLINK-CONFIGURATIONS.md for the two-layer (per-host cache + per-uplink window) breakdown.
Scoring during a downgrade window (per_flow scope):
- While the downgrade timer is active, the uplink's effective latency score has
failure_penalty_maxadded on top of the normal failure penalty. This preventsactive_active + per_flowflows from switching back to the primary uplink while it is operating in H2 fallback mode: as the normal failure penalty decays, the extra downgrade penalty keeps the primary's score unfavorable until the window closes.
Warm-standby connections respect the active downgrade state: while an uplink is in H3→H2 or QUIC→H2 downgrade, new standby slots are filled using H2.
Transport handshake timeouts: every WebSocket connect path enforces an upper bound so that a silently-broken or black-holed server cannot stall new sessions for minutes while keeping the uplink nominally "healthy".
- Fresh connect (new TCP/QUIC + TLS + protocol handshake): 10 s for H1, H2, and H3. Without this bound a network black hole can hang up to ~127 s (Linux TCP SYN retransmit, H1/H2) or up to 120 s (QUIC
max_idle_timeout, H3). - Reused shared H2/H3 connection (opening a new WebSocket stream over an already-established connection): 7 s per await for H3, 10 s per await for H2. This catches the case where the shared pool handle is still nominally "open" to the client-side library but the underlying path has died — e.g. NAT rebinding, server graceful close received late, or silent packet loss.
When a timeout fires, the error is treated as an upstream runtime failure: the shared pool entry (if any) is invalidated on the next open attempt, report_runtime_failure sets a cooldown, and the probe is woken immediately. In active_passive + global mode the active uplink is replaced only after the probe confirms the primary as down on a fresh connect of its own — transient shared-pool glitches do not change the exit IP, while recovery when the primary is genuinely unreachable is bounded to roughly one probe cycle.
Shared connection reconnect serialization: when the shared H2 or H3 connection drops and N sessions simultaneously try to reconnect, only one new TCP+TLS+H2 or QUIC+TLS+H3 handshake is performed. A per-server-key tokio::sync::Mutex<()> serialises the slow path: the first waiter establishes the connection and caches it; all other waiters find the fresh entry under the lock and reuse it without starting their own handshake. This prevents thundering herd storms where N sessions each independently open a full TLS negotiation toward the same server after a shared connection drop.
SOCKS5 negotiation abort classification: when a local SOCKS5 client (TUN interceptor such as Sing-box or Clash) aborts the handshake early — closing the TCP connection after the method-negotiation greeting but before or during the CONNECT request — the resulting early eof / failed to read request header errors are classified as expected client disconnects and logged at debug level rather than warn. This is normal behaviour during reconnect storms when the TUN interceptor flushes its connection pool.
Each uplink has its own:
- TCP URL and mode
- UDP URL and mode
- cipher and password
- optional Linux
fwmark - per-uplink priority via
weight— treated as a hard ordering signal: among healthy candidates the highest weight always wins, regardless of EWMA. Useweightto mark backups you do not want the failover/sticky path to drift onto. Equal-weight uplinks are tie-broken by EWMA-derived score (and finally by config index).
Selection pipeline:
- Health probes update the latest raw RTT and EWMA RTT.
- Probe-confirmed failures add a decaying failure penalty. When probes are enabled, runtime failures (e.g. an H3 connection reset under load) do not add a penalty on their own — they only set a temporary cooldown. The penalty is added only when a probe confirms a real failure (
consecutive_failures ≥ min_failures). This prevents penalty accumulation on a healthy uplink due to transient errors under load. - Effective latency is derived from EWMA RTT plus current penalty.
- Candidates are sorted: healthy first, then by
weight(higher first), then byeffective_latency / weight, then by config index. EWMA-derived score only ranks within the same weight band — it cannot promote a lower-weight uplink above a higher-weight one. - Sticky routing and hysteresis reduce avoidable switches.
- Warm-standby pools reduce connection setup latency.
Sticky-route cap: the sticky-route table is bounded at 100,000 per-flow entries. Under traffic from large NAT pools or many distinct clients in per_flow routing scope, the table would otherwise grow unboundedly. New per-flow entries beyond the cap are silently dropped — the flow falls back to a fresh latency-ordered selection instead of a sticky one. Global and per-transport pinned entries (used in global and per_uplink scopes) are always stored regardless of this limit.
Routing scope behavior:
per_flow: different targets can choose different uplinksper_uplink: one selected uplink is shared per transport, so TCP and UDP may still use different uplinks; inactive_passivemode each transport keeps its own pinned active uplink until failover or explicit reselection, and penalties no longer bias the strict transport scoreglobal: one selected uplink is shared across all new user traffic until failover or explicit reselection. TCP score still takes priority for ranking, but UDP-capable active uplinks must also keep UDP healthy: a UDP probe failure or UDP runtime cooldown can trigger a global failover. Penalties no longer bias the strict global score.
Auto-failback behavior: controlled by load_balancing.auto_failback (default false).
false(default): the active uplink is only replaced when it fails (enters cooldown or is no longer healthy). While the active uplink is still healthy, it stays active regardless of whether a higher-priority uplink has recovered. This is the recommended setting for production because it avoids connection disruption caused by proactive primary preference.true: when the current active uplink is healthy and a probe-healthy candidate with a higherweight(or equal weight and lower config index) exists, the proxy may return traffic to that candidate — but only after the candidate has accumulatedmin_failuresconsecutive successful probe cycles. Priority is determined byweight, not EWMA: this prevents spurious switches under load, when the active uplink's EWMA is temporarily elevated. Failback only moves toward higher weight; switching to a lower-weight uplink requires a probe-confirmed failover.
Penalty-aware failover: when the current active uplink enters cooldown and the selector must pick a replacement, candidates are re-sorted as: healthy first → cooldown remaining → weight (higher first) → penalty-aware EWMA score ((EWMA + penalty) / weight) → config index. weight is the primary ordering signal so a deliberately downranked backup is not promoted by a faster probe RTT alone; the penalty-aware score still breaks ties within the same weight, preventing oscillation with three or more equal-weight uplinks (without penalties a probe-cleared primary with a better raw EWMA would be selected again immediately even though it just failed).
Runtime failover:
- UDP can switch uplinks within an active association after runtime send/read failure.
- TCP can fail over before a usable tunnel is established.
- Established TCP tunnels are not live-migrated.
Available probe types:
ws: verifies TCP+TLS+WebSocket handshake connectivity to the uplink. No WebSocket ping/pong frames are sent — many servers do not respond to WebSocket ping control frames. Confirms that a new connection can be established; data-path integrity is verified by HTTP/DNS probes.http: real HTTP request overwebsocket-stream— verifies the full data path.dns: real DNS exchange overwebsocket-packet— verifies the full UDP data path.
Probe execution controls:
max_concurrent: total concurrent probe tasksmax_dials: dedicated cap for probe dial attemptsmin_failures: consecutive probe failures required before the uplink is marked unhealthy (default:1). Also used as the consecutive-success threshold for auto-failback stability: whenauto_failback = true, a recovered primary must accumulatemin_failuresconsecutive probe successes before traffic can be returned to it.attempts: number of probe attempts per uplink per cycle. Each attempt that fails increments the consecutive-failure counter; a passing attempt resets it to zero and increments the consecutive-success counter.
Probe timing:
- Probes normally run on a fixed
intervaltimer. - When a runtime failure sets a fresh failure cooldown on an uplink, the probe loop is immediately woken up (via an internal
Notify) so that failover is confirmed within one probe cycle rather than waiting for the next scheduled interval. This significantly reduces end-to-end failover latency. - Probe suppression under active traffic (global + probe): in
routing_scope = globalmode with probes enabled, the probe cycle is skipped for an uplink when all three conditions are met: (1) real traffic was observed within the lastinterval, (2) the uplink is probe-healthy (tcp_healthy = true), (3) routing scope isglobal. Active traffic is stronger evidence of reachability than a probe ping. This prevents false-negative probe results under load: when the probe loop wakes immediately after an H3 runtime failure, the server may be busy and unable to accept a new QUIC connection for the probe — which would otherwise cause a spurious failover. For non-global scopes the probe still runs even when traffic is active, to confirm recovery after cooldown.
Warm-standby validation:
- Every 15 seconds, standby connections are validated using a 1 ms non-blocking read. If the server closed the connection (EOF, close frame, or error), the slot is cleared and refilled. A timeout (no data in 1 ms) means the connection is still open.
Probe activation rules:
- probes do not start unless probe settings are explicitly configured
[probe]alone does not enable any check- at least one of
[probe.ws],[probe.http],[probe.dns],[probe.tcp], or[probe.tls]must be present
Uplinks without a udp_ws_url are treated as TCP-only: UDP health state and standby slots are not created or tracked for them, and UDP-related probe outcomes do not affect their UDP health metric.
Supported:
- SOCKS5 IPv6 targets
- IPv6 literal upstream URLs such as
wss://[2001:db8::10]/SECRET/tcp - IPv6 probes
- IPv6 listeners
- IPv6 UDP packets in TUN mode
- IPv6 upstream transport for
h2andh3
The process attaches only to an already existing TUN device. Interface creation, addresses, routing, and policy routing stay outside the app.
Capabilities:
- IPv4 and IPv6 UDP packet forwarding
- IPv4 and IPv6 IP fragment reassembly on the TUN ingress path
- local IPv4 ICMP echo reply (
ping) handling - local IPv6 ICMPv6 echo reply handling, with source fragmentation to the IPv6 minimum MTU when needed
- optional group-health gating of those echo replies (
tun_suppress_icmp_reply_when_downon the uplink group): pings routed to a group stop being answered while the group has no healthy uplink, so an external watchdog can detect a dead tunnel - IPv6 UDP and ICMPv6 handling across supported extension-header paths
- per-flow uplink transport
- flow idle cleanup
- bounded flow count
- oldest-flow eviction on overflow
- flow metrics and packet outcome metrics, including local ICMP replies
Capabilities:
- stateful userspace TCP relay over Outline TCP uplinks
- SYN / SYN-ACK / FIN / RST handling
- out-of-order buffering
- receive-window enforcement
- SACK-aware receive/send logic
- adaptive RTO
- zero-window persist/backoff
- bounded buffering and retransmit budgets
- flow termination on timeout, overflow, or relay failure
- transport-error reporting to the uplink penalty system: abrupt upstream closes (e.g. QUIC
APPLICATION_CLOSE/H3_INTERNAL_ERROR) are forwarded toreport_runtime_failure, so the H3→H2 downgrade and failure penalty apply to TUN TCP flows the same way they apply to SOCKS5 flows; clean WebSocket closes (FIN or Close frame) are not counted as failures
This is intended for real operations, but it is still not equivalent to a kernel TCP stack.
tun.ipsec_bypass = true adds a hard-coded fast-path: UDP flows whose destination port is 500 or 4500 skip policy routing and resolve to the direct path (same as via = "direct"). VoWiFi and other IKEv2/IPsec clients can then establish ESP-in-UDP datagrams that would otherwise be lost — the TUN classifier only forwards TCP/UDP/ICMP, so raw ESP (IP protocol 50) is always dropped regardless of the routing decision.
The bypass relies on the direct path's local socket to reach the destination. On hosts where TUN catches the default route, that socket would loop straight back into TUN; on Linux set direct_fwmark and add a matching ip rule fwmark X lookup Y so the bypassed flow escapes the loop. Without direct_fwmark and a corresponding policy-routing rule, the process logs a startup warning.
Default: false. Both ports must be matched together — IKEv2 stacks move IKE_AUTH off port 500 mid-session via NAT_DETECTION, so bypassing only 4500 still breaks the handshake.
When the upstream transport refuses an oversize UDP datagram on the TUN path (raw QUIC SS-UDP, VLESS-UDP, SS-2022, …), the engine synthesises an ICMP "Fragmentation Needed" (IPv4) or "Packet Too Big" (IPv6) toward the original sender so its PMTUD state machine can react. The boolean knob tun.pmtud_emit_below_quic_initial controls a single question: may that PTB advertise a path MTU below QUIC v1's Initial-datagram minimum (1200 v4 / 1280 v6, RFC 9000 §14.1)?
Default false — sub-minimum PTBs are suppressed. Compliant QUIC stacks treat such a PTB as "destination cannot carry QUIC" and fall back to TCP, so leaving the gate in place keeps real QUIC traffic (YouTube, Google services) on UDP even when the TUN uplink's per-datagram budget sits just below 1200 bytes. Sub-minimum oversize drops are silently absorbed and the sender's own retransmit / timeout logic eventually adjusts.
Set tun.pmtud_emit_below_quic_initial = true to restore unconditional PTB emission. Use it on deployments where QUIC eviction is a non-issue and the explicit PMTUD signal on every sub-minimum drop is worth more — for example a pure VoWiFi / IKEv2 concentrator carrying IKE_AUTH with certificates over a narrow raw-QUIC uplink, where the PTB is the only way for the IKE retransmit loop to learn the effective tunnel MTU. The full contract — when the PTB fires, what is throttled, where the minimum comes from, what changes on opt-in — lives in docs/TUN-PMTUD.md.
Per-uplink fwmark applies SO_MARK to outbound sockets:
- HTTP/1.1 WebSocket TCP sockets
- HTTP/2 WebSocket TCP sockets
- HTTP/3 QUIC UDP sockets
- probe dials
- warm-standby connections
Requirements:
- Linux only
CAP_NET_ADMIN
If [metrics] is configured the process serves the read-only Prometheus
endpoint:
/metrics- Prometheus text exposition
curl http://[::1]:9090/metricsThe metrics listener has no mutating endpoints. The earlier /switch
handler has been moved to a separate, authenticated control-plane listener
(see below) so observability access does not also grant authority to flip the
active uplink.
If [control] is configured the process serves mutating endpoints on a
separate TCP listener, gated by a mandatory bearer token:
GET /control/topology- instance/group/uplink topology for dashboardsGET /control/summary- compact group/uplink health countersPOST /control/activate- JSON activation API for UI click actionsGET/POST/PATCH/DELETE /control/uplinks- stage[[outline.uplinks]]edits in the config filePOST /control/apply- hot-apply staged uplink edits without a process restartPOST /switch- manual active-uplink override
There is no anonymous access path. Requests without a matching
Authorization: Bearer <token> header are rejected with 401 Unauthorized
before the request body is inspected.
Either configure both listen and a token in [control], or pass
--control-listen (CONTROL_LISTEN) together with --control-token
(CONTROL_TOKEN). The token may also be read from a sidecar file via
token_file = "..." (path resolved relative to the config file). Setting
only one of the two halves is a startup error.
Bind the control listener to loopback or a management VLAN; the token is defence in depth, not a substitute for network-level isolation.
POST /switch lets an operator pin the active uplink for an active_passive
group without waiting for the probe loop. The selection is persisted via the
state store (when configured) so it survives restarts.
Query parameters:
uplink(required) - uplink name to activate.group(optional) - target group. When omitted, the registry searches all groups (uplink names are globally unique).transport(optional) -tcp,udp, orboth(default). Honoured only inrouting_scope = per_uplink; ignored underglobalscope.
Examples:
TOKEN="long-random-secret"
# Switch the only group to uplink "backup" (both transports if per_uplink)
curl -XPOST -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:9091/switch?uplink=backup'
# Switch only the UDP active uplink in per_uplink mode
curl -XPOST -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:9091/switch?uplink=backup&transport=udp'
# Disambiguate by group name
curl -XPOST -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:9091/switch?group=main&uplink=backup'Returns 200 on success, 400 when the uplink/group is unknown or the group
is not in active_passive mode, 401 when the bearer token is missing or
incorrect, and 405 for non-POST methods. The override holds while the
chosen uplink is healthy; if the probe loop later marks it unhealthy, normal
failover takes over. With auto_failback = true, the loop may flip back to a
higher-priority uplink once it stabilises.
GET /control/topology returns JSON with groups and uplinks (including
active_global, active_tcp, active_udp booleans per uplink) for the
built-in dashboard or external control clients.
GET /control/summary returns compact counters:
groups_total, uplinks_total, healthy/unhealthy TCP/UDP counts, and active
selection counters.
POST /control/activate accepts JSON and reuses the same internal switching
logic as /switch:
{
"group": "core",
"uplink": "uplink-02",
"transport": "tcp"
}/control/uplinks mutates the canonical [[outline.uplinks]] array in the
on-disk TOML. Mutation responses include apply_required: true when
/control/apply can activate the staged change; restart_required is reserved
for control states that cannot hot-apply.
Examples:
TOKEN="long-random-secret"
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:9091/control/topology'
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:9091/control/summary'
curl -XPOST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"group":"core","uplink":"uplink-02","transport":"tcp"}' \
'http://127.0.0.1:9091/control/activate'Prometheus example:
scrape_configs:
- job_name: outline-ws-rust
metrics_path: /metrics
static_configs:
- targets:
- "[::1]:9090"Metrics include:
- build and startup info
- process resident memory and heap usage gauges
- SOCKS5 requests and active sessions, including
command="connect",command="udp_associate", andcommand="udp_in_tcp" - session duration histogram
- payload bytes and UDP datagrams
- oversized UDP drop counters for incoming client packets and outgoing client responses
- uplink health, latency, EWMA RTT, penalties, score, cooldown, standby readiness.
uplink_healthis exported as1(healthy) or0(unhealthy) only when the probe has run and confirmed a state. Before the first probe cycle the metric is absent — an empty value means "unknown", not unhealthy. - routing policy and active-uplink selection state
- probe results and latency
- warm-standby acquire and refill outcomes
- TUN flow and packet metrics
tun2tcpretransmit, backlog, window, RTT, and RTO metrics
On Linux, the process memory sampler updates:
outline_ws_rust_process_resident_memory_bytesoutline_ws_rust_process_virtual_memory_bytesoutline_ws_rust_process_heap_allocated_bytesoutline_ws_rust_process_heap_mode_info{mode}outline_ws_rust_process_open_fdsoutline_ws_rust_process_threads
Heap metrics currently fall back to VmData-based estimation on Linux and export heap_mode_info{mode="estimated"}.
On Linux, the process also emits a periodic descriptor inventory log:
process fd snapshot
The descriptor snapshot includes total open FDs plus a breakdown for sockets, pipes, anon inodes, regular files, and other descriptor types.
outline_ws_rust_selection_mode_info{mode}, outline_ws_rust_routing_scope_info{scope}, outline_ws_rust_global_active_uplink_info{uplink}, and outline_ws_rust_sticky_routes_total expose selector configuration and active-uplink state.
outline_ws_rust_group_bypass_active{group, transport} reports the live bypass_when_down state: 1 while new flows of that transport are being dispatched direct (tunnel bypass) because the group has no healthy uplink, 0 while traffic tunnels normally. The series exists only for groups with bypass_when_down = true; the built-in dashboard renders the same signal as a group-header chip (grey Bypass: armed / amber Bypass: DIRECT), and the Grafana dashboard carries a matching stat + timeline in the Routing Policy section.
Per-uplink open-connection accounting (used to detect connections leaking
into a non-active uplink after a Global / PerUplink switchover) is
exported by:
outline_ws_rust_uplink_open_connections{group, transport, uplink}— gauge of currently open upstream transports attributed to each uplink. After a switchover the new active uplink ramps up while the old one drains; a series that fails to drain is the leak signal.outline_ws_rust_uplink_connection_close_total{group, transport, uplink, classification}— counter of upstream-transport closes, classified at close time asactive(the uplink was still active),inactive(the active pointer had moved elsewhere — a stranded session draining), orunknown(PerFlowscope where no active pointer exists).rate(...{classification="inactive"}[5m])is the drain rate after a switchover; sustained non-zero outside a recent switch points at a stale sticky route or warm-standby pool.
Both are wired to the dashboard row Inactive Uplink Leak (Global / Per-Uplink)
in grafana/outline-ws-rust-dashboard.json. Probe / health-check
connections are intentionally not attributed — the metrics cover only
user-traffic dials.
When TUN UDP forwarding fails before a packet can be delivered upstream, outline_ws_rust_tun_udp_forward_errors_total{reason} breaks that down into all_uplinks_failed, transport_error, connect_failed, and other.
Oversized SOCKS5 UDP packets dropped before uplink forwarding, and oversized UDP responses dropped before client delivery, are exported as outline_ws_rust_udp_oversized_dropped_total{direction="incoming|outgoing", cause} (the cause label distinguishes quic_dgram, vless_quic_dgram, vless_udp, ss_socket, socks_client, socks_relay, socks_direct, socks_in_tcp).
On the TUN UDP path, oversize drops also synthesise an ICMP "Fragmentation Needed" (IPv4) or "Packet Too Big" (IPv6) reply toward the sender so its own PMTUD state machine can react — throttled to one PTB per second per flow, and suppressed below QUIC v1's Initial-datagram floor (1200 v4 / 1280 v6) so well-behaved QUIC clients are not pushed off UDP into a TCP fallback. See docs/TUN-PMTUD.md for the full contract.
Local ICMP echo handling is exported separately via outline_ws_rust_tun_icmp_local_replies_total{ip_family}.
For direct transport = "shadowsocks" UDP uplinks, the same oversized checks still apply on the local relay boundaries:
- incoming: the relay drops the packet if
target + payloadexceeds the Shadowsocks AEAD payload limit before encrypting and sending it to the uplink - outgoing: the relay drops the packet if the decoded upstream response becomes larger than a safe SOCKS5 UDP datagram before sending it back to the client
Grafana dashboards:
The experimental uplinks/control-plane Grafana dashboard is intentionally not packaged; use the built-in /dashboard UI for multi-instance uplink activation.
For a basic production install on Linux use the bundled install.sh script. Run it as root on the target host:
curl -fsSL https://raw.githubusercontent.com/balookrd/outline-ws-rust/main/install.sh -o install.sh
chmod +x install.sh
./install.sh --help
sudo ./install.shInstall modes:
- Default: installs the latest stable release for the current architecture
CHANNEL=nightly: installs the rolling nightly prereleaseVERSION=v1.2.3: pins the install to a specific stable tag
Examples:
./install.sh --help
sudo ./install.sh
sudo ./install.sh --force
sudo CHANNEL=nightly ./install.sh
sudo VERSION=v1.2.3 ./install.shWhat the script does:
- detects the host architecture and downloads the latest GitHub release artifact
- skips the download if the installed version already matches the selected release; use
--forceorFORCE=1to override - for the nightly channel, tracks the release commit SHA in
/var/lib/outline-ws-rust/nightly-committo detect new builds - installs the binary to
/usr/local/bin/outline-ws-rust - installs unit files into
/etc/systemd/system - creates
/etc/outline-ws-rustand/var/lib/outline-ws-rust - downloads
config.tomlandinstances/example.tomlonly if they do not already exist - restarts only already-active
outline-ws-rustunits - does not automatically enable/start a fresh service
After the first install:
- Edit
/etc/outline-ws-rust/config.toml. - Enable one of the service variants:
- single instance:
sudo systemctl enable --now outline-ws-rust.service - named instance:
sudo systemctl enable --now outline-ws-rust@NAME.service
- single instance:
- Check status with
systemctl status outline-ws-rust --no-pager. - Check logs with
journalctl -u outline-ws-rust -e --no-pager.
The script is safe to re-run for upgrades: it compares the installed version against the selected release and only downloads and replaces the binary when a newer version is available. It automatically restarts any active outline-ws-rust units after upgrade. If the service was stopped, the script leaves it stopped.
Supported release architectures currently match GitHub CI artifacts: x86_64-unknown-linux-musl and aarch64-unknown-linux-musl.
Useful overrides:
CHANNEL=stable|nightly: choose the release channel; default isstableVERSION=v1.2.3: pin the install to a specific stable tagFORCE=1: reinstall even when the installed version already matchesINSTALL_PATH=/path: install the binary outside/usr/local/binCONFIG_DIR=/path: keep configuration outside/etc/outline-ws-rustSTATE_DIR=/path: use a different state directoryGITHUB_TOKEN=...: GitHub token to avoid API rate limits
VERSION and CHANNEL=nightly are mutually exclusive.
If github.com or its release CDN (objects.githubusercontent.com) is unreachable from the host, route the install through any HTTP/SOCKS5 proxy that can reach GitHub. The script does not need to be modified — curl honours the standard proxy environment variables.
Important: sudo strips http_proxy/https_proxy from the environment by default, so pass them explicitly:
sudo https_proxy=http://HOST:PORT http_proxy=http://HOST:PORT ./install.shor export and use sudo -E:
export https_proxy=http://HOST:PORT
export http_proxy=http://HOST:PORT
sudo -E ./install.shFor SOCKS5 (e.g. an ssh -D 1080 user@vps tunnel) use ALL_PROXY=socks5h://HOST:PORT. Authenticated proxies use http://USER:PASS@HOST:PORT (URL-encode special characters in the password).
Verify the proxy works before running the installer:
curl -v --proxy http://HOST:PORT --max-time 20 https://github.com -o /dev/nullProduction-oriented systemd units are included at:
systemd/outline-ws-rust.service— single instancesystemd/outline-ws-rust@.service— named-instance template (reads config frominstances/NAME.toml)
Typical installation flow:
- Install the binary to
/usr/local/bin/outline-ws-rust. - Install the configuration to
/etc/outline-ws-rust/config.toml. - Copy both unit files to
/etc/systemd/system/. - Reload and enable the service:
sudo systemctl daemon-reload && sudo systemctl enable --now outline-ws-rust
The unit includes:
- automatic restart on failure
- journald logging
- elevated
LimitNOFILE LimitSTACK=8Mto avoid oversized anonymous thread-stack reservations- a fixed
outline-wssystem user / group (provisioned byinstall.sh) so state files keep a stable owner across restarts andStateDirectory=outline-ws-rust/_defaultlands on a writable, unit-managed path CAP_NET_ADMINforfwmark; remove iffwmarkis not usedPrivateDevices=false— required for TUN mode; harmless if TUN is not used- conservative systemd hardening flags
On Linux, the bundled runtime pins Tokio worker and blocking thread stacks to 2 MiB so the process does not inherit very large per-thread virtual stack mappings from the host environment.
The service uses tracing for structured logs. The bundled systemd unit sets:
RUST_LOG=info
Use debug only during troubleshooting — connection lifecycle and transport-layer events become much more verbose.
- Protect
metrics.listen; do not expose it without additional access controls. - Protect
control.listeneven more strictly: bind it to loopback or a management network, treat the bearer token as a credential (rotate, store out of band), and never re-use the metrics port for it. The control listener is the only path that can mutate active-uplink selection. - Listener hardening against slowloris / idle-connection DoS is built in:
the SOCKS5 accept loop caps in-flight connections at 4096 and enforces a
10 s handshake timeout on
negotiate; the/metricslistener caps concurrency at 64 with a 5 s header-read timeout; the control listener caps concurrency at 16 with the same 5 s header-read timeout (the bearer check runs only after headers are received, so the timeout is what keeps unauthenticated peers from pinning sockets). These ceilings are compiled in and not config-tunable. - HTTP/3 requires public UDP reachability on the selected port.
fwmarkworks only on Linux and requiresCAP_NET_ADMINor root.- TUN mode requires
/dev/net/tunaccess on the host (PrivateDevices=false).
Useful local checks:
cargo check
cargo testManual real-upstream integration tests exist for HTTP/2 and HTTP/3:
RUN_REAL_SERVER_H2=1 \
OUTLINE_TCP_WS_URL='wss://example.com/SECRET/tcp' \
OUTLINE_UDP_WS_URL='wss://example.com/SECRET/udp' \
SHADOWSOCKS_PASSWORD='Secret0' \
cargo test --test real_server_h2 -- --nocaptureRUN_REAL_SERVER_H3=1 \
OUTLINE_TCP_WS_URL='wss://example.com/SECRET/tcp' \
OUTLINE_UDP_WS_URL='wss://example.com/SECRET/udp' \
SHADOWSOCKS_PASSWORD='Secret0' \
cargo test --test real_server_h3 -- --nocaptureIntegration tests for group isolation, fallback, and direct dispatch:
cargo test --test group_routing -- --nocaptureWarm-standby integration test:
cargo test --test standby_validation -- --nocapture- Outline
outline-ss-server hev-socks5-corehev-socks5-tunnel- Shadowsocks AEAD specification
- RFC 8441: Bootstrapping WebSockets with HTTP/2
- RFC 9220: Bootstrapping WebSockets with HTTP/3
Vendored dependency patches are tracked in:
This is the source of truth for local deviations from upstream crates, including the vendored h3 patch used for RFC 9220 support.
