diff --git a/Dockerfile.patch b/Dockerfile.patch new file mode 100644 index 0000000..f4595cb --- /dev/null +++ b/Dockerfile.patch @@ -0,0 +1,58 @@ +# 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 + +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 + +# Keep original CMD / entrypoint from base image. diff --git a/marznode/backends/singbox/_config.py b/marznode/backends/singbox/_config.py index 77daf01..189f489 100644 --- a/marznode/backends/singbox/_config.py +++ b/marznode/backends/singbox/_config.py @@ -82,6 +82,18 @@ def _resolve_inbounds(self): settings["sid"] = inbound["tls"]["reality"].get("short_id", [""])[0] + # 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" + 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 +124,13 @@ 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} + # 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: 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),