Skip to content

Commit 78258fe

Browse files
feat(connect): survive WS port conflicts + clearer firewall guidance (#61)
Address operator review of the connect one-liner install: - WS bind failure is no longer fatal: bind the listener eagerly and, on failure (e.g. :8081 already in use), log a warning and run without the sink, so a taken port can't crash-loop the container and tear down the DoubleZero tunnel (src/main.rs, src/sinks/ws.rs — bind/serve split). - Installer WS port preflight: detect an already-bound WS port and offer to pick another port, disable the sink, or continue (scripts/connect*.sh). - WS_BIND="" now works through the one-liner (forwarded even when empty), so the WS sink can be disabled without a hand-written docker run. - Firewall guidance for default-deny-incoming hosts: admit the decapsulated inner multicast on the tunnel interface (ufw allow in on doublezero1), since allowing GRE alone isn't enough. Docs + CHANGELOG updated.
1 parent 3961a9e commit 78258fe

10 files changed

Lines changed: 339 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
`FEEDS` row depends on it.
3535

3636
### Changed
37+
- Installer (`scripts/connect*.sh`) usability fixes after review:
38+
- **WebSocket port preflight**: before starting the container the installer checks whether the WS
39+
port is already bound on the host and, interactively, offers to pick another port, disable the
40+
sink, or continue (non-interactively it warns and continues — the bridge then runs without the
41+
sink, tunnel unaffected).
42+
- **`WS_BIND=""` now works through the one-liner**: `WS_BIND` is forwarded whenever it is *set*,
43+
including set-but-empty, so the WS sink can be disabled straight from the pipe (previously only
44+
non-empty values were relayed, forcing a hand-written `docker run`).
45+
- **Firewall guidance for default-deny-incoming hosts**: the ufw/firewalld hints now note that
46+
allowing GRE + UDP 44880 admits only the *outer* encapsulated packets — the decapsulated inner
47+
multicast re-traverses `INPUT` on the tunnel interface (`doublezero1`) and must be allowed too
48+
(`sudo ufw allow in on doublezero1`). Mirrored in `README.md` / `scripts/README.md`.
3749
- Public-feeder transport scaffolding extracted into a venue-generic `ingest::public_feeder`
3850
(a `PublicVenue` trait + one reconnecting run loop + shared decode helpers); Hyperliquid
3951
(`ingest::ws_feeder`) is the first implementor (#53). The four `dz_ws_feeder_*` metrics are now
@@ -49,6 +61,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4961
Applied in both `src/main.rs` and the image `ENV`.
5062

5163
### Fixed
64+
- A taken WebSocket-sink port no longer takes the whole bridge down. A bind failure on `--ws-bind`
65+
(e.g. the default `0.0.0.0:8081` colliding with a pre-existing `127.0.0.1:8081` listener) was
66+
fatal: the process exited, the container's `--restart unless-stopped` restarted it, doublezerod
67+
and the DoubleZero tunnel came down with it, and — since `doublezero connect multicast` runs only
68+
once from the installer — the tunnel never re-established (status stuck `disconnected`, the real
69+
cause buried in the restart loop). The listener is now bound eagerly (`sinks::ws::bind`, split
70+
from `serve`) and a bind failure is logged and skipped: the bridge runs without the sink while
71+
the tunnel and shred forwarding keep going (`src/main.rs`, `src/sinks/ws.rs`).
5272
- Installer pre-flight access-pass check (`scripts/connect*.sh`) hardened after review:
5373
- A confirmed miss (an identity with no pass for the host IP or `0.0.0.0`) now only hard-aborts
5474
when the public IP was **explicitly supplied** via `DZ_CLIENT_IP`; when the IP was only

CLAUDE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ One WS-server task plus **one receiver task per selected feed** share a single
4040
instrument snapshot. `main.rs` selects feeds (`--feed`, or all of `ingest::feeds::FEEDS` by
4141
default), builds the shared `Arbiter` around the broadcast `Sender`, spawns the receivers into a
4242
`JoinSet` (and the optional public WS input feeders), and exits if the WS server, any receiver, or a
43-
feeder task returns.
43+
feeder task returns. **Exception:** the WS listener is bound *eagerly* before its task is spawned,
44+
and a bind failure (e.g. the port is already taken) is logged as a warning and the process runs
45+
without the sink — never fatal, so a taken WS port can't crash-loop the container and tear down the
46+
DoubleZero tunnel.
4447

4548
Ingest has **two source transports** that converge on one shared `arbiter` before the broadcast:
4649
the always-on DZ Edge **multicast** receivers, and optional **public WebSocket** feeders (off by
@@ -163,7 +166,9 @@ Modules are grouped by role under `src/`:
163166
`depth` per symbol** (full state), then streams quotes/trades/midpoints/depth. Implements the
164167
PROTOCOL.md v1 surface: optional per-client subscribe/unsubscribe filtering (empty filter list =
165168
firehose), app ping/pong + server WS-ping heartbeat with idle-timeout reaping, and the limits
166-
(max clients/subs/inbound-rate, broadcast backpressure where a slow client drops oldest).
169+
(max clients/subs/inbound-rate, broadcast backpressure where a slow client drops oldest). The
170+
listener is bound via `ws::bind()` (separate from `ws::serve()`) so `main.rs` can treat a bind
171+
failure as non-fatal — a taken port disables the sink but leaves the tunnel running.
167172
- **`model.rs`** — wire types (`NormalizedQuote`/`NormalizedTrade`/`NormalizedMidpoint`/
168173
`NormalizedDepth`/`NormalizedInstrument`, the `FeedMessage` tagged enum) and the `now_ns()` /
169174
`now_mono_ns()` clocks. The `InstrumentSnapshot` and `DepthSnapshot` are both keyed by

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ What the script does:
6262
6363
Requirements: **Linux/amd64**, GRE connectivity (allow IP protocol 47 at the cloud provider; on
6464
AWS disable the ENI source/dest check), and a host public IP authorized onchain for the chosen
65-
environment. See [scripts/README.md](scripts/README.md) for the full requirements and caveats.
65+
environment. If the host runs a **default-deny-incoming** firewall, also admit the decapsulated
66+
inner multicast on the tunnel interface (e.g. `sudo ufw allow in on doublezero1`) — allowing GRE
67+
alone isn't enough, since the inner UDP re-traverses `INPUT` on `doublezero1` after decapsulation.
68+
See [scripts/README.md](scripts/README.md) for the full requirements and caveats.
6669

6770
## Configure (override the one-liner)
6871

@@ -100,9 +103,14 @@ struct in [`src/main.rs`](src/main.rs); per-feature config lives in the [docs](d
100103
> `warn`. Set `RUST_LOG=debug` for verbose output. The installer also caps the container log on
101104
> disk (json-file driver, ~60 MB ceiling) so it can't fill the host.
102105
103-
> **Limitation:** only **non-empty** values are forwarded, so you can't pass an *empty* override
104-
> (e.g. `WS_BIND=""` to disable the WebSocket sink) through the installer. For that, run a
105-
> hand-written `docker run` — see [Self-hosting](docs/self-hosting.md).
106+
> **Note:** only **non-empty** values are forwarded, with one exception: `WS_BIND` is forwarded
107+
> whenever it is *set* — including set-but-empty — so `WS_BIND="" curl … | bash` disables the
108+
> WebSocket sink straight from the one-liner. The installer also runs a host-side **port
109+
> preflight**: if the WS port is already taken it warns and (interactively) offers to pick another
110+
> port, disable the sink, or continue. Even if a conflict slips through, a WS bind failure is
111+
> non-fatal — the bridge logs it and keeps running (the tunnel and shred forwarding are
112+
> unaffected). A hand-written `docker run` is still an option — see
113+
> [Self-hosting](docs/self-hosting.md).
106114
107115
Examples:
108116

docs/output-sinks.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ quotes/trades/midpoints/depth, with optional per-client subscribe/unsubscribe fi
4545
heartbeat/limit enforcement.
4646

4747
> **Note:** when running via the installer one-liner, set these as env vars before the pipe (or
48-
> with `docker run -e`). The `WS_BIND=""` disable case can't go through the installer (it only
49-
> forwards non-empty values) — run a hand-written `docker run` for that. See
48+
> with `docker run -e`). `WS_BIND=""` (disable the sink) **does** go through the installer —
49+
> `WS_BIND` is forwarded whenever it is set, including set-but-empty — and the installer runs a
50+
> host-side port preflight that flags a taken WS port before starting the container. A taken port
51+
> is non-fatal regardless: the bridge logs the bind failure and runs without the sink. See
5052
> [Configure](../README.md#configure-override-the-one-liner).

scripts/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ The bridge serves:
118118
kernel tunnels.
119119
- **GRE connectivity.** On a cloud host you must also allow IP protocol 47 at the provider level
120120
(and, on AWS, disable the ENI source/dest check) — the script warns but can't fix this for you.
121+
- **Host firewall (default-deny-incoming).** Allowing GRE (protocol 47) + UDP 44880 admits the
122+
*outer* encapsulated packets, but the kernel decapsulates them and the *inner* multicast UDP
123+
re-traverses the `INPUT` chain on the tunnel interface (`doublezero1`), where a default-deny
124+
policy drops it. Also admit that interface, e.g. `sudo ufw allow in on doublezero1` (or place
125+
`doublezero1` in a firewalld trusted zone). The installer warns about this when it detects an
126+
active ufw/firewalld.
127+
- **WebSocket port.** The bridge serves the WS sink on `:8081` by default. If that port is already
128+
in use the installer's preflight flags it and (interactively) offers to pick another port,
129+
disable the sink (`WS_BIND=""`), or continue; a bind failure is non-fatal either way (the tunnel
130+
is unaffected).
121131
- **Access pass.** `doublezero connect` requires the host's public IP to be authorized onchain for
122132
the chosen environment; otherwise the tunnel won't come up. The installer now checks this **up
123133
front** (step 2): if the identity has no access pass for the host's public IP or `0.0.0.0` it

scripts/connect-devnet.sh

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -377,14 +377,80 @@ if [ "$($SUDO sysctl -n net.core.rmem_max 2>/dev/null || echo 0)" -lt "$RECV_BUF
377377
|| warn "Could not raise net.core.rmem_max to $RECV_BUF_MAX (the bridge will use the host's current ceiling)."
378378
fi
379379

380-
# best-effort firewall hints (don't auto-edit the user's firewall)
380+
# best-effort firewall hints (don't auto-edit the user's firewall). Beyond GRE + the liveness
381+
# port, a *default-deny-incoming* host also has to admit the decapsulated inner traffic: the GRE
382+
# rule lets the outer encapsulated packets in, but once the kernel decapsulates them the inner
383+
# multicast UDP re-traverses the INPUT chain on the tunnel interface (doublezero1) and is dropped
384+
# unless that interface is allowed. The interface only exists once the tunnel is up, but the rule
385+
# can be added ahead of time.
381386
if command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -qi "Status: active"; then
382-
warn "ufw is active: ensure IP protocol 47 (GRE) and UDP $LIVENESS_UDP_PORT are allowed."
387+
warn "ufw is active: allow GRE (IP protocol 47) and UDP $LIVENESS_UDP_PORT, e.g.
388+
sudo ufw allow proto gre from any to any
389+
sudo ufw allow $LIVENESS_UDP_PORT/udp
390+
If your policy is default-deny-incoming, also admit the decapsulated inner multicast on the tunnel:
391+
sudo ufw allow in on doublezero1"
383392
fi
384393
if command -v firewall-cmd >/dev/null 2>&1 && $SUDO firewall-cmd --state 2>/dev/null | grep -qi running; then
385-
warn "firewalld is running: ensure GRE (protocol 47) and UDP $LIVENESS_UDP_PORT are allowed."
394+
warn "firewalld is running: allow GRE (protocol 47) and UDP $LIVENESS_UDP_PORT. If your default zone denies incoming, also place the tunnel interface in a trusted zone once it exists:
395+
sudo firewall-cmd --zone=trusted --change-interface=doublezero1"
386396
fi
387397

398+
# ----------------------------------------------------------------------------
399+
# 4b. WebSocket sink port preflight (host-side)
400+
# ----------------------------------------------------------------------------
401+
# A taken WS port would make the bridge fail to bind that listener. The bridge now degrades
402+
# gracefully (it logs a warning and runs without the sink — the DoubleZero tunnel is unaffected),
403+
# but catch the conflict here too so the operator can pick another port or disable the sink up
404+
# front instead of silently losing the WS output. Also defines ws_disabled/ws_port, reused by the
405+
# env passthrough and the final status print.
406+
407+
# The WS sink is disabled when WS_BIND is set-but-empty (WS_BIND="").
408+
ws_disabled() { [ "${WS_BIND+set}" = set ] && [ -z "${WS_BIND}" ]; }
409+
# The WS port: WS_BIND's trailing :port when set non-empty, else the default WS_PORT.
410+
ws_port() { if [ -n "${WS_BIND:-}" ]; then printf '%s' "${WS_BIND##*:}"; else printf '%s' "$WS_PORT"; fi; }
411+
412+
# Is a TCP port already bound on this host? Prefer ss, fall back to netstat; if neither exists we
413+
# can't tell (rc 2 -> skip). A ':'/'.' immediately before the port anchors the match so e.g. 8081
414+
# doesn't match 18081.
415+
port_in_use() {
416+
local p="$1"
417+
if command -v ss >/dev/null 2>&1; then
418+
$SUDO ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$"
419+
elif command -v netstat >/dev/null 2>&1; then
420+
$SUDO netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$"
421+
else
422+
return 2
423+
fi
424+
}
425+
426+
preflight_ws_port() {
427+
ws_disabled && return 0 # sink off -> nothing to bind
428+
local p; p="$(ws_port)"
429+
case "$p" in *[!0-9]*|'') return 0;; esac # not a plain numeric port -> let the bridge validate WS_BIND
430+
port_in_use "$p"; local rc=$?
431+
if [ "$rc" -eq 2 ]; then
432+
warn "Can't check whether TCP port $p is free (no ss/netstat installed); if it's in use the WS sink won't bind (the tunnel is unaffected)."
433+
return 0
434+
fi
435+
[ "$rc" -ne 0 ] && return 0 # free
436+
# In use — name the holder when ss can show it.
437+
local who=""
438+
command -v ss >/dev/null 2>&1 && who="$($SUDO ss -Hltnp 2>/dev/null | awk -v p=":${p}\$" '$4 ~ p {print $NF; exit}')"
439+
warn "TCP port $p is already in use${who:+ ($who)}; the WS market-data sink can't bind there."
440+
if [ -r "$TTY" ] && [ "$DZ_ASSUME_YES" != 1 ]; then
441+
local choice; choice="$(ask 'WS port in use — [p]ick another port, [d]isable the WS sink, or [c]ontinue anyway' 'c')"
442+
case "$choice" in
443+
p|P) local np; np="$(ask 'New WS port' '8181')"; WS_BIND="0.0.0.0:${np}"; WS_PORT="$np"
444+
info "WS sink will use 0.0.0.0:${np}."; preflight_ws_port ;; # re-check the new choice
445+
d|D) WS_BIND=""; info "WS sink disabled (WS_BIND=\"\")." ;;
446+
*) warn "Continuing; the bridge starts without the WS sink (the tunnel is unaffected)." ;;
447+
esac
448+
else
449+
warn "Continuing non-interactively; the bridge starts without the WS sink (the tunnel is unaffected). Re-run with WS_BIND=<host>:<free-port> to serve it, or WS_BIND=\"\" to disable it explicitly."
450+
fi
451+
}
452+
preflight_ws_port
453+
388454
# ----------------------------------------------------------------------------
389455
# 5. cloud detection -> warn about provider-level firewall (script can't fix)
390456
# ----------------------------------------------------------------------------
@@ -427,11 +493,10 @@ mount_args=()
427493
[ "$KEY_SRC" = file ] && mount_args=(-v "$KEYFILE":"$KEYPAIR_DEST":"$MNT_OPT")
428494
# Relay bridge env vars to the container. The bridge reads every flag from an env var, so this is
429495
# the only wiring needed to tune the WS sink, narrow feeds, or raise log level — no per-feature
430-
# logic here. Only non-empty values are forwarded (so an empty override like WS_BIND="" to disable
431-
# the WS sink can't be passed this way — use a hand-written `docker run` for that edge case).
496+
# logic here. Only non-empty values are forwarded, with one exception (WS_BIND, below).
432497
PASSTHROUGH=(
433498
DZ_FEEDS DZ_IFACE DZ_RECV_BUF
434-
WS_BIND WS_HEARTBEAT_SECS WS_IDLE_TIMEOUT_SECS WS_MAX_CLIENTS
499+
WS_HEARTBEAT_SECS WS_IDLE_TIMEOUT_SECS WS_MAX_CLIENTS
435500
WS_MAX_SUBS WS_MAX_INBOUND_PER_MIN WS_BROADCAST_CAPACITY
436501
DZ_SHRED_DEDUP_MODE DZ_SHRED_RPC_URL DZ_SHRED_FORWARD DZ_SHRED_SOURCES
437502
DZ_SHRED_CODE_PREFIX DZ_SHRED_PORT DZ_SHRED_DEDUP_WINDOW_SLOTS
@@ -441,6 +506,10 @@ env_args=()
441506
for v in "${PASSTHROUGH[@]}"; do
442507
[ -n "${!v:-}" ] && env_args+=(-e "$v=${!v}")
443508
done
509+
# WS_BIND is the one var forwarded whenever it is *set* — including set-but-empty (WS_BIND="") —
510+
# so the WS sink can be disabled straight from the one-liner (an empty --ws-bind turns the sink
511+
# off in the bridge). The preflight above may also have set/cleared it in response to a conflict.
512+
[ "${WS_BIND+set}" = set ] && env_args+=(-e "WS_BIND=${WS_BIND}")
444513
$SUDO docker run -d --name "$DZ_NAME" \
445514
--restart unless-stopped \
446515
--stop-timeout 60 \
@@ -483,7 +552,7 @@ EXEC_TTY=""; [ -t 1 ] && EXEC_TTY="-t"
483552
# user onchain for $DZ_ENV. If this errors with an access-pass message, that
484553
# provisioning step still needs to happen. Once the tunnel is up, the bridge
485554
# self-heals onto the doublezero1 interface within ~30s and quotes begin flowing.
486-
$SUDO docker exec $EXEC_TTY "$DZ_NAME" doublezero connect multicast || warn "connect failed (often: no access pass for this IP, or provider firewall/NAT). See notes above."
555+
$SUDO docker exec $EXEC_TTY "$DZ_NAME" doublezero connect multicast || warn "connect failed (often: no access pass for this IP; provider firewall/NAT; or a default-deny host firewall dropping the decapsulated inner multicast on doublezero1). See the firewall notes above."
487556

488557
# ----------------------------------------------------------------------------
489558
# 8. status + management hints
@@ -496,8 +565,12 @@ echo
496565
spin_sleep 5 "Waiting for the tunnel to settle"
497566
$SUDO docker exec "$DZ_NAME" doublezero status || true
498567
echo
499-
info "Done. The bridge is serving normalized quotes:"
500-
echo " WebSocket : ws://${HOST_IP}:${WS_PORT} # normalized quotes (see PROTOCOL.md)"
568+
if ws_disabled; then
569+
info "Done. The WebSocket sink is disabled (WS_BIND=\"\"); the bridge ingests DZ Edge and (if configured) forwards shreds, but serves no WebSocket."
570+
else
571+
info "Done. The bridge is serving normalized quotes:"
572+
echo " WebSocket : ws://${HOST_IP}:${WS_PORT} # normalized quotes (see PROTOCOL.md)"
573+
fi
501574
echo
502575
info "Manage with:"
503576
echo " sudo docker logs -f $DZ_NAME # bridge + daemon logs"

0 commit comments

Comments
 (0)