Repository navigation
Releases
v0.8.22 — xray loopback guard, one holder per credential, MTProto instance scoping, silent-core recovery, L2TP upgrade billing, x/net v0.62.0
Release commit e4b18be7589a76a6f1abb01f2d9f44284aba6a2e on main. Previous release: v0.8.21 (347da494). This
release carries the 41 commits in v0.8.21..v0.8.22 (86 files, +5673 / -308); every one is listed by
git log v0.8.21..v0.8.22. NodeVersion 0.6.22 (e05d805b). One patch step after v0.8.21.
Before the fleet rollout
- One node must pass the L2TP upgrade check below (OBS-a5) before any node is moved from 0.6.20, because that is the
path v0.8.21 billed wrongly. - Any panel config that gives two users the same VLESS id, VMess id or Trojan password now gives the credential to one
of them only (C2). Check the panel for shared credentials before the rollout.
Xray: no user reaches a loopback destination (C1)
- Own control ports (
a6af5dd7RED,90759cd5fix,7fd97ebepins): ApplyAPI writes two rules for this instance's
API and metrics ports, ahead of every panel rule, to a blackhole outbound the node adds itself. They cover loopback,
unspecified, link-local and the host's interface addresses, pluslocalhost,*.localhostand numeric host forms
(2130706433,127.1,0x7f000001,localhost.). They live in the config every clone, Restart and
restorePreviousConfig replays, and ApplyAPI stays idempotent. - Every loopback destination on the user inbounds (
decd7f1etest infra,e8594146RED,2e5f9649fix): two more
rules send 127.0.0.0/8, 0.0.0.0/8, 169.254.0.0/16, ::1/128, ::/128, fe80::/10 and the IPv4-mapped forms, plus
localhostand*.localhost, on every port, to the blackhole, only for inbounds the node manages users on (vless,
vmess, trojan, shadowsocks, hysteria). Panel dokodemo-door and other non-user inbounds keep the panel's routing. - Known residual, accepted: under
routing.domainStrategyAsIs, a public domain name whose DNS record points at
loopback is matched by neither rule. UnderIPIfNonMatchorIPOnDemandthe address rule catches it. Operators of
cores set toAsIsshould add their own private-address block (for examplegeoip:privateto a blackhole) or move
the core toIPIfNonMatch.
Xray: one holder per credential on every inbound (C2)
- Core patch (
fbaddb22RED,6283e614fix):patches/xray-validator-credential-owner.patch, the 20th fork patch at
the xray pind2758a0. VLESS and Trojan validators refuse a key another email holds and remove only what still
belongs to the removed user; VMess refuses a command key whose decoder belongs to another email and removes a decoder
only while the removed user holds it. The Dockerfile guards every changed file. - Node holder logic (
bb2f4b63RED,86959bdefix, newbackend/xray/credential.go): a user whose credential another
user holds is refused and waits; it is installed when the holder leaves, including through UpdateUsersAndRestart. A
removed user never takes another user's credential. A user that moves onto a held credential is refused and its old
account leaves the core. - A batch frees the keys its users move off before it admits anyone, and revokes every replaced account before adding
(db45fd9cRED,3adeb7c8fix). - Tests:
8083f007,40dd004b,96c12a19,86da26e5.
Xray: a silent core is restarted after its full sync was abandoned
- A health restart aborts a silent full sync even after its caller gave up (
2d195875RED,92504e43fix). - Readiness bounds are per-Xray fields with the production defaults (
32147e60RED,388b3581fix); an abort racing
a core that turns ready ends in one clean outcome (f498a706, interleaving test).
MTProto: instance scoping and per-instance live cut (C3/C4)
- Each MTProto instance accepts only users that have its inbound (
308ffcfcRED,cae14431fix). Before, a user taken
off one MTProto inbound but kept on another still authenticated on the one it lost. - A live session is cut the moment its user loses the inbound it authenticated on (
fafc7cbdRED,e8cd97f7fix,
merged in9498dde6). Sessions on the instances the user keeps, and other users' sessions, survive.
L2TP: no rebilling after an upgrade from 0.6.20 (OBS-a5)
baf27844: each inbound keeps a persistent marker under/var/lib/pg-node/l2tp-counted, written on the first
successful save of its counted state. A missing counted file without the marker takes the present sessions as already
counted; with the marker it still means a fresh/runafter a reboot and counts from zero.- Upgrade check:
/var/lib/pg-nodemust be on persistent storage in the node's container setup, so the marker survives
a container recreate. - Forward accept rules stay out of mangle chains (
0bc4b8caRED,0544e60bfix); the linux-only IPsec guard tests
build only on linux (caf0987e).
Upstream PasarGuard node parity
- PasarGuard#55: recent node logs are kept and log streams fan out per subscriber (
38744465,pkg/logstream). - PasarGuard#101: WireGuard forward accept rules stay out of mangle chains (
3998a572). - Upstream
81af68e(released in upstream node v0.5.7): golang.org/x/net v0.60.0 -> v0.62.0 and the indirect module
refresh at upstream's exact versions (3bf215e8); thegodirective moves to 1.26.9, the toolchain stays 1.26.10. - Waiver ledger (
69e17a0f,e4b18be7):81af68e,24e2b12(PasarGuard#55) anda3d2d2f(PasarGuard#101) are ported; the version bumps
739eab9(v0.5.7) and675bdaa(v0.5.6) are not applicable to the fork's version line.
Pins
- mtg: Free-Guy-IR/mtg main
1ca247ba6e85e785d09c890301035096e5469bcb(was1563871b36dd,181af755): an accept
loop stalled by a slow PROXY protocol header and a panic inconns.goare fixed, with tests. - sing-box:
SINGBOX_COMMITdbd00be874a06d9870b2602dd92885367384c779(was5a7c1f1a31cd,46c8bc1e), still
v1.13.21, adds the port of upstream sing-quic7e0a586(hysteria2: ignore EMSGSIZE in the port hopping receive
loop). - xray:
XRAY_COMMITd2758a023cd7unchanged; one new fork patch (C2). - Go toolchain 1.26.10 unchanged.
Build, CI and supply chain
- Every published image carries
org.opencontainers.image.revision, the commit it was built from (5a470e57RED,
3a911231fix); a workflow gate refuses any image build step without it. - govulncheck: GO-2026-6866, GO-2026-6860 (x/net) and GO-2026-5841 (klauspost/compress) are gone. The node never called
the fixed x/net symbols, so this is not a security release for the node. - The sing-box ledger test fails on the test goroutine instead of hanging (
5b9281ab, D-a1).
Upgrade and compatibility
- Panel side: no
.protoor controller API change; only NodeVersion moves to0.6.22. - Shared credentials across users on one xray inbound are no longer served to both (C2): the second user waits.
- A user moved off one MTProto inbound is refused there and its live sessions there are cut (C3/C4).
- An xray user on a user inbound can no longer reach loopback, unspecified or link-local destinations (C1), except the
AsIsresidual above. - L2TP needs
/var/lib/pg-nodeto persist (OBS-a5).
Rollback
Redeploy the image the node ran before (ghcr.io/free-guy-ir/node:v0.8.20-degraded-start for nodes upgraded from
0.6.20). Rollback loses the C1, C2, C3/C4, OBS-a5 and silent-core fixes. Older images do not read the markers under
/var/lib/pg-node/l2tp-counted, so they can stay in place.
Known, tracked
- C2H1-S1, C2H1-S2 (LOW): a batch claimant admitted while a stock core's revoke fails, and extra drops if the core
crashes during the revoke phase until the panel resends; both need a fault-injecting fake core to test. - GO-2026-6651: the govulncheck waiver expires 2026-11-30.
- Upstream waivers:
8d0a7cf8expires 2026-11-10 and 36 more pending waivers expire 2026-11-20; the Upstream Drift
gate goes red after an expiry unless the item is ported or the waiver renewed. - GO-2026-5932 (x/crypto openpgp) is reported with no call path.
v0.8.21 — accounting, revocation and core-crash fixes, upstream node v0.5.7, mtg v2.3.0
Release commit 347da49466514ccc2ef0a2384a46d8111c45fed3 on main. Previous release: v0.8.20-degraded-start
(2d8e11f3). This release carries the 545 commits in v0.8.20-degraded-start..v0.8.21; every one is listed by
git log v0.8.20-degraded-start..v0.8.21. NodeVersion 0.6.21. One patch step after v0.8.20.
Before the fleet rollout: dd-mode MTProto media check (DEPLOY)
The mtg pin now carries upstream 63049fa39c43 "preserve the sign of Telegram DC ids". A dd (plain) MTProto client that
asks for media DC -N now reaches Telegram as -N instead of N. This changes what every dd stream sends. Before the
image goes to the whole fleet, one node must pass a real-traffic check through a dd link: open a chat with photos,
videos and files that are not cached, and confirm they load. Only then roll out to the rest.
Pins
- mtg:
github.com/9seconds/mtg/v2v2.3.0, replaced by Free-Guy-IR/mtg main1563871b36dd518970490c8d018bdd2325d75e31
(7fa5c6c3). That is upstream release v2.3.0 merged with the fork's fixes (950d39a9, pin7f8c04762e3b) plus one
fork commit that rejects a connection whose remote address has no TCP IP instead of panicking. The upstream v2.3.0
commits it carries (transient accept errors, Linux accept(2) retry, socket-option drop logging, ordinary stop, v2
network constructor, signed DC ids) are listed indocs/upstream-parity.md, "mtg v2.3.0 commits in the pin". - sing-box:
SINGBOX_COMMIT5a7c1f1a31cd11998ad249f00216f3a7add47d2b, Free-Guy-IR/sing-box main, still
v1.13.21(347da494, after899005e1b8bfin007bae8bandv1.13.21ind189e942). It brings the vision close
fix and three verbatim sing-vmess ports: the vision reshape split under the 8171-byte limit (upstream40a1d62fd3a9),
no zero-length end-of-stream chunk on an empty write (upstream2a5002728353) and the reader port (upstream
31ec11e8790c). sing-quic staysv0.6.5. - xray:
XRAY_COMMITd2758a023cd7, built against golang.org/x/net 0.60.0;Dockerfile.xray, which shipped an
unpatched core, is removed (60ecdcac). - Go toolchain 1.26.10 in go.mod, CI and the image builders (
410815f6).
Upstream PasarGuard node parity
- v0.5.7: grpc 1.84.0 -> 1.85.0 (PasarGuard#122,
bdd91efe, with the RED for Stop hanging behind a GracefulStop that waits on an
orphaned handler,b45a71b5) and gopsutil 4.26.9 -> 4.26.10 (PasarGuard#121,62059d0a). Waivers settled inb08ecee0; the
indirect-module refresh81af68eand the version bump739eab9stay recorded as pending. - PasarGuard#112: WireGuard canonical public keys and IPv6
NODE_HOST(74e9aaa4), and the counter-dip rule for the WireGuard
tracker only (ee73b4f7). - PasarGuard#114: Shadowsocks 2022 inbounds stay multi-user when they start empty (
3d18e70f). - PasarGuard#115: keep-alive refreshes on every call from the owning panel (
c2e15f29). - PasarGuard#116: a core start can outlive the Start request (
58a47bd8). - PasarGuard#90: configurable WireGuard server interface MTU (
f9c4f05b). - The node v0.5.5 items, including the upstream grpc, gopsutil, x/sys, x/net and x/crypto bumps, recorded in
docs/upstream-parity.md. go-chi moves to 5.5.0. - Not ported: node v0.5.6 MASQUE (PasarGuard#96,
88d5e75) and its version bump675bdaastay pending in
.github/upstream-waivers.txtunder NODE-SYNC-055, until 2026-11-20.
MTProto
- Each instance's serve loop is supervised: an accept error cannot silence it, a panicking loop marks only its instance
dead, one dead instance no longer stops the node, and state is reported per instance (a8f6b6a2,09d06836,
5a50d944). - A panic in mtg or in a node callback no longer takes the node down (
15c04808). - A revoked user's open sessions are closed, including a stream begun before the revoke (
dde62226,90fd4897); a
removal without a usable secret revokes by identity (63e33176); a stream context with no Close raises one alarm per
instance (bf7eb766, F-V7-S7). - Accounting is handed to the next backend on every Start, force-closed middle-proxy relays bill what they write after
Shutdown, concurrent Shutdowns hand over once, and Restart is refused after shutdown (472d2d7d,217ea7c5,
f7e3c7d5,3a3157b6). Goroutine leaks on Start and the Restart listener swap are fixed (a6a82378,0e84d610,
4e434845).
Xray
- A revoked or removed user's live connections and UDP flows are cut and new ones refused; a replaced account is revoked
and only inbounds that held the user are touched (fdde3199,6d56a6d4,529d4067,f115e999,7e97beea). - Health restarts wait for user syncs, never outlive shutdown, probe the core a sync installed and keep what a cancelled
sync staged (82733dfc,a7c46ccf,982bc47c,c2ed9854,26bba5a8). - The config is serialised from a snapshot (
797adcf0); per-user stats registration is atomic (df13041b);
unchanged users are left alone during sync and the last entry for a user wins within a batch (40006fe1,5a3c2ba0). - X-Forwarded-For is honoured only behind
sockopt.trustedXForwardedForand only fromXRAY_TRUSTED_FORWARDER_CIDRS,
which refuses IPv4-mapped entries (a170e667,c1e32b6c,a104cfb5); a pinned CA needs a server name (e1b77af8). - Core crash and race fixes carried at the pin: QUIC sniffer, WebSocket early data, SS2022 invalid destination, DNS
names over 255 bytes, HTTP 1xx, tunnel inbound on a unix socket, WireGuard gVisor tun and outbound Close race, routing
API race, XHTTP client races, singbridge datagram release (099f105f,d0b86f11,e9a433d0,78ea868e,448b373e,
2d0272ec,89e6a8e0,b7498bbc,a60ba6f2,4c7f0a6d,0abd7251); route picks no longer wait on rule updates
(14cf5056); an empty hysteria auth is never installed (8882fc25).
L2TP accounting
- Every session's bytes are billed once across restarts, renegotiations, reused pids, late ip-down and reboots: the
session identity and final record are written atomically, watermarks survive a grace period and unreadable state, the
fallback window is decided on boot time, and a missing run directory is an incomplete listing (b193386f,
cf7f5008,ae0bc187,5cd061b5,4d3ff097,28600993,387d80cf,7a39c000,4f6bb0e9and the rest of the
fix(l2tp)series). ip-up/ip-down are replaced atomically and keep every character of a record value (6ae0fb84,
9166cb5a). User syncs are refused after shutdown (db096a71).
Stats, WireGuard, OpenVPN, sing-box
- Uplink is bytes received from the client on every protocol; node-level and per-inbound traffic are kept apart; a
dipped counter rebases only its direction, and a restarted counter counts from zero with unread bytes carried
(68d40f03,61979286,17dfdf50,e55fd11e,83932cb0). - WireGuard accounts every peer byte through removals, replacements, Start and interface hand-over, and removes the peer
when a user loses access (774fa21a,054d4aca,a2c63c2d,cbb63a06). - OpenVPN drains final counters, settles sessions after the management loop exits, revokes a user without an OpenVPN
block, and indexes users by email so bulk updates stay linear (143c2532,8ab5dc8f,514460a4,c3ff1c3b). - sing-box: health restarts wait for user syncs, a closed stats client returns an error instead of crashing, and an empty
hysteria2 password is never installed (aa2131db,7067a349,8db51e50).
Controller, RPC and REST
- The API key is checked before grpc-go reads the request, and an invalid or nil
API_KEYfails fast (d489663a,
88162e25). - User syncs are serialised with every core lifecycle change; a queued SyncUser fans out to every backend; a down primary
is isolated from the extra backends; backend management requires the current client; the keep-alive watchdog is
serialised with Start, and panel keepalive pings are accepted (af180e20,ee207282,673524b5,de3493fb,
40248f2c,7192af88).
Build, CI and supply chain
install_core.shand base images pinned and shipped geo data verified (b30c3e46); every patched xray file guarded
in the image build (11d549d0,8fbae98f); govulncheck refuses unproven patch application (1df3a918,703eeaf2);
tracked executables and files over 5 MB are refused (8c24aa4band follow-ups).- Upstream Drift CI fails on unported upstream commits and releases; every unported upstream commit carries a reasoned
waiver in.github/upstream-waivers.txt(thedriftseries).
Known, tracked
No production defect was found in v0.8.20-degraded-start..v0.8.21. These items are tracked, with an owner and a date:
- D-a1:
backend/singbox/inbound_ledger_lock_test.gocallst.Fatalffrom a spawned goroutine (test-only). Due 2026-11-05. - OBS-a5: L2TP double bill at the 0.6.20 -> 0.6.21 upgrade when
/run/pg-l2tpor the PID namespace outlives the
container (bare metal, systemd,pid: host, bind-mounted/run/pg-l2tp). Not reachable in the stock image. The fix,
a persistent upgrade marker under/var/lib/pg-node, goes into v0.8.22; deploy gate G3c, before node 70 is upgraded. - N4-E2: the
4f0bbc51WireGuard test checks only aggregate bytes, so a per-user misattribution would pass
(billing-relevant). Due 2026-11-10. - S3-W1..W6: weak tests in
a5757d79,6519b862,84feaa37,ca520191,09049248,cb1f730b. Due 2026-11-10. - N4-E1, N4-E3..E7: L2TP lock tests without
useHostSeam(45f5b25e,5e86ff90);10c22bd4does not prove Read is
parked;ac063346/4f3a2302rely on a 300 ms sleep;5d015b5e/619015fdbounded under-bill awaits an owner decision;
7044833bwindow-close and finals-without-identity assertions;ac29f67dpending-* cases and9e9bd59cdocs. Due 2026-11-10. - N5-E1..E5:
7b4e67fd,0a3c68fa,3a844f88,f1958fe6,9fc13a1enot RED at their own commit; drift rule cases
masked by rules 508/537;shadowsocks2022_test.goskips without an xray binary;797adcf0equivalence pin; parity doc
count inf2e6adb0. Due 2026-11-10. - S2-N1..N8: Makefile `instal...
v0.8.20 — a filter rule must never keep a node down
A routing rule the core cannot resolve used to make xray refuse to start, which took five production nodes to status=error until the assignment was deleted by hand.
The node now retries once with the panel's pgcf-* rules stripped out and comes up unfiltered, and reports the degradation through BaseInfoResponse (fields 5-7) and its log stream, so the panel can see it and name the assignment that caused it.
Stripping is only reached when the config actually carries pgcf-* rules and the stripped retry succeeds. Messages that routing shares with DNS must arrive with routing context before they count. A core starved of memory or killed from outside is retried unchanged first, and an unchanged retry that fails for an unrelated reason is returned as it is.
NodeVersion 0.6.20.
v0.8.19 — carry the content-filter data file
A content-filter profile naming any pglist-* category builds a rule referencing ext:pgfilter.dat. That file shipped only inside the panel, and the rule is written into the shared core config, so xray did not ignore it — it refused to start:
Failed to start: main: failed to load config files: [stdin:]
> infra/conf: failed to build routing configuration
> failed to open file: pgfilter.dat
Every node on that core was one restart away from being down. Five production nodes proved it.
The data file is now built into the image beside geoip.dat and geosite.dat. Side-by-side on the exact rule that broke the fleet: v0.8.18 returns failed to open file: pgfilter.dat, this build returns Xray 26.3.27 started.
The xray source is also pinned to commit d2758a0 and the checkout asserted against it, matching the sing-box stage. A git tag can be moved; a tag alone does not say which code was built.
v0.8.18 — let a routing rule carry a large geosite category
A routing rule could not carry a large geosite category
Installing a filter that names geosite:category-ads-all came back, on every node at once:
grpc: received message larger than max (4326122 vs. 4194304)
4194304 is grpc-go's default receive size. The ceiling is not this node's: controller/rpc/service.go has set MaxRecvMsgSize/MaxSendMsgSize to 64 MiB since upstream v0.2.0, and the node's own log line Method: AddRoutingRule, Code: ResourceExhausted proves it decoded the 4.33 MB message fine — that line comes from a unary interceptor, which grpc-go runs only after dec(in) has already enforced the limit.
The ceiling is Xray's own commander: app/commander/commander.go calls grpc.NewServer() with no options at all. A rule naming a geosite category is expanded into concrete domains before it reaches the core, and category-ads-all holds 170,624 of them.
Xray is now built from a pinned upstream tag with a two-line patch giving its commander the same 64 MiB this node already allows elsewhere. The build runs git apply --verbose and then greps for the result, so a patch that stops applying fails the build rather than silently shipping an unpatched core. The prebuilt binary the install script fetches still provides the geo assets; only the binary is replaced.
Proven end to end, not argued: built on the test host, the test node recreated on the patched image (Xray 26.3.27 … d2758a0-dirty), and the same 170,624-domain list applied — enforced: true, last_error: null, and the node logged Method: AddRoutingRule, Status: Success. The identical list fails on every unpatched node.
The gRPC server had no cap on concurrent streams
It ran at grpc-go's math.MaxUint32. Because the 64 MiB receive ceiling is enforced before the api-key interceptor runs — ClientAuth is NoClientCert, and the generated handler calls dec(in) before interceptor(...) — any peer that completed the TLS handshake could hold open unbounded concurrent 64 MiB allocations on hosts that publish the port. It is now GRPC_MAX_CONCURRENT_STREAMS, default 64, far above what one panel uses.
NodeVersion goes 0.6.16 → 0.6.18.
v0.8.17-named-identity
Pins the sing-box core that identifies hot-reloadable users by name instead of by slice position. NodeVersion moves to 0.6.17.
Why
Hot-reloadable inbounds resolved a user from an index frozen into the connection context at authentication and replayed for the life of the session. The panel pushes the full user set sorted by name on every change, so adding or removing any alphabetically earlier user shifted every later index. A live session then resolved to a different user and billed them for traffic they never generated, and a shrinking set panicked with index-out-of-range and killed the core.
What changed
SINGBOX_COMMIT now points at the core that carries identity by name for vless, vmess, trojan, tuic and shadowsocks, guards the hot-reloaded user maps with an RWMutex, and closes a removed user's live sessions.
Its TUIC regression test reproduces the wrong attribution and the panic against the previous pin, and passes on this one.
v0.8.16-multicore
Node 0.6.16: lock-free attach, 0600 configs, stats success-count, build retry
Carries the multi-core process-lifecycle hardening from the final audit with
its version constant correctly set to 0.6.16.
v0.8.15-multicore
Build a backend off the controller lock, 0600 configs, count stats successes
Final-audit hardening: AttachBackend no longer holds the controller lock
across the version probe; staged sing-box config is 0600; StatsAll keys the
all-failed decision on delivered-backend count, not payload size.
v0.8.14-multicore
Reap a forked probe child via Cancel, drop the post-Wait reap
WaitDelay bounds the call; the context-cancel handler group-kills a hung
binary while it is still alive. The post-Wait ReapProbe raced a reused pid
and is removed. No real core version command forks, so behaviour is unchanged.
v0.8.13-multicore
Only group-kill a probe proven to own its process group
ReapProbe now sends the group SIGKILL only when the target is provably not
this node's own process group, and always ends with a single-pid kill.