From 0573b8245d08e9283921d4d4ccf949a9876e0663 Mon Sep 17 00:00:00 2001 From: abpestov-cpu Date: Mon, 20 Apr 2026 16:18:29 +0300 Subject: [PATCH 1/3] fix(sing-box): support VLESS xtls-rprx-vision flow for injected users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sing-box strictly validates that VLESS user flow matches inbound flow (sagernet/sing-vmess vless/service.go:68-69). When an inbound advertises "flow: xtls-rprx-vision" but the VLESSUser entry is injected without it, the handshake fails with "flow mismatch". Previously, marznode's sing-box backend never set flow on users. This meant REALITY+Vision inbounds were unusable via marznode — only the xray backend was fixed (PR #30). This patch mirrors that behavior: - In _resolve_inbounds(): detect VLESS+REALITY over raw TCP and set the inbound's flow to "xtls-rprx-vision" (mirroring xray _config.py). - In append_user(): when the resolved inbound carries a flow, pass it as kwarg to the account constructor so sing-box sees the flow on the injected VLESSUser. Also: add sing-box config pre-check in _runner.start() — fail fast with a clear error instead of a silent crash-loop on invalid configs. Overlay Dockerfile (Dockerfile.patch) provided for deploying patched files on top of dawsh/marznode:latest — upstream Dockerfile pulls jklolixxs/sing-box:latest which is no longer publicly available. Refs: marzneshin/marznode#29 (report), #30 (xray fix) Co-Authored-By: Claude Opus 4.7 --- Dockerfile.patch | 12 ++++++++++++ marznode/backends/singbox/_config.py | 20 +++++++++++++++++++- marznode/backends/singbox/_runner.py | 16 ++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.patch diff --git a/Dockerfile.patch b/Dockerfile.patch new file mode 100644 index 0000000..59ec188 --- /dev/null +++ b/Dockerfile.patch @@ -0,0 +1,12 @@ +# Overlay patch on top of dawsh/marznode (sing-box 1.11.3 already inside). +# Avoids rebuilding from scratch — only copies patched Python files over the base image. +# Upstream build (Dockerfile) pulls jklolixxs/sing-box:latest which is no longer publicly available. + +FROM dawsh/marznode@sha256:64734e9a0a075cd4da590765ed11f86c564ba311a339dab72b0976438104adab + +# Patched files implementing xtls-rprx-vision flow for VLESS-REALITY in sing-box backend +# + sing-box config pre-check on start (fail-fast instead of crash-loop on invalid JSON). +COPY marznode/backends/singbox/_config.py /app/marznode/backends/singbox/_config.py +COPY marznode/backends/singbox/_runner.py /app/marznode/backends/singbox/_runner.py + +# Keep original CMD / entrypoint from base image. diff --git a/marznode/backends/singbox/_config.py b/marznode/backends/singbox/_config.py index 77daf01..bc384ef 100644 --- a/marznode/backends/singbox/_config.py +++ b/marznode/backends/singbox/_config.py @@ -82,6 +82,15 @@ def _resolve_inbounds(self): settings["sid"] = inbound["tls"]["reality"].get("short_id", [""])[0] + # For VLESS+REALITY over raw TCP, sing-box supports xtls-rprx-vision. + # Mirrors marznode PR #30 behavior for the xray backend. + if ( + inbound["type"] == "vless" + and settings["tls"] == "reality" + and not inbound.get("transport") + ): + settings["flow"] = "xtls-rprx-vision" + if "transport" in inbound: settings["network"] = inbound["transport"].get("type") if settings["network"] == "ws": @@ -112,7 +121,16 @@ def _resolve_inbounds(self): def append_user(self, user: User, inbound: Inbound): identifier = str(user.id) + "." + user.username - account = accounts_map[inbound.protocol](identifier=identifier, seed=user.key) + kwargs = {"identifier": identifier, "seed": user.key} + # For VLESS inbounds that advertise a flow (e.g. xtls-rprx-vision for + # REALITY), propagate it to the account so sing-box registers the user + # with a matching flow; otherwise the client handshake is rejected with + # "flow mismatch" by sagernet/sing-vmess vless/service.go. + if inbound.protocol == "vless": + cfg = inbound.config if isinstance(inbound.config, dict) else {} + if flow := cfg.get("flow"): + kwargs["flow"] = flow + account = accounts_map[inbound.protocol](**kwargs) for i in self.get("inbounds", []): if i.get("tag") == inbound.tag: if not i.get("users"): diff --git a/marznode/backends/singbox/_runner.py b/marznode/backends/singbox/_runner.py index 254eec1..957946c 100644 --- a/marznode/backends/singbox/_runner.py +++ b/marznode/backends/singbox/_runner.py @@ -31,10 +31,26 @@ def __init__(self, executable_path: str): self._logs_task = None atexit.register(lambda: self.stop() if self.running else None) + async def _validate_config(self, config_path: str): + """Run `sing-box check` before starting — fail fast with a clear error + instead of a silent crash-loop on invalid configs.""" + proc = await asyncio.create_subprocess_exec( + self.executable_path, "check", "-c", config_path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + err = stderr.decode(errors="replace").strip() + logger.error("sing-box config validation failed: %s", err) + raise RuntimeError(f"invalid sing-box config: {err}") + async def start(self, config_path: str): if self.running is True: raise RuntimeError("Sing-box is started already") + await self._validate_config(config_path) + cmd = [self.executable_path, "run", "--disable-color", "-c", config_path] self._process = await asyncio.create_subprocess_shell( " ".join(cmd), From 65fc3776471f6b2845a5cffd4164b2c3b5312f09 Mon Sep 17 00:00:00 2001 From: abpestov-cpu Date: Tue, 21 Apr 2026 23:40:30 +0300 Subject: [PATCH 2/3] feat(singbox): bump to v1.13.9, build from source with required tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old overlay layered dawsh/marznode:latest (sing-box 1.11.3) with patched Python only. 1.11.3 is incompatible with recent Xray-core REALITY clients — they get "[EOF]" right after the TLS handshake and the server log shows "REALITY: processed invalid connection". The fix is to move to v1.13.9, where the TLS stack has been reworked. The official ghcr.io/sagernet/sing-box:v1.13.9 image ships without `with_v2ray_api` (needed for FetchUsersStats) and `with_grpc` (v2ray gRPC transports), so we build our own from source — keeping full tag parity with the base dawsh image, minus tags that 1.13.x absorbed: - with_reality_server → merged into with_utls - with_ech → migrated to Go stdlib Build specifics learned the hard way: - -checklinkname=0 is required: sing-box uses `//go:linkname` into crypto/tls internals (`handlePostHandshakeMessage`) which Go 1.23+ rejects without the flag. - -X internal/godebug.defaultGODEBUG=multipathtcp=0 matches upstream release/LDFLAGS. - GOTOOLCHAIN=local pins us to the image's Go (go.mod may request a newer patch version). Consumer-side effect: 1.13.x drops the legacy WireGuard `outbound` format — it must be migrated to the new top-level `endpoints: [{ type: wireguard, address, peers: [...] }]`. Our Router/marzneshin configs have been updated in a parallel commit. --- Dockerfile.patch | 56 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/Dockerfile.patch b/Dockerfile.patch index 59ec188..f4595cb 100644 --- a/Dockerfile.patch +++ b/Dockerfile.patch @@ -1,11 +1,57 @@ -# Overlay patch on top of dawsh/marznode (sing-box 1.11.3 already inside). -# Avoids rebuilding from scratch — only copies patched Python files over the base image. -# Upstream build (Dockerfile) pulls jklolixxs/sing-box:latest which is no longer publicly available. +# Multi-stage build: patched marznode with sing-box v1.13.9. +# +# Stage 1 builds sing-box from source because the official +# ghcr.io/sagernet/sing-box:v1.13.9 image ships without the +# `with_v2ray_api` and `with_grpc` tags — marznode needs the former for +# user-traffic stats (FetchUsersStats) and the latter for v2ray grpc +# transports. The base image dawsh/marznode:latest carries sing-box +# 1.11.3, which is incompatible with recent Xray-core REALITY clients +# ("REALITY: processed invalid connection" / client [EOF] right after +# the TLS handshake). +# +# Tag changes between 1.11.3 and 1.13.9: +# - `with_reality_server` merged into `with_utls` — not passed explicitly. +# - `with_ech` deprecated (migrated to stdlib) — dropped. +# Everything else is kept at parity with dawsh/marznode's default set. +FROM golang:1.25-alpine AS singbox-builder + +ARG SING_BOX_VERSION=v1.13.9 +# Tag parity with dawsh/marznode's sing-box 1.11.3 build, minus tags that +# became implicit in 1.13.x (with_reality_server → with_utls, with_ech → +# stdlib). `with_musl` mirrors the official alpine-based release. +ARG SING_BOX_TAGS="with_gvisor,with_quic,with_grpc,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_v2ray_api,with_musl,badlinkname,tfogo_checklinkname0" + +RUN apk add --no-cache git + +RUN git clone --depth 1 --branch "${SING_BOX_VERSION}" \ + https://github.com/SagerNet/sing-box.git /src +WORKDIR /src + +# -checklinkname=0 / -X internal/godebug.defaultGODEBUG=multipathtcp=0 +# mirror release/LDFLAGS from upstream. Without -checklinkname=0 Go 1.23+ +# rejects sing-box's linkname hooks into crypto/tls internals ("invalid +# reference to handlePostHandshakeMessage"). GOTOOLCHAIN=local pins us to +# the image's Go (go.mod may request a newer patch version). +RUN GOTOOLCHAIN=local CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -buildid= -checklinkname=0 -X internal/godebug.defaultGODEBUG=multipathtcp=0 -X github.com/sagernet/sing-box/constant.Version=${SING_BOX_VERSION}" \ + -tags "${SING_BOX_TAGS}" \ + -o /out/sing-box \ + ./cmd/sing-box + +# Sanity-check that the produced binary has all the tags we asked for. +RUN /out/sing-box version + +# --- + +# Stage 2: overlay on top of dawsh/marznode (Python + base sing-box), +# replace the 1.11.3 binary with our 1.13.9 build, and copy the patched +# Python files (VLESS xtls-rprx-vision flow for sing-box backend + +# sing-box config pre-check on start). FROM dawsh/marznode@sha256:64734e9a0a075cd4da590765ed11f86c564ba311a339dab72b0976438104adab -# Patched files implementing xtls-rprx-vision flow for VLESS-REALITY in sing-box backend -# + sing-box config pre-check on start (fail-fast instead of crash-loop on invalid JSON). +COPY --from=singbox-builder /out/sing-box /usr/local/bin/sing-box + COPY marznode/backends/singbox/_config.py /app/marznode/backends/singbox/_config.py COPY marznode/backends/singbox/_runner.py /app/marznode/backends/singbox/_runner.py From 1943032a57614a272c0a44df5d2625ec92ca1896 Mon Sep 17 00:00:00 2001 From: abpestov-cpu Date: Wed, 22 Apr 2026 15:07:10 +0300 Subject: [PATCH 3/3] refactor(singbox): simplify VLESS flow injection before upstream PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop redundant isinstance(inbound.config, dict) guard: models.Inbound declares `config: dict` via pydantic, so the type is enforced at parse time and the isinstance branch was dead. - fold the two nested ifs into a single walrus expression. - extend the comment on settings["flow"]="xtls-rprx-vision" to name the affected clients (xray-core, v2rayN, NekoBox) and the concrete rejection path (sagernet/sing-vmess "flow mismatch"), plus the precedent marznode#30 (fix(xray): consider flow when transport is raw). No functional change — just prep for an upstream PR. --- marznode/backends/singbox/_config.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/marznode/backends/singbox/_config.py b/marznode/backends/singbox/_config.py index bc384ef..189f489 100644 --- a/marznode/backends/singbox/_config.py +++ b/marznode/backends/singbox/_config.py @@ -82,8 +82,11 @@ def _resolve_inbounds(self): settings["sid"] = inbound["tls"]["reality"].get("short_id", [""])[0] - # For VLESS+REALITY over raw TCP, sing-box supports xtls-rprx-vision. - # Mirrors marznode PR #30 behavior for the xray backend. + # VLESS-REALITY over raw TCP: expose xtls-rprx-vision as the + # default flow so users inherit it via append_user(). Mirrors the + # xray backend fix from marznode#30 — without this, modern clients + # (xray-core, v2rayN, NekoBox) default to vision and get rejected + # by sagernet/sing-vmess on "flow mismatch". if ( inbound["type"] == "vless" and settings["tls"] == "reality" @@ -122,14 +125,11 @@ def _resolve_inbounds(self): def append_user(self, user: User, inbound: Inbound): identifier = str(user.id) + "." + user.username kwargs = {"identifier": identifier, "seed": user.key} - # For VLESS inbounds that advertise a flow (e.g. xtls-rprx-vision for - # REALITY), propagate it to the account so sing-box registers the user - # with a matching flow; otherwise the client handshake is rejected with - # "flow mismatch" by sagernet/sing-vmess vless/service.go. - if inbound.protocol == "vless": - cfg = inbound.config if isinstance(inbound.config, dict) else {} - if flow := cfg.get("flow"): - kwargs["flow"] = flow + # Propagate VLESS flow (e.g. xtls-rprx-vision for REALITY) to the + # account so sing-box registers the user with a matching flow — + # otherwise the client handshake is rejected with "flow mismatch". + if inbound.protocol == "vless" and (flow := inbound.config.get("flow")): + kwargs["flow"] = flow account = accounts_map[inbound.protocol](**kwargs) for i in self.get("inbounds", []): if i.get("tag") == inbound.tag: