Cross-cutting Concerns¶
Auto-Reconnection¶
stateDiagram-v2
[*] --> Connected
Connected --> KeepaliveFail: SSH keepalive timeout<br/>(every 15s)
Connected --> TransportError: Network / Xray error
KeepaliveFail --> Cleanup: Close local listeners<br/>+ SSH connection
TransportError --> Cleanup
Cleanup --> Wait_2s: Attempt 1-8
Cleanup --> Wait_4s: Attempt 9-12
Cleanup --> Wait_8s: Attempt 13-16
Cleanup --> Wait_16s: Attempt 17-20
Cleanup --> Wait_30s: Attempt 21+
Wait_2s --> Reconnecting
Wait_4s --> Reconnecting
Wait_8s --> Reconnecting
Wait_16s --> Reconnecting
Wait_30s --> Reconnecting
Reconnecting --> Connected: Success<br/>(resets backoff)
Reconnecting --> Cleanup: Failure Both the forward tunnel (client) and reverse tunnel (server) implement exponential backoff reconnection:
- Backoff: 2s -> 4s -> 8s -> 16s -> 30s (max), staying roughly four attempts at each level; a drop after a successful connection resets the backoff so re-connection is fast
- Keepalive: SSH keepalive every 15 seconds; on failure, triggers reconnect
- TCP Keepalive: 30-second TCP keepalive on all connections
- Forward tunnel cleanup: On keepalive failure, all local listeners are closed first (unblocking Accept loops), then the SSH connection is closed, triggering the reconnect loop
Dynamic User Management¶
The SSH server re-reads authorized_keys on every authentication attempt. This means:
tw server user invitetakes effect immediately -- no need to restart the running server- Revoking a user (removing their key from
authorized_keys) takes effect on the next connection attempt - Each key entry can have independent
permitopenrestrictions (and an optionalsingle-sessionflag)
Permitopen Enforcement¶
When the SSH server authenticates a client, it parses permitopen options from the matching authorized_keys entry and stores them in gossh.Permissions.Extensions["permitopen"]. On every direct-tcpip channel request, the server checks the target host:port against the permitted list. If no permitopen options are set (e.g., the server's own key), all destinations are allowed. A custom single-session option, enforced in checkAuthorizedKey, rejects a second concurrent connection for the same user.
Relay Config Updates¶
sequenceDiagram
participant S as Server (tw)
participant TX as Temp Xray (management tunnel)
participant R as Relay (SSH)
participant XR as Relay Xray
S ->> TX: Start temporary Xray instance (presents mTLS client cert)
S ->> R: SSH through temp tunnel
S ->> R: sudo cat /usr/local/etc/xray/config.json
R -->> S: JSON config
S ->> S: Parse JSON, check duplicate UUID,<br/>add client to vless-in-#lt;server-id#gt;
S ->> R: sudo tee config.json (updated)
S ->> XR: gRPC AlterInbound / AddUserOperation (:10085)
alt gRPC fails
S ->> R: sudo systemctl restart xray
end
S ->> TX: Stop temporary Xray
Note over TX: Tunnel destroyed (expected) tw server user invite (and delete/unregister) updates the relay's Xray config remotely:
- Starts a temporary Xray instance (dokodemo-door on
server.temp_xray_port+1, default 59001, falling back to any free loopback port — separate from a runningtw server start) - SSHs into the relay through the temporary tunnel using the server's SSH key
- Reads
/usr/local/etc/xray/config.jsonviasudo cat - Parses the JSON, checks for duplicate UUID, adds the new client entry to this server's own
vless-in-<server-id>inbound - Writes the updated config via
sudo tee /usr/local/etc/xray/config.json(persistence) - Hot-adds the UUID via the Xray gRPC API (
AlterInbound/AddUserOperationon loopback:10085, forwarded over the SSH connection); falls back tosystemctl restart xrayif the API call fails
Relay-side tenant changes (server enroll / un-enroll) follow the same live-update philosophy but at a bigger granularity: the admin re-renders the Caddyfile, authorized_keys, and Xray config.json wholesale from the registry, then live-adds (AddInbound + AddRule) or live-removes the affected tenant via the same gRPC API — no Xray restart. Enroll and un-enroll are serialized by a local file lock (internal/ops/oplock.go).
Transport Protocol¶
Xray VLESS + XHTTP over TLS:
- VLESS: Lightweight proxy protocol with UUID-based authentication
- XHTTP: HTTP-based transport that splits data into standard HTTP requests/responses (per-tenant path
/tw/<server-id>,stream-onemode) - TLS: Terminated by Caddy on the relay (mutual TLS — the client presents the per-server certificate); SNI matches the relay domain
- Result: Traffic is indistinguishable from normal HTTPS browsing to firewalls and DPI
Config Change Detection¶
flowchart LR
subgraph "On Startup"
A[Read config.yaml] --> B["SHA-256 hash<br/>(FileHash)"]
B --> C["Save as cfgHash"]
end
subgraph "Dashboard Poll (every 3s)"
D["/api/status"] --> E["FileHash()"]
E --> F{cfgHash ==<br/>current hash?}
F -->|Yes| G["No change"]
F -->|No| H["Show banner:<br/>'Config changed.<br/>Restart to apply.'"]
end
subgraph "On Restart"
I[User clicks Restart] --> J[Reload config from disk]
J --> K[Apply new log level]
K --> L["Save new cfgHash"]
end
style C fill:#1565C0,color:#fff
style H fill:#E65100,color:#fff
style L fill:#00897B,color:#fff config.FileHash() computes a SHA-256 digest of the raw config file on disk. Unlike Config.Hash() (which marshals the parsed struct), FileHash() captures all changes including unknown fields, comments, and formatting differences.
On server or client start, the current file hash is saved as cfgHash in the respective manager struct. The ConfigChanged() method on Ops compares the current file hash against the startup hash:
func (o *Ops) ConfigChanged() bool {
currentHash := config.FileHash()
// Compare against server's cfgHash if running...
// Compare against client's cfgHash if running...
}
The dashboard polls ConfigChanged() every 3 seconds via the /api/status endpoint. When a change is detected, the UI shows a banner:
"Configuration has changed. Restart/Reconnect to apply."
Changes take effect on restart
Config changes are never applied live to a running server or client. The user must explicitly restart (server) or reconnect (client) to pick up the new configuration. On restart, the config is reloaded from disk and the new log level is applied via logging.SetLevel().
Mode Enforcement¶
The mode field in config.yaml can be "relay", "server", "client", or empty (bootstrap). The legacy value "admin" is canonicalized to "relay" on read. When set:
- CLI:
requireMode(allowed ...string)inroot.gochecks the configured mode before executing a command. Relay-only commands (e.g.,tw relay create,tw relay invite) return an error in server or client mode, and so on for each role; the variadic form lets a command be allowed in more than one mode.tw joinis the exception — it's role-neutral and skipsrequireModeentirely, since the issuer's invite (not the enrollee's current mode) decides the resulting role. - Dashboard: The
pageData.Modefield is passed to all templates. Navigation links and page content adapt -- pages belonging to other roles are hidden.
// modeError is the pure decision behind requireMode:
// nil if current is unset or present in allowed, otherwise a descriptive error.
func modeError(current string, allowed []string) error {
if current == "" {
return nil // mode not set yet, allow
}
for _, a := range allowed {
if current == a {
return nil
}
}
return fmt.Errorf("this command requires %s mode, but tw is configured in %s mode",
strings.Join(allowed, " or "), current)
}
Mode signature (tamper-evidence). The mode is additionally protected by a detached ed25519 signature (mode_auth: {sig, issuer} in config, internal/ops/modeauth) over (mode, profile identity), where the identity is the profile's own id_ed25519.pub. The relay signs its own mode with its own key; a server's mode is signed by the relay admin inside the invite grant (tw relay invite / tw join); a client's mode is signed by its server inside the invite grant (tw server user invite / tw join). requireMode verifies the signature on every gated command: a present-but-invalid signature (a hand-edited mode field) is refused with a re-enroll/re-import hint; a missing signature is legacy-tolerated with a warning (the relay self-heals by re-signing). This is deliberately not a security boundary — the real role boundary is the relay's authorized_keys restrictions and the mTLS/PKI trust chain.
User Online Tracking¶
flowchart TD
REQ["/api/users/online"] --> CACHE{Cache valid?<br/>TTL 20s}
CACHE -->|Yes| RET[Return cached result]
CACHE -->|No| QUERY["GetOnlineUsers()<br/><i>gRPC via server tunnel → relay :10085</i>"]
QUERY --> PRIMARY["QueryStats 'online'<br/><i>user>>>UUID>>>online</i>"]
PRIMARY --> HAS{Stats<br/>available?}
HAS -->|Yes| MAP[Map UUID → online]
HAS -->|No| FALLBACK["QueryStats 'user>>>'<br/><i>Reset: true, check traffic</i>"]
FALLBACK --> TRAFFIC{Non-zero<br/>uplink/downlink?}
TRAFFIC -->|Yes| ONLINE[Mark UUID online]
TRAFFIC -->|No| OFFLINE[Mark UUID offline]
ONLINE --> MAP
OFFLINE --> MAP
MAP --> SAVE["Cache result<br/>(20s TTL)"]
SAVE --> RET
style REQ fill:#1565C0,color:#fff
style RET fill:#00897B,color:#fff
style FALLBACK fill:#E65100,color:#fff The server tracks which client users are currently connected by polling the relay's Xray Stats API:
- Stats query:
GetOnlineUsers()connects to the relay's Xray gRPC API (port10085) via the server's already-running Xray tunnel (usingsshThroughServerTunnel(), which avoids creating a temporary Xray instance). - Primary method: Queries
QueryStatswith pattern"online"looking foruser>>>{UUID}>>>onlinestats entries (XraystatsUserOnlinefeature). - Fallback: If no online stats are available, falls back to traffic-based detection: queries
user>>>pattern withReset_: true, and any UUID with non-zerotraffic>>>uplinkortraffic>>>downlinksince the last poll is considered online. The server's own UUID is excluded. - Caching: Results are cached with a 20-second TTL (
onlinePolltimestamp). Concurrent refresh attempts return the stale cache instead of blocking. - Relay setup: freshly provisioned relays already carry
stats,StatsService, and the statspolicyin the rendered Xray config (relayconfig.json.tmpl).EnsureRelayStats()still runs in the background at server startup to patch legacy relays that predate the template; if patching occurs, Xray is restarted on the relay.
The dashboard's users page calls /api/users/online which returns the online map. Online users are shown with a badge in the UI.
Relay compatibility
The statsUserOnline feature requires Xray v26.2.6+; the relay's pinned Xray (v26.6.27) includes it. Older hand-managed relays fall back to traffic-based detection, which has lower granularity (a user appears online only while actively transferring data).
Dashboard Architecture¶
The dashboard is a server-rendered web application using Go templates and vanilla JavaScript. All assets (templates, CSS, JS, xterm.js vendor files) are embedded in the binary via go:embed.
Log Streaming¶
The teeHandler wraps the existing slog.Handler to duplicate every log record into a logBuffer ring buffer (capacity: 500 entries). This architecture preserves the original handler chain -- including the dynamic slog.LevelVar for runtime log level changes -- while feeding the dashboard.
flowchart LR
SLOG["slog.Default()"] --> TEE["teeHandler"]
TEE --> INNER["slog.TextHandler<br/>→ stderr"]
TEE --> BUF["logBuffer<br/><i>ring, 500 entries</i>"]
BUF --> S1["Subscriber 1<br/><i>chan cap 64</i>"]
BUF --> S2["Subscriber 2<br/><i>chan cap 64</i>"]
BUF --> SN["Subscriber N<br/><i>chan cap 64</i>"]
S1 --> SSE1["SSE /api/logs"]
S2 --> SSE2["SSE /api/logs"]
SN --> SSEN["SSE /api/logs"]
style SLOG fill:#1565C0,color:#fff
style TEE fill:#1976D2,color:#fff
style BUF fill:#7E57C2,color:#fff
style SSE1 fill:#00897B,color:#fff
style SSE2 fill:#00897B,color:#fff
style SSEN fill:#00897B,color:#fff Each SSE subscriber gets a buffered channel (capacity 64). Slow subscribers have events dropped rather than blocking the log pipeline.
Progress Events (SSE)¶
Long-running operations (relay provisioning, server start, user creation) report progress via ProgressFunc callbacks. The SSE hub (sseHub) manages sessions:
sequenceDiagram
participant B as Browser
participant API as REST API
participant HUB as SSE Hub
participant OPS as Ops Layer
B ->> API: POST /api/server/start
API ->> HUB: sseHub.create()
HUB -->> API: sessionID + ProgressFunc
API -->> B: {"session_id": "abc123"}
B ->> HUB: EventSource /api/events/abc123
OPS ->> OPS: Start operation...
OPS ->> HUB: ProgressFunc(step 1/5, "Loading config")
HUB -->> B: SSE event: {step:1, total:5, msg:"Loading config"}
OPS ->> HUB: ProgressFunc(step 2/5, "Starting SSH")
HUB -->> B: SSE event: {step:2, total:5, msg:"Starting SSH"}
OPS ->> HUB: ProgressFunc(step 5/5, "completed")
HUB -->> B: SSE event: {step:5, total:5, status:"completed"}
HUB ->> HUB: Close session - Dashboard initiates an operation via a REST API call (e.g.,
POST /api/server/start) - The handler creates an SSE session with
sseHub.create(), which returns a session ID and aProgressFunc - The session ID is returned to the browser in the JSON response
- The browser opens an
EventSourceconnection to/api/events/{sessionID} - Progress events flow:
opsmethod ->ProgressFunc->sseSession.ch-> SSE stream -> browser - Terminal events (
status: "completed"withstep == total, orstatus: "failed") close the session
WebSocket SSH Terminal¶
sequenceDiagram
participant XT as xterm.js (Browser)
participant WS as WebSocket Handler
participant SSH as SSH Session
participant R as Relay (PTY)
XT ->> WS: WebSocket upgrade
WS ->> SSH: ops.RelaySSH() via Xray tunnel
SSH ->> R: Request PTY (xterm-256color, 80x24)
R -->> SSH: PTY allocated
par stdout goroutine
R -->> SSH: terminal output
SSH -->> WS: binary message
WS -->> XT: render in xterm.js
and stdin goroutine
XT ->> WS: binary (keystrokes)
WS ->> SSH: stdin
SSH ->> R: input
end
XT ->> WS: text: {"type":"resize","cols":120,"rows":40}
WS ->> SSH: WindowChange(120, 40) The /api/relay/ssh endpoint provides a browser-based SSH terminal to the relay:
- Browser opens a WebSocket connection
- Server upgrades the connection and establishes an SSH session to the relay via the Xray tunnel (
ops.RelaySSH()) - A PTY is requested (
xterm-256color, 80x24) - Two goroutines bridge the streams:
- SSH stdout -> WebSocket: binary messages carry terminal output
- WebSocket -> SSH stdin: binary messages carry keyboard input; text messages carry JSON control frames (
{"type":"resize","cols":120,"rows":40"})
- The browser renders the terminal using xterm.js with the fit addon for automatic resizing
Build-time Version Injection¶
The application version is stored in a single package-level variable:
This variable is overridden at build time via Go's -ldflags -X mechanism. The Makefile auto-detects the version from git describe --tags --always --dirty, while the GitHub Actions release workflow injects the exact tag name (e.g. v1.2.3).
The version is consumed in two places:
- CLI: Cobra's
Versionfield on the root command, enablingtw --version - Dashboard API: The
/api/statusendpoint includes theversionfield in its JSON response
Xray Version Pinning¶
The Xray version installed on the relay is controlled by a single constant:
// internal/relay/terraform/generate.go
XrayVersion = "v26.6.27" // must stay compatible with the xray-core in go.mod (pinned mtls commit)
This constant is injected into both:
- cloud-init.yaml.tmpl: used during
tw relay createto provision new cloud relays - install-script.sh.tmpl: used for manual (bring-your-own VM) relay setup
Both templates pass --version {{ .XrayVersion }} to the official Xray install script. The relay's standalone Xray release must stay protocol-compatible with the in-process xray-core dependency in go.mod (currently a pinned upstream mtls-branch commit rather than a tagged release), preventing VLESS/XHTTP incompatibilities.
Version sync
When bumping github.com/xtls/xray-core in go.mod, check whether the XrayVersion constant in generate.go needs to move too. Existing relays will continue running the old version until reprovisioned or manually updated.
Certificate Lifecycle (mutual-TLS admission)¶
The relay admits connections via mutual TLS, backed by a per-server certificate authority. The lifecycle is handled transparently:
- Generation — on the first
tw server start(ortw joinenrolling this machine as a server, ortw relay create),internal/ops/keys.go(ensureCerts) creates the CA (ca.crt/ca.key) and issues the server's client certificate (client.crt/client.key, CN = server-id) viainternal/pki. Generation is idempotent and self-healing: an existing CA is never regenerated, but a missing client cert is re-issued from the existing CA. It is skipped in client mode. - Distribution to the relay — for the admin's own entry, the CA public certificate is base64-embedded into cloud-init / the install script and written to
/etc/caddy/ca/<server-id>.crtat provisioning; for joined servers, the admin writes their CA cert (carried in the invite offer over the SPAKE2 channel) to the same location during enrollment and reloads Caddy. The rendered Caddyfile (client_auth verify_if_givenwith one trust-pool entry per tenant, plus a certless/enrollroute per tenant) is rewritten the same way. CA signing keys never leave their servers — the relay only ever holds public certificates. - Distribution to clients — for an invited client (
tw server user invite), the client generates its own key and a CSR locally; the server signs the CSR with its CA and sends back onlyclient.crtover the encrypted invite channel —client.keynever transits at all.internal/pki(GenerateKeyAndCSR,SignClientCSR) backs this. The same per-server certificate is shared by all of that server's users;applyClientCertPathsderives the on-disk paths at runtime so a stored context works regardless of platform orTW_CONFIG_DIR. - Rotation — re-provisioning the relay (or un-enrolling and re-enrolling a server) regenerates that tenant's trust-pool entry. There is no per-user certificate and no CRL on the relay; per-user revocation is an SSH
authorized_keysoperation (see Access Control), and per-server revocation istw relay un-enroll-server.
Validity: CA 10 years, client certificate 5 years (ECDSA P-256). Full detail on Relay Authentication.
Xray-core mutual-TLS dependency
Presenting a client certificate on an outbound TLS connection is a recent Xray-core capability (older releases wired only server-side certificate selection, and the uTLS path used by XHTTP dropped the certificate fields). go.mod therefore pins github.com/xtls/xray-core to the upstream commit that introduced native mTLS — the usage: "client-cert" certificate type with the GetClientCertificate callback carried through to the uTLS path. This requires Go 1.26; the build is a plain go build with no fork or patch. When upstream publishes mTLS in a tagged release, bump the version.
Key Dependencies¶
| Dependency | Version | Purpose |
|---|---|---|
github.com/xtls/xray-core | pinned upstream mtls commit (v1.260327.1-0.20260617150841-…) | In-process VLESS + XHTTP + mTLS transport |
golang.org/x/crypto | v0.55.0 | Embedded SSH server + client tunnels |
github.com/spf13/cobra | v1.8.1 | CLI framework |
github.com/google/uuid | v1.6.0 | UUID generation for Xray clients |
github.com/gorilla/websocket | v1.5.3 | WebSocket for dashboard SSH terminal |
google.golang.org/grpc | v1.82.1 | gRPC API server + relay live-add/stats queries |
gopkg.in/yaml.v3 | v3.0.1 | Configuration file handling |