Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions Dockerfile.patch
Original file line number Diff line number Diff line change
@@ -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}" \

Check warning on line 37 in Dockerfile.patch

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Line is too long. Split it into multiple lines using backslash continuations.

See more on https://sonarcloud.io/project/issues?id=marzneshin_marznode&issues=AZ4jg_Y3WK2aw_ookFfQ&open=AZ4jg_Y3WK2aw_ookFfQ&pullRequest=41
-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.
20 changes: 19 additions & 1 deletion marznode/backends/singbox/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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"):
Expand Down
16 changes: 16 additions & 0 deletions marznode/backends/singbox/_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down