Bug fixes in Hiddify sing-box v5
Bugs fixed in the Hiddify fork on top of sing-box v1.15. The newest fix is at the top. Each entry names the commit that fixes it.
A proxy was pinged several times at once, and lowest-delay moved to a slower one
Commit: 635d533ac
What happened: a tap on a proxy, or a group moving its selection to it, started a test while the cycle's test of it was running, and a staged test started a second and a third attempt beside a slow first one: the proxy was pinged two or three times, and whichever test ended last wrote the result, so a late failure replaced a fresh answer and the groups left the member. After a start or a hot reload, when every result is cached, the first member to answer took the lowest-delay selection at 91 ms over a 17 ms one still being tested, and lost it a moment later; each move tested the new pick again, seconds after it was measured, and under load that second ping read 2549 ms for a 59 ms member. A member chosen while the fast one did not answer was held for the whole cycle, long after the fast one answered again. hiddify-core sent an absolute tolerance of 100 ms, which kept a 59 ms member over a 25 ms one.
Fix: one URL test per outbound at a time: a test asked for while one runs joins it and takes
its result. A staged test retries only a failed attempt, at the next mark, and skips the first
mark for an outbound that worked in this run. Lowest-delay holds during a cycle only until the
cycle has tested the member in use. A selection measured in the last 15 s is not tested again. The
MONITORING and LOWEST log lines say why every test runs and what the group decides.
hiddify-core sends a tolerance of 20 ms with a ratio of 0.3, and keeps a profile's proxy tags
stable across its updates, so a hot reload keeps the unchanged proxies and their pings. Then
(a85a71b1a): the first cycle's results arrive one by one and the selection still hopped at each
faster one, 439 ms to 170 to 112 to 41 to 18 in forty seconds. A member that took the selection
with a real ping stays for 30 s while it answers, then the best known takes it. A ping cached from
the previous run decides nothing and is not shown: it only orders the members until the real pings
arrive, and the app's list sorts by it (sort_delay) without showing it.
With a large subscription, the pings changed every few seconds
Commit: 635d533ac
What happened: with a free subscription of a hundred or more configs, the ping the app showed changed every few seconds, up and down (29, 44, 33 ms). Three things tested the same proxies again and again. A round robin balancer told monitoring of a new selection whenever the members it rotates through changed, which is almost every result of a cycle, and each telling tested its parents (the Extra Security hop) and its next pick. A member that started working retested everything measured through each group it is in, the hop behind the selector too, whether the group used it or not. And each regular cycle moved the selection result by result. In the first minutes on an unstable network, the home screen said the selected proxy was not responding while others worked: lowest-delay stayed on the member it had cached from the last run, since a staged test's miss was hidden from the groups behind the last confirmed result, which for that member was the cached one, and was even marked as fresh.
Fix: only a balancer that sends every connection through one member (lowest-delay) tells monitoring of a new selection. A member that starts working retests what goes through a group only when the group uses it. During a regular cycle of the schedule, lowest-delay and round robin keep the members they use while these answer, and choose with all the results when it ends (monitoring tells every group); the first cycle and one after a network change take results as they come. A miss of a member with no result of this run but the cached one is seen by the groups, so lowest-delay leaves it for a member that answers. The app shows a round robin group by a member that another replaces only when faster by more than the lowest-delay tolerance. On the Android emulator with a 160-config subscription and Extra Security, the hop's priority tests in three minutes went from 65 to 0, and the ping shown stayed the same after the first seconds.
WARP crashed the packet tunnel when it restarted during a network change
Commit: 00f7b6e63
What happened: after a network change, a WARP endpoint with the scanner checks its endpoint in the background, and scans for another when it does not answer. When WARP restarted meanwhile (a refused device, a change of tunnel), the restart cleared the endpoint's scanner state while the check of the start that ended still ran. The scan read that state again to save its scores and hit a nil pointer, which ended the whole process: on iOS, the packet tunnel. The first scan at connect did the same, its probe reading the state again (h_failover.go:258 in 5.5.1): on Windows, right after connecting on a filtered network, when a refused registration restarted WARP ("register WARP masque device: context canceled").
Fix: a check reads the scanner state of its start once and works on it to the end; the scan, the probes, the noise candidates and the scores take it from the check instead of reading the endpoint again, and the state is read and written under the endpoint's lock. A test clears the state in the middle of a scan, as a restart does; it crashed before the fix.
A DNS rule with bypass_if_failed logged an error for every query it passed on
Commit: b1ec924c5
What happened: when the server of a rule with bypass_if_failed failed, the next rule
answered the query, but the failure was still logged at error level. hiddify-core's Fake DNS rule
uses bypass_if_failed to pass every query other than A and AAAA (HTTPS, TXT, ...) from the fake IP
server to the remote rule, so a browser filled the log with "only IP queries are supported by
fakeip" errors.
Fix: a failure that the next rule takes over is logged at debug level. A failure with no bypass is still an error. Two tests check both cases.
A masterdns outbound used one of several resolvers on the same IP
Commit: 0e8ed7f69
What happened: with smart_pool off, the outbound gives its resolvers to the MasterDnsVPN
client, whose library entry point kept one resolver per IP. Resolvers on one IP with different
ports (127.0.0.1:5301, 127.0.0.1:5302, or a server answering on more than one port) became a
single resolver, so the client's balancer and packet duplication had nothing to spread over.
Fix: MasterDnsVPN (the Hiddify fork, 62e94f1) keeps each IP and port as a resolver of its
own; the resolver file loader keeps its old behaviour. A test checks one IP with three ports.
The default build left out mKCP, Sudoku, Bond and the call tunnel
Commit: 1c6b0ec6f
What happened: the Makefile's default tags were meant to add with_sudoku, with_call,
with_bond and with_kcp, but they were joined with / instead of ,. Go read the four as one
unknown tag, so make builds and the test runs that use the Makefile tags had none of them, and a
profile with mKCP failed with "mKCP is not included in this build".
Fix: the tags are separated by commas. The full test suite passes with them on.
Links with Xray's merge mask, dialMode or hellochrome_120 were dropped or failed
Commit: 502f16cfa
What happened: patterniha's Xray-core links (and the subscriptions built from them) use three
things this fork did not know. A finalmask merge mask made the whole link fail to load, so the
link was left out of the profile. dialMode=oob.0 was ignored. Xray's versioned fingerprint names
such as fp=hellochrome_120 failed the outbound with "unknown uTLS fingerprint".
Fix: finalmask has the merge mask and an oob mask, Xray's oob. dialMode, which sends the
first writes with an extra byte as TCP urgent data (ported from patterniha/Xray-core, MPL 2.0).
uTLS takes every fingerprint name of Xray's lists, in any case, after sing-box's own names.
Monitoring: proxies stayed "waiting" after a hot reload, and stopped being tested when idle
Commit: pending
What happened: Three things kept rows of the proxies page on the waiting animation for good.
A hot reload (a profile update, a changed setting) replaces the outbounds whose options changed;
each replaced one started over with no result, and Reload could only start a test cycle when
none was running. In a profile of hundreds of proxies a cycle runs for minutes, so the replaced
ones waited for the next interval tick. The ticks themselves stop once the core has routed
nothing for the idle time (three intervals, 30 minutes with Hiddify's defaults), and the app
watching the list did not count as activity, so with little traffic nothing was tested again at
all; a tap on a row still tested that one row, which made it look as if only the clicked proxy
got a ping. And the Touch that restarts the stopped workers left the old activity time in
place, so the next tick stopped them again.
Fix: A replaced outbound keeps its last result as a cached one, due for a test, the way a result loaded at start is: the groups can fall back on it and the app shows it as waiting. A reload then tests the outbounds it created or replaced, and only those, at once and whether or not a cycle is running; the others keep their result until their regular time. Restarting the workers counts as activity. hiddify-core touches the monitoring while the app's proxies stream is open.
IPv6 by outbound: IPv6-first sites still died through a proxy without IPv6
Commit: d992de248
What happened: The IPv6 probe that 5.4.0 added opened a connection to a public resolver's
IPv6 address through the outbound and took an open connection as IPv6 carried. A VLESS, Trojan,
VMess or Shadowsocks dial returns before the proxy's server has connected anywhere, so the probe
said yes for every such proxy, AAAA answers went out, Chrome, Samsung Internet and the YouTube
app reached Google over IPv6, the proxy closed the connection and the page showed
ERR_CONNECTION_CLOSED, while Telegram and Instagram, on IPv4, worked (hiddify-app 5.3 and 5.4 on
Android 16 and 17, #2420). The probe was also traffic of its own to Google and Cloudflare
addresses, which a filtered exit breaks, and a reset after the proxy's attempt would not have
helped: an app falls back to IPv4 only when the connect itself fails, and through a TUN the
connect succeeds locally before the proxy has tried anything.
Fix: The probe is gone (ipv6_probe is retired). An outbound's IPv6 is verified at its URL
test, with the user's own test URL fetched once more over the host's IPv6 address
(verify_ipv6), once per run and cached per outbound; a share link may declare it (ipv6=1,
ipv6=0, ipv6_declared); and a TCP connection to an IPv6 address through an outbound not known
yet is watched by the router (route.ipv6_fallback): when the outbound gives nothing, the same
outbound is dialed with the sniffed domain, the app's bytes are said again and the connection
goes on, the app seeing no failure, and the outcome teaches the outbound's IPv6. ipv6_by_outbound
and ipv6_unavailable_via are fail-open again: an outbound not known yet is let through, and one
known to have no IPv6 gets no AAAA answers and has its IPv6 connections refused before the
handshake. hiddify-core turns IPv6 for the apps off by default and all of this on when the IPv6
mode turns it on. See IPv6 by outbound.
Naive: App Review rejected macOS builds for a non-public API
Commit: pending
What happened: App Review rejected the macOS app because hiddify-core.dylib and the
packet tunnel referenced _kCFBundleNumericVersionKey, which Apple counts as a non-public API.
The reference comes from Chromium's base::mac::OuterBundleCachedInfoPlistData()
(info_plist_data.o) in the macOS libcronet.a that with_naive_outbound links. The iOS and
tvOS libraries do not have it.
Fix: protocol/naive/h_cronet_info_plist_darwin.go defines that function itself, returning
empty data, so the linker no longer takes info_plist_data.o from the archive. Only Chromium's
multi-process code (Mach port rendezvous, child-process code signature checks) calls it, and
Cronet runs inside the process, so naive works as before.
MASQUE: masque:// links from MahsaNG failed to load
Commit: pending
What happened: MahsaNG writes the device's key before the @ and the endpoint, with its
port, after it, and leaves the endpoint's key out. ray2sing only read a key parameter, so the
device had no key and the config failed with "private_key is not an ECDSA key"; and even with
the key, a device without endpoint_public_key was refused, and a port other than 443 was
dropped.
Fix: ray2sing reads that shape of link (key, endpoint and port, address, mtu,
keepalive, transport, sni, insecure, pin, ech, packets). The device's
endpoint_public_key defaults to the key of the fixed certificate Cloudflare's MASQUE edge
presents on every endpoint, the port of an endpoint written as ip:port is used, and
tls.certificate_public_key_sha256 pins the endpoint by the hash of its key, as the links do.
A link to WARP is a WARP config: when Cloudflare refuses the link's device (its key was never
enrolled, or the device was deleted), the outbound registers a device as the profile does,
saves it as the replacement and runs the tunnel with it.
DNS and routing: IPv6-first sites failed through a proxy without IPv6
Commit: bc0447ad8
What happened: Sites that publish AAAA records (Google, YouTube, most big CDNs) were reached over IPv6 whenever the device had it, through a proxy whose exit has no IPv6. The connection died on the server side, the browser never fell back in time, and the site did not open, while IPv4-only sites through the same proxy worked. Routing IPv6 away was no answer: traffic that is not routed leaks past the proxy.
Fix: Monitoring probes each outbound and endpoint, on its own, for IPv6 reachability and keeps
the answer in its URL-test history. A DNS rule with ipv6_by_outbound hands out no AAAA answers
while that outbound lacks IPv6, and a route rule with ipv6_unavailable_via matches the IPv6
destinations that would die there so they can be rejected at once. One proxy can carry IPv6 while
the next one does not; a proxy that gains IPv6 is used for it on the next run. See
IPv6 by outbound.
DNS: a hanging server in a sequential group failed every query
Commit: 900d60c40
What happened: A sequential group walked its servers from the first on every query and gave each one the rest of the query's time. A first server whose port the proxy's exit dropped hung for the whole DNS timeout, the query failed, and the servers behind it were never asked. With hiddify-core's remote group, where the chosen TCP server comes first, configs through such a proxy never resolved a name, while the same configs worked with a plain UDP address.
Fix: A server that fails is asked after the others for error_ttl, still asked when they
all fail, and forgotten when the network changes. Each server is waited for at most
member_timeout, or, unset, the query's time left shared among the servers still to ask, at
least 2 s each.
Monitoring: the selected proxy's ping arrived late
Commit: f7d6695cb
What happened: The first test cycle took the members fastest-first by last run's result,
so the proxy the user had selected waited behind every member sorted before it. Moving a
selector to another member tested only the outbounds routed through the selector, never the
new selection, so its ping waited for the next cycle. A test asked for (a tap, a selection)
went through the worker pool, behind a cycle of timeouts that holds every workers for up to
url_test_timeout each; its delay was stored only after the IP lookup that follows the URL
test, which takes as long again; and a proxy that did not answer showed nothing until the
whole timeout had passed.
Fix: Before the first cycle, the outbound each group's selection leads to (a selector's
pick, a balancer's member, through nested groups) is tested ahead of everything else, and a
selection change tests the new selection with priority. A test asked for runs at once, beside
the cycle's tests, in stages (on_demand_timeouts, 1 s, 3 s and 10 s unless set): the proxy
shows as unavailable for now at the first mark with no answer, or as soon as the first attempt
fails, and the first answer from any attempt replaces it at once, however late. Groups keep
the last confirmed result through such a miss. The delay is published as soon as the URL test
answers, the IP information when its lookup is done, and a priority result reaches the groups'
subscribers past debounce_window.
Monitoring: after a while nothing was tested any more
Commit: f7d6695cb
What happened: A URL test dials the outbound with a deadline in its context. An outbound whose dial ignores that deadline, as a dial into a native library may (cronet's naive dial, by the logs), never returned, so its worker waited for good, and so did the cycle waiting for that result. A few such tests, and every worker was held: no cycle, no priority test, no ping for any outbound, until the core was restarted.
Fix: A worker waits for one test at most twice its deadline plus url_test_timeout and
two seconds: the URL test, the IP lookup and one more test after a network reset, each with
its own deadline. Past that the test counts as failed, its late result is dropped if it ever
comes, and the worker goes on to the next test.
Security: every inbound listened on MPTCP
Commit: 9aca6c8c1
What happened: Go 1.24 and newer listen on Multipath TCP by default on Linux, and the Linux
kernel let a remote root listen on IPv4 MPTCP. The TCP listener and the dialer set MPTCP only
when tcp_multi_path is true and left the Go default otherwise, so every inbound of a build
without the multipathtcp=0 setting was an MPTCP listener. The sing-box binary had the
setting in its link flags; hiddify-core's Makefile had none, and its CI set a symbol that does
not exist (internal/godebug.defaultGODEBUG), so the app's cores listened on MPTCP.
Fix: The listener and the dialer set MPTCP off unless tcp_multi_path asks for it, and
both main modules carry godebug multipathtcp=0 in go.mod, which every build of them gets
without link flags, as go version -m of the binaries shows. A Linux-only test checks that an
inbound with the defaults accepts a plain TCP connection, not an MPTCP one. Update the Linux kernel as well: the kernel bug is not fixed by
this, and an inbound listening on all addresses (0.0.0.0) is reachable from the network in
any case.
Balancer: every test round moved the selection and cut every connection
Commit: d23f1250b
What happened: The lowest-delay strategy took the new minimum at every monitoring
update, with no tolerance (the option was accepted and ignored), and the balancer then closed
every connection through it, inbound ones included with interrupt_exist_connections. Two
servers with close delays swapped at every round, and during a ping of all servers the results
arrived one by one, so the selection moved and the connections were closed again every half
second. A voice call or a video dropped, and an app update being downloaded failed, while the
previous server was fine. The first cycle also sorted untested members (delay 0) ahead of the
cached ones, so the member that was fastest last run was tested last, and the group started on
the first member of the list instead of the cached best.
Fix: lowest-delay keeps its member while it answers, and moves only to a member faster by
more than tolerance (50 ms when unset), or when its member fails a test. Connections are
tracked per member; a member that becomes faster takes new connections only, a member that
dies has only its own connections closed, and 30 seconds after start the connections on the
members the group no longer uses are moved once, when a result of this run exists. The balancer
starts from the cached results, and the first cycle tests the cached fastest member first.
Tested with unit tests of the strategy, the per-member interrupt, the settle, the start from the
cache and the cycle order.
WireGuard noise: the reserved bytes were written into the noise packets
Commit: wireguard-go@3b861c636
What happened: A wireguard endpoint with no detour sends through the standard bind, whose
SendWithoutModify called Send, and Send writes the peer's reserved bytes (WARP's client id)
into bytes 1–3 of every packet. Noise packets are sent with SendWithoutModify to stay as they
are, but got the reserved bytes too: a QUIC template or <qi> Initial went out as
ce 57 3d 35 01 08 … instead of ce 00 00 00 01 08 …, a broken QUIC version, and every random
packet carried the same three bytes as the tunnel's own packets, an easy fingerprint. AmneziaWG
(awg) endpoints and endpoints behind a detour were not affected.
Fix: The standard bind writes the reserved bytes in Send only. Tested with a packet capture of
WARP configs using wnoise links, Clash amnezia-wg-option and sing-box jc/id/ip/ib: the
templates now go out byte for byte and the tunnels still connect.
XHTTP: the server leaked goroutines and connections
Commit: 0f0f6966e
What happened: On an XHTTP inbound, the download (GET) request hands its connection to the inbound, which reads the request header from the upload queue. Only closing the connection ends that read, and it was closed only after the inbound returned; the session cleanup is off once the download has started. A client that went away before uploading anything kept the handler, its connection and its session forever: servers grew to about 11,000 goroutines and 1,500 sockets in a week (hiddify-core#163). A full upload queue that nobody read could also stop the session's cleanup for good.
Fix: The connection is closed as soon as the download request ends, and a pending upload gives up when the queue closes. Tested with a client that leaves before uploading and with a full queue, which both hung without the fix, and against Xray 26.10.29 and 26.3.27.
AmneziaWG: an endpoint behind a detour that was not ready failed to start
Commit: df246df56
What happened: An awg endpoint with a single peer opens its connection to the peer when it
starts. When its detour was not ready yet, such as another WireGuard endpoint still starting,
that dial failed, and with it the start of the endpoint and of the whole config ("WireGuard is not
ready yet"). The warp endpoint's awg mode retried; a plain awg endpoint did not.
Fix: The endpoint starts without the connection and dials it again each second until the
detour lets it, then works as before. Tested with a dialer that fails its first dials, which
failed the start without the fix, and live with an awg endpoint through a WARP wireguard
endpoint.
Monitoring: an outbound behind a detour was tested before its detour
Commit: 79c0e9c00
What happened: An outbound that dials through another was tested once that one was merely connected; a selector detour passed when any member had a ping, even a cached one, or when every member was connected, which a selector over one SOCKS proxy always is. The Extra Security hop (Psiphon, WARP) was tested before the proxy it goes through had a result, so its ping and IP measured nothing. The check also stopped applying after the tenth test cycle.
Fix: It is tested once the outbound its detour leads to (the selected member, for a group) has a result of this run and is not being tested again, waiting at most 10 times 3 seconds. When that outbound works after failing, those tested through it are tested again. Tested with an outbound behind a selector, which was tested too early without the fix.
Monitoring: a cached IP was never looked up again
Commit: 9b7726a00
What happened: At start each outbound shows the last run's exit IP from the cache file. It was looked up again only while the outbound's result came from the cache, and the first test of the run, even a failed one, cleared that. The cached IP then stayed for the whole run, though the exit IP of WARP or Psiphon can change between runs.
Fix: URLTestHistory.IpInfoFromCache marks cached IP information, and each working test
looks it up until a lookup of this run succeeds. The app can show a cached IP as not confirmed.
Tested with a saved IP, a failed test, then a working one, which kept the old IP without the fix.
Monitoring: URL test did nothing for an outbound that was connecting
Commit: 8239116da
What happened: A URL test asked for while the outbound was still connecting (Psiphon) was skipped and queued again, but the outbound stayed marked as having a test queued, so the retry and every later URL test of it were dropped until the core restarted.
Fix: The retry clears the mark first. Tested with a URL test asked for while the outbound connects, then another, which never ran without the fix.
Psiphon: never connected, "domain not resolved"
Commit: 1a9f0b4f7
What happened: Psiphon (alone, or in the chain modes) never connected, on every platform. Since its own
connections go through a SOCKS bridge on the outbound's dialer (c82d7d86b), that dialer did not resolve
domains. On its first start Psiphon has no servers and fetches its server list from s3.amazonaws.com; each
fetch failed with "domain not resolved", so it never found a server ("no active tunnels").
Fix: The dialer resolves domains, or leaves them to the detour. Psiphon now connects in seconds. Tested that the outbound's dialer resolves domains, which it did not without the fix.
MASQUE: MASQUE in MASQUE never connected
Commit: 11a8c9737
What happened: With a MASQUE outbound behind another one, the inner tunnel dials its HTTP/2 endpoint as an
IPv4-mapped address ([::ffff:162.159.198.2]:443). The outer tunnel took it as IPv6 and, having only an IPv4
address, refused it with "missing IPv6 local address".
Fix: The tunnel's stack unmaps the destination before choosing the address family. Tested with an IPv4-only tunnel dialing an IPv4-mapped address, which it refused without the fix.
HTTP transport (HTTP/1.1): Trojan behind Xray's raw HTTP header answered 400
Commit: e29388e76
What happened: A Trojan outbound with the http transport over HTTP/1.1 (Xray's "raw TCP with HTTP
header", rawhttp in Hiddify Manager) failed about half the time with "v2ray-http: unexpected status: 400 Bad
Request" against an Xray server behind HAProxy. With the PROXY protocol, Xray's first read holds at most 256
bytes with that header, so 200 to 228 bytes of the request. Xray parses the HTTP header from it and fails when
the read ends in a header name; when the whole request fits in it, its Trojan inbound reads its request from
what is left of that read only and rejects a piece shorter than its 58-byte header. The headers are sent in
random order, so a short request from Hiddify fell in either case depending on the connection.
Fix: The request starts with a Cookie header (unless the configuration has one) whose value covers the
bytes where that first read can end, so the request is longer than it and the read always ends inside a value.
Tested with a short request, which fits in the first read without the fix.
AmneziaWG: the core could not stop behind a stalled detour
Commit: 96652cba6
What happened: Stopping the core hung for minutes with WARP in AmneziaWG mode behind a proxy (VMess over XHTTP here) whose stream had stalled. Each AmneziaWG send holds the device's network lock while it writes to the detour; the write never returned, and closing the device, which takes that lock to close the socket, waited for it. WARP's search for a port that answers hung the same way when it replaced the tunnel.
Fix: The device closes its detour connections before it closes, so the stuck write fails and the lock is released. Tested with a detour whose writes block, which fails without the fix.
WARP behind a URL test or balancer: torn down on every move
Commit: ce1ed43e8
What happened: When WARP's detour was a URL test or a balancer, every move of the group
closed the tunnel and opened a new one, just as a selector change does. A group that flips between
two close members (the lowest-delay one changes every few tests) cut WARP each time, and in auto
mode started a new race each time, though the old tunnel still worked.
Fix: A move the user made in a selector still applies at once. A move the group made by itself applies at most every 30 seconds (at once when the member left had failed, or WARP is not connected), through a new tunnel opened next to the one in use, which replaces it only once it connects. Tested with a URL test whose member fails, for every tunnel, live.
URL test and balancer: their moves reached monitoring late
Commit: 4b866b5d9
What happened: A URL test that moved to another member did not tell monitoring, so whatever followed the group (a tunnel through it, the app's proxy list) only saw the move at the next monitoring test. A balancer told only its own subscribers, and a follower of all groups could read the old member first.
Fix: Both signal monitoring when they move, as a selector does. Tested with a test that fails without the fix.
WARP auto mode: stuck on a tunnel that never connected
Commits: ae3ec6c67, 8db9e7ba2
What happened: Behind Extra Security, WARP in auto mode showed WARP · AWG ⏳ Connecting...
for good. The race picked the tunnel whose handshake answered first, here AmneziaWG, and kept it
although the tunnel itself never started: the AmneziaWG socket asked the detour for UDP to any
address (0.0.0.0), which a proxy whose UDP needs a destination refuses (400 Bad Request),
while the probe asked for UDP to the peer and got through. Changing the proxy in the selector only
reconnected the same tunnel.
Fix: With a single peer, AmneziaWG opens its UDP to that peer, as WireGuard does. Auto mode gives the chosen tunnel 20 seconds to connect and otherwise races again without it, and races again when the detour moves to another proxy (one without UDP then gets MASQUE over HTTP/2). Tested live through an HTTP proxy for every tunnel, and with a move to SOCKS4, which has no UDP.
Psiphon: dead after its tunnel core stopped, and only one at a time
Commit: 3c5e93945
What happened: When Psiphon's tunnel core gave up (after establish_tunnel_timeout, for
example on a network where Psiphon was blocked for a while) or failed, the outbound stayed dead
until the app restarted. A second Psiphon outbound could not start next to the first, and two
would have written the same server list files.
Fix: A stopped tunnel core is started again, after 5 seconds and up to every 5 minutes. Several Psiphon outbounds run at once: each has its own data folder, and each checks its own tunnel before it shows as connected, since the tunnel core's notices do not say which outbound they are about.
Psiphon: shown as connecting with a working tunnel
Commit: df13d433d
What happened: Psiphon showed "Connecting..." while traffic and URL tests went through it. Its connected state came only from the Tunnels notices, through the single notice writer that psiphon-tunnel-core has for the whole process. Each Psiphon set its own writer and reset it when it closed, so one started while another was closing (an app reload) lost the writer and never saw its notice; a second one could not set a writer at all.
Fix: One notice writer is installed for the process and passes every notice to each running Psiphon; it is reset only when the last one stops. A connection through the tunnel counts too: the Psiphon controller only dials while a tunnel is up, and its "no active tunnels" error marks it as not connected.
WARP scanner: probes did not send what the device sends
Commit: 28ff47e9b
What happened: The scanner judges an endpoint by a handshake made after the same noise the
device sends. But in the default noise mode the probe left out the short header the device puts
before each fake packet, and with awg it sent the WireGuard noise instead of the AmneziaWG
junk and special packets. An endpoint could then answer the probe and not the device, or the
other way round.
Fix: The device and the probe build the fake packets with the same code, and with awg the
probe sends i1–i5 and the jc junk packets. Tested with every noise preset against Cloudflare.
Shadowsocks: QUIC protocols never connected through it
Commit: 1e2ddd7fd
What happened: Hysteria2, and any other QUIC protocol, through a Shadowsocks detour never connected. QUIC reads each packet into a buffer of about one packet, the Shadowsocks client decrypts in place in that buffer, and a full packet with its salt and tag did not fit. It failed with "cipher: message authentication failed", and the QUIC connection ended.
Fix: The Shadowsocks client reads each packet whole and copies it. Tested with Hysteria2 through Shadowsocks, VLESS and Trojan; only Shadowsocks failed before.
WARP: background work outlived the box, and AmneziaWG through a cold detour never started
Commit: 79d187b08
What happened: The box closes each endpoint's scope, not the endpoint, so WARP's port rotation, failover and their goroutines kept running after the box closed, and tried to start replacements that failed with "context canceled". Separately, AmneziaWG opens its socket when it starts, and fails when the detour does not work yet (a group still on a dead member); WARP then gave up and stayed "endpoint not initialized" for good, even after the detour recovered.
Fix: WARP's background work stops when its scope closes. The first start of the tunnel is retried with a backoff (1 s, doubling, up to 30 s), like the registration. Tested live through a selector that moves from a dead member to a working one, and with a unit test that fails without the fix.
MASQUE: a tunnel set up while the outbound closed stayed open
Commit: ff42383d6
What happened: Closing a masque outbound whose tunnel was still being set up returned
"tunnel not initialized", and the tunnel, once set up, was never closed. The profile's endpoint
addresses were also cut out of ip:port by fixed offsets, which breaks on any other port.
Fix: Close ends a registration in progress, and a tunnel set up meanwhile is closed when its setup ends. The endpoints are parsed as addresses with a port.
masque-client: shown as connected before it reached the server
Commit: 85cff7f36
What happened: The upstream masque-client endpoint used the default ready state, so it
showed as connected while it had no session with the server.
Fix: It is ready only while a session with the server is up. An on_demand endpoint, which
connects when it is used, is always ready.
Psiphon: connected state raced, and a notice could crash the process
Commit: 7a85e7be9
What happened: The connected flag was written from a new goroutine for each notice and read
without a lock. It was only ever set, never cleared when the tunnel count dropped to zero. A
Tunnels notice whose count was not a JSON number panicked on a type assertion.
Fix: Notices are handled in order, the state is atomic and follows the tunnel count, and a count of any other type is ignored.
Monitoring: a selection reached the app late, and chained outbounds were tested too early
Commit: af68dde0a
What happened: A selection change waited for the debounce meant for bursts of test results before the group's subscribers saw it, and a subscriber busy with the last update could miss it. An outbound behind a group detour was tested before any member of that group worked, so a chained WARP was marked dead at start.
Fix: A selection is published at once, to the group, its parents and the catch-all group, with room for 8 pending events. An outbound whose detour is not ready is not tested yet, for the first 10 cycles.
Sudoku: a large transfer to a slow reader reset the stream
Commit: 16096efa2
What happened: The Sudoku mux had no flow control. Once a stream held 4 MiB its reader had not read, the next frame reset the stream with "mux receive queue full". The mux's own test failed in 61 of 900 runs.
Fix: The mux waits for the reader to make room, which holds the session and so the sender. 900 of 900 runs pass.
MASQUE: HTTP/3 never connected through Shadowsocks, VLESS or Trojan
Commit: 1f44631b9
What happened: WARP over MASQUE with HTTP/3 worked through a SOCKS detour only. QUIC reads each packet into a buffer of about one packet, but a proxy that decrypts in place needs room for its own header and tag too. A full packet was cut off, failed to decrypt ("cipher: message authentication failed"), and the read error ended the QUIC connection.
Fix: Each packet is read whole from a proxy's packet connection and then copied, in the tunnel and in the scanner's probes. Tested live through SOCKS, Shadowsocks, VLESS and Trojan.
Psiphon: every binary crashed at start when built with Go 1.27
Commit: bb18926a9
What happened: Go 1.27 added a field to tls.ConnectionState. The psiphon-tls fork checks at
start that its own copy has the same layout, and panicked, so any program with Psiphon crashed
before doing anything.
Fix: The fork keeps one copy of the struct per Go version. Built and tested with Go 1.27.3 and 1.26.3.
Shadowsocks simple-obfs: obfs_host was not checked
Commit: 357a0ec54
What happened: The Shadowsocks inbound accepted obfs_host but never compared it with the host
the client sent, so any host passed the simple-obfs handshake.
Fix: When obfs_host is set (one host or a comma-separated list), a client whose HTTP Host
or TLS server name is not listed is treated as not simple-obfs. It goes to obfs_fallback, or is
closed. Tested over HTTP and TLS obfs, with a listed and an unlisted host.
DNS: bypass_if_failed ignored an empty answer in legacy mode
Commit: 0e67074d9
What happened: In legacy DNS mode, a rule with bypass_if_failed moved on to the next rule
only when the query failed. An A or AAAA query that came back with no address stopped there,
although a lookup already treated the same empty answer as a failure.
Fix: An address query with no address in the answer now counts as failed and moves on to the next rule, as it does for lookups.
WARP: WireGuard through a detour often never connected
Commit: ce64e6017
What happened: WARP over WireGuard behind a detour (for example Extra Security) often never connected. WireGuard sent the handshake to the profile's IPv4 and IPv6 addresses through one UDP association, and a detour without IPv6 closed it. Without the scanner, a profile port that did not answer was also used for good. WARP and MASQUE showed as connected as soon as they were set up, even when nothing got through.
Fix: WARP uses the profile's IPv4 peer address and moves to the next port when one completes no handshake within 10 seconds. WARP counts as connected only while a handshake is less than 3 minutes old, kept fresh by a 25-second keepalive; MASQUE only while it has a connection to the server. Tested live through a working and a dead detour, for WireGuard, AmneziaWG, and MASQUE over HTTP/3 and HTTP/2.
WireGuard legacy outbound: network was ignored
Commit: 2ab7b4351
What happened: The wireguard_legacy outbound accepted network, but always offered TCP and
UDP.
Fix: The outbound offers the networks in network, as upstream's old outbound did. Ping is
always offered.
Sudoku: fallback did nothing
Commit: d53e3c37f
What happened: The Sudoku inbound accepted fallback but did not use it. A connection that
was not Sudoku was closed.
Fix: A connection whose handshake fails is passed to the fallback address with the bytes it
already sent, so a prober sees the site there. A connection that sends more than 64 KiB before
its handshake fails is closed.
Call: an unknown wbstream mode never connected
Commit: 7d6d0ef26, 88d4af4c2
What happened: With "platform": "wbstream", a mode other than dc or video was accepted,
but the tunnel never connected.
Fix: The config fails to load with "call: unknown wbstream mode …, use dc or video".
Call: route rules stopped after a reconnect, and a failed connect was final
Commit: 3f48fb169
What happened: On vk, telemost and dion, after the call reconnected, the server side
opened connections itself, past the router, so route rules no longer applied. The inbound and
outbound connected once, and after a failure did not try again until sing-box restarted. On
dion, email and password without cookies failed with "empty cookie string". The bridge and
Telemost's active bridge were read and written by several goroutines without a lock.
Fix: After a reconnect, connections go to the router again. Both sides try again after 5
seconds, 10 seconds, 30 seconds, then every minute until sing-box stops. On dion, email and
password alone log in. The bridges are read and written safely.
TLS: a tls_fragment in the tls block did nothing
Commit: a29c81702
What happened: Old editor templates put tls_fragment inside the outbound's tls block.
sing-box accepted it there and did not apply it.
Fix: An enabled tls_fragment in the tls block is moved to the outbound's dial fields,
where it applies. When the dial fields have their own enabled tls_fragment, that one is used.
WARP MASQUE: renaming the outbound registered a new device
Commit: a6ca1672a
What happened: The MASQUE profile and scanner scores were saved under the outbound's tag, so
renaming the outbound registered a new device with Cloudflare. With profile.detour set and no
outbound manager available, the outbound crashed on a nil pointer.
Fix: The new unique_identifier field names the saved profile and scores. When it is empty,
the tag is used, as for warp. A missing outbound manager is reported as an error.
DNSTT: DNS queries could loop back into the VPN
Commit: 8f5156c41
What happened: The DNSTT client opens its resolver sockets itself, and they did not get
sing-box's socket settings. bind_interface, routing_mark and the platform's socket protection
were not applied, so on a VPN the DNS queries could loop back into the tunnel.
Fix: The resolver sockets get the outbound's socket settings. detour, which the DNSTT client
cannot use, logs a warning.
DNSTT: record-type did not change the tunnel
Commit: 474f382e5
What happened: record-type only named the saved resolver list. The tunnel always asked for
TXT records.
Fix: record-type selects the record type of the data the server sends back: txt, cname,
a, aaaa, mx, ns or srv. The server must use the same type.
DNSCrypt: a stamp of another type failed only at the first query
Commit: 219cd3dcc
What happened: The sdns server takes only DNSCrypt stamps, but a DoH, DoT or plain DNS stamp
loaded and failed only at its first query.
Fix: Such a stamp fails when the config loads, and the error says to use the https, tls
or udp server type.
SSH inbound: host_key_algorithms, network and deadlines were ignored
Commit: 0ebdf81b7
What happened: The SSH inbound read host_key_algorithms and network but used neither.
Forwarded connections ignored read and write deadlines.
Fix: host_key_algorithms limits the host keys. An RSA key signs only with the listed
rsa-sha2-512, rsa-sha2-256 or ssh-rsa. Another key is used only when its type is listed. If
no host key is left, the config fails to load. A network without udp turns UDP over TCP off.
Deadlines work on forwarded connections.
Balancer: an unset delay_acceptable_ratio left no usable member
Commit: 3943213db
What happened: Unset, delay_acceptable_ratio was 0, so no member was within the acceptable
delay. round-robin used only the fastest member, and consistent-hashing and sticky-sessions
treated every member as dead.
Fix: Unset or 0 means 2, the value the Hiddify app sends.
AmneziaWG: the first packet after setting s4 was dropped
Commit: 40dfde705
What happened: After s4 was set, the first data packet went out with the old padding, and
the server dropped it.
Fix: The bundled amneziawg-go is updated with the fix.
Reports: archives held sensitive data
Commit: 37d5006d1
What happened: Report archives are not encrypted, but when the app asked for encryption they
were named .age. They held the configuration, the open connections of OOM reports, and logs
with addresses, links and secrets. The metadata held the full process path, which can contain
the user name.
Fix: Archives are always named .zip. They leave out the configuration and the open
connections. Logs and other text files are scrubbed of IP addresses, proxy links and DNS stamps,
URL paths and queries, UUIDs, emails and key=value secrets. The metadata keeps only the process
name.
Call: a Telemost relay failure stopped the app, and VK logged the join token
Commit: e727940f5
What happened: When the Telemost relay failed to start, the whole process exited. On vk,
joining an existing call wrote the call's join token, part of the tunnel key, to the log.
Fix: The Telemost error is logged and the process keeps running. The join token is no longer logged.
WireGuard: an awg block was silently ignored
Commit: 3fa692844
What happened: A wireguard endpoint accepted an awg block but never used it. The
endpoint ran plain WireGuard while the config looked obfuscated.
Fix: Such a config fails to start and says to use "type": "awg".
AmneziaWG: s3, s4 and h ranges stopped the endpoint
Commit: ee5e66490
What happened: The bundled amneziawg-go was older than AmneziaWG 2.0. A config with s3,
s4 or a range in h1–h4, as AmneziaWG 2.0 servers give out, failed to start with "invalid
UAPI device key: s3".
Fix: The endpoint uses amneziawg-go 3.1, which accepts these values and adds the 3.x settings.
DNSCrypt: each query fetched the resolver certificate again
Commit: 3e475bb96
What happened: The sdns server fetched the resolver certificate before every query, so each
lookup took two round trips.
Fix: The certificate is kept until it expires, a query fails, or the network changes.
XHTTP: auto mode did not match Xray's client
Commit: a4310256e
What happened: With mode set to auto, the client always used packet-up. Xray's client
uses stream-one over REALITY, so a server that allows only stream-one refused a Hiddify
client left on auto, while an Xray client got through.
Fix: auto picks the mode as Xray's client does: stream-one over REALITY, stream-up over
REALITY with a separate download, and packet-up otherwise. Tested against Xray 26.10.21 and
26.3.27.
URL test: a canceled unified delay test kept running
Commit: 48dd50df5
What happened: With unified delay, a test makes two requests. The second one did not stop when the test was canceled, so it could run on for 15 seconds. A cancel between the two requests was reported as a success.
Fix: The second request stops on cancel, and a canceled test reports an error.
SSH: UDP was sent to an outbound that cannot carry it
Commit: a4afbdedf
What happened: The SSH outbound offered UDP by default, but SSH carries UDP only with
udp_over_tcp. Without it, the router sent UDP to the outbound and every UDP connection failed.
Fix: The outbound offers UDP only when udp_over_tcp is on, so UDP goes to another outbound.
SSH: one bad forward request closed the whole connection
Commit: ea5c6b5be
What happened: When the SSH inbound got a port-forward request it could not read, it closed the whole SSH connection, with every other forward on it.
Fix: Only that request is refused; the connection and its other forwards go on.
Balancer: connections closed on every test round
Commit: 0e4c6636e
What happened: With consistent-hashing or sticky-sessions and
interrupt_exist_connections, the balancer closed all its connections after every monitoring
update, even when nothing changed.
Fix: Connections are closed only when the set of usable members changes.
Rule sets: a failed download was retried only after a day
Commit: d918f8167
What happened: A remote rule set with no cached copy starts empty and is downloaded once the
service is up. When that download failed, for example because the proxy was still connecting,
the next try came only after update_interval, a day by default. Until then the rules in it did
not apply.
Fix: A failed download is retried after 1 s, 10 s, 30 s, 1 min, 5 min and 10 min, then every 30 min until it succeeds. A cached copy stays in use meanwhile.
XHTTP: seq in the path was lost when the session was elsewhere
Commit: a22fe417c
What happened: The XHTTP server read the path only when both session_placement and
seq_placement were path. With the session in the query, a header or a cookie and the seq in
the path, every packet-up request lost its seq, so the upload did not work.
Fix: As in Xray, the session and the seq each take the next path segment when they are placed in the path, so seq in the path works wherever the session is.
XHTTP: the padding generator could loop forever
Commit: ee39f4889
What happened: The random padding string never finished for a character set whose size divides 256, because every random byte was rejected. The padding uses a 62-character set, so no current config hit this.
Fix: The limit no longer wraps around, so the generator ends for any character set.
Shadowsocks obfs: the HTTP server's Date header looked fake
Commit: 4aef1b3ca
What happened: The HTTP obfs server wrote the Date header in the server's local time zone. A
real nginx always sends GMT, so the response was easy to tell from a real web server.
Fix: The Date header is sent in GMT, in the standard HTTP format.
MASQUE: an IPv6 bind address in a port mapping was rejected
Commit: 9e374aaf0
What happened: A port mapping in the documented [::1]:8080:host:port form was always
rejected, because the colons inside the IPv6 address were treated as separators. No Hiddify
option uses port mappings yet, so no config was affected.
Fix: A bracketed IPv6 bind address is read as one part, and it resolves correctly.
MASQUE: UDP to IPv6 addresses failed
Commit: db20461ff
What happened: UDP to an IPv6 destination through MASQUE was sent from the tunnel's IPv4 address, so it failed.
Fix: UDP to an IPv6 destination uses the tunnel's IPv6 address.
VLESS: the Vision wrapper could skip a layer below it
Commit: f9eca8b90
What happened: The Vision wrapper told the copy loop it could read and write the TLS connection directly, even when an encryption layer or a transport sat between them. That could have sent data past that layer. The Vision connection above it already prevents this, so no traffic was affected.
Fix: The wrapper allows the shortcut only when the connection below it does.
VLESS: padding rules in encryption were read as keys
Commit: dcbc72095
What happened: A padding rule such as 100-111-1111 in a VLESS encryption or decryption
string is also valid base64. It was decoded as a key and rejected with an invalid key length, so
the node did not start.
Fix: In the padding part of the string, a segment of digits and dashes is read as a padding rule.
SSH: a UDP-over-TCP session never closed when idle
Commit: a1f14b533
What happened: With udp_over_tcp, each UDP connection was counted twice on the SSH session,
and one count was never released. The session always looked busy, so it stayed open after it
went idle.
Fix: A UDP-over-TCP connection is counted once, and an idle session closes as expected.
SSH: the inbound ignored server_version
Commit: 97539d44c
What happened: The SSH inbound read server_version but never used it, so it always
announced the Go library's default version.
Fix: The server announces server_version. A bare name such as OpenSSH_9.6 gets the
required SSH-2.0- prefix.
DNSTT: resolver tests left connections open
Commit: f3b9ea0b3
What happened: Each test of a DoH, DoT or TCP resolver opened a connection and never closed it. A resolver that never answered could also hold the test forever.
Fix: Each test closes its connection and gives up after 10 seconds.
DNSTT: a failed resolver connection crashed the test
Commit: 2048c1b84
What happened: When the connection to a DoH, DoT or TCP resolver could not be opened, the resolver test ignored the error and crashed.
Fix: The test returns the connection error.
AmneziaWG: an IPv6 peer address was rejected
Commit: c59c8eb37
What happened: A peer at an IPv6 address was passed to the device as 2001:db8::1:51820,
without brackets. The device rejected it, so the endpoint could not reach that peer.
Fix: An IPv6 peer address is written in brackets, as [2001:db8::1]:51820.
Clash API: a balancer's connection showed the wrong chain
Commit: 6bf0372a9
What happened: For a connection through a balancer, the connections list built a chain with the outbound the balancer chose, but then showed the original chain. The chosen outbound did not appear.
Fix: The connections list shows the chain with the outbound the balancer chose.
DNS hosts: sslip.io names did not resolve
Commit: 68a7c5d30
What happened: The hosts DNS server never resolved sslip.io names, because the name in a DNS
query ends with a dot. An IPv6 name such as 2001-db8-0-0-0-0-0-1.sslip.io could also be read as
the IPv4 address 0.0.0.1.
Fix: The trailing dot is removed before matching. IPv6 is tried first, and each pattern starts at the beginning of a label.
SDNS: the server had no tag or type
Commit: 8affe038b
What happened: An sdns DNS server was created without its tag and type. Its detour
outbound was also not marked as something it needs, so it was not sure to start first.
Fix: The sdns server gets its tag and type, and its detour is started before it.
Xray helpers: a range number too large was changed silently
Commit: 164930d71
What happened: A range field such as x_padding_bytes, given as a number too large for it,
was silently cut down to a different value. For example, 9999999999 became 1410065407. The same
value as a string was rejected.
Fix: A number out of range is rejected, as in the string form.
Xray helpers: a UUID of the wrong length could crash
Commit: b291486b7
What happened: Reading a UUID from 16 raw bytes crashed when the length was wrong, instead of returning an error. Nothing in Hiddify uses this yet, so no config was affected.
Fix: A wrong length returns an error that gives the length.
Xray helpers: the buffered writer stopped when its buffer filled
Commit: 8e2f9b3e5
What happened: The Xray buffered writer stopped with a "buffer full" error when the data was larger than its free space, instead of sending the buffer and writing the rest. Nothing in Hiddify uses this writer yet, so no traffic was affected.
Fix: A full buffer is sent and the writer goes on with the rest of the data.
URL test: a canceled test was reported as 0 ms
Commit: b160c990b
What happened: When a URL test was canceled after its connection opened, it returned no delay and no error. It was recorded as a successful test with a 0 ms delay.
Fix: A canceled test is reported as an error.
WARP: a bad device id crashed license registration
Commit: 9b503de83
What happened: Adding a WARP license to a new device built the request before checking that
it was valid. A device id with a control character in it crashed the app instead of returning an
error.
Fix: The request error is checked first and returned.
WARP: a device given by id and auth_token could not start
Commit: 0b03e9f80
What happened: A warp profile with id and auth_token fetched the existing device from
Cloudflare, but the API does not return the private key, so the endpoint had no key and did not
start.
Fix: That path now takes the device's key from profile.private_key, checks it against the
device's public key, and refuses to start without it.
Tunnel: override_tunnel_destination did nothing
Commit: 3f3c7eece
What happened: The override_tunnel_destination route option was read but never set on the
connection. Nothing else sets a tunnel destination, so the tunnel_server endpoint refused every
connection and the reverse tunnel could not carry traffic.
Fix: The option now sets the connection's tunnel destination.
Tunnel: an unknown user froze the server
Commit: 641b3fc9e
What happened: When a connection was for a user the tunnel_server endpoint did not know, the
endpoint returned an error but kept its lock held. Every later connection through it then hung.
The error also named the sender instead of the unknown user.
Fix: The lock is released on that path, and the error names the unknown user.
MASQUE: use_ipv6 stopped the outbound from starting
Commit: 8fe3e948b
What happened: The profile has no IPv6 HTTP/2 endpoint. Since vhttp auto became the
default, a MASQUE outbound with use_ipv6 looked one up, failed and never started.
Fix: auto uses the scanner's IPv6 HTTP/2 endpoint when it finds one, and otherwise stays on
HTTP/3.
DNS group: a poisoned CNAME or a server failure could win
Commit: 46df8123e
What happened: A DNS group accepted the first response that still had records after
ignore_ranges. A poisoned answer made of a CNAME and a blocked address kept its CNAME, so it won
with no address in it. A SERVFAIL or REFUSED response was kept as the fallback answer, so when no
server had an address the group could return it instead of another server's NXDOMAIN.
Fix: An A or AAAA query needs an address in the answer, so a CNAME left alone is not enough. SERVFAIL and REFUSED count as failures, and the group goes on to the next server. NXDOMAIN is still returned when no server has an answer.
MASQUE: closing the tunnel did not stop it
Commit: 130720144
What happened: Closing the WARP MASQUE outbound (on a reload or shutdown) did not stop its tunnel. While it was connecting, closing blocked for good. After closing, it kept reading the closed device in a tight loop, filling the log with "failed to read from TUN device" and using CPU until the service stopped.
Fix: Closing the tunnel stops a connect in progress and both of its loops.
REALITY: fragment and record_fragment did nothing
Commit: 59a414925
What happened: tls.fragment and tls.record_fragment were accepted on REALITY nodes but
never applied, so the ClientHello always went out whole. With the post-quantum key share it is
about 1.7 KB, two TCP segments, which some networks drop.
Fix: Both options now apply to REALITY as to the other TLS clients. Tested against Xray 26.10.21 and 26.3.27.
WARP: network changes were not passed on
Commit: ab47a78e6
What happened: The warp endpoint did not pass network changes to the WireGuard endpoint
inside it, so after moving between networks it kept its old socket.
Fix: Network changes reach the inner endpoint, which rebinds its socket.
XHTTP: obfs mode padding outside the Referer was rejected
Commit: 17c771dd4
What happened: The XHTTP server read the padding only from the Referer header or the query.
With x_padding_obfs_mode and another placement (a header, a cookie, or a custom header), every
request was rejected with 400, so an Xray or sing-box client using those settings could not connect.
Fix: The server reads and checks the padding where the configured placement puts it, as Xray does. Tested against Xray 26.10.21 and 26.3.27.
XHTTP: data races
Commit: 0493e3f8d
What happened: Four pieces of XHTTP state were shared between goroutines without synchronization: the download stream's reader, a buffer's length read after it was handed off, the upload queue's closed flag and reader, and the connection addresses. Under load these could corrupt data or crash the process.
Fix: Each is now guarded or passed atomically.
gVisor: a failed TCP handshake could crash the process
Commit: 31947f874 (gVisor fork d811af3)
What happened: In the gVisor network stack, a TCP segment that arrived just after a handshake failed, while the connection was being closed, used state that had already been cleared. The whole process crashed. The gVisor TUN stack and the MASQUE and AmneziaWG endpoints use this stack.
Fix: Such a segment is ignored, and closing the connection cleans up. gVisor is now forked
under replace/gvisor.
TUN: new TCP connections refused after a listener error
Commit: de0aae2c2 (sing-tun fork abfac68)
What happened: The system TUN stack hands each new TCP connection to a local listener. Any error while accepting, even a short-lived one such as running out of file descriptors, stopped the listener's loop for good. Every new TCP connection was then refused until the VPN restarted, while UDP kept working.
Fix: Short-lived errors are waited out, and a listener that died is recreated. sing-tun is now
forked under replace/sing-tun.
WireGuard: recovery after the network goes bad
Commits: 386158d33 and 31c0f5732 (wireguard-go fork f191d67, b46c414, a7a59c8, 6f870fd,
5e21bd6, a6fcdc9)
What happened:
- After the device slept, or when DPI flagged a connection, handshakes kept failing from the same source port, and WireGuard and WARP did not recover until the network changed.
- A receive routine that met a lasting socket error stopped, and nothing restarted it.
- WireGuard did not start at all on a host or interface without IPv6.
- Noise (fake) packets: only the first one was sent, the start of each was cut off, and the top of every configured range (count, size and delay) was never used.
Fix:
- After failed handshakes, the socket is reopened on a new port. This happens once the handshake
cycle gives up, or after 3 unanswered attempts when the session is already dead, at most once
every 90 seconds. A configured
listen_portis kept. - A receive routine that stops reopens the socket.
- IPv4 is kept when IPv6 cannot be opened.
- All noise packets are sent whole, and ranges include their upper bound.
Local DNS on Linux: no fallback when systemd-resolved is incompatible
Commit: 53aa823c9
What happened: On Linux, the local DNS server failed outright when the systemd-resolved
service lacked a method it needs.
Fix: It falls back to /etc/resolv.conf, and returns to systemd-resolved once it works.
MASQUE: no fallback from HTTP/3 when UDP is blocked
Commit: 787f27a51
What happened: When UDP was blocked, the HTTP/3 attempt used up the whole connection timeout, so there was no time left to fall back to HTTP/2. A session that failed on HTTP/3 also kept retrying HTTP/3.
Fix: The HTTP/3 attempt gets at most 5 seconds (or half the time left), and a failed HTTP/3 session makes the next attempt fall back.
Tailscale: control connection over port 80
Commit: a210bd114
What happened: Tailscale's control connection first tried a plaintext HTTP upgrade on port 80, which filtered networks intercept or recognize, and moved to port 443 only after a delay.
Fix: It goes straight to port 443. Set TS_FORCE_NOISE_443=false to restore the old behavior.
Crash reports: unbounded, and they carried the configuration
Commit: 1017225d5
What happened: Every crash added a report that was never removed. Each report also carried a copy of the configuration, including server addresses, keys and passwords, although reports are made to be shared.
Fix: The newest 32 reports of each kind are kept, up to 64 MiB. Reports no longer include the configuration, and an old copy is removed.
SOCKS: UDP never reached servers that answer with 0.0.0.0
Commit: 70d6601fd
What happened: Many SOCKS5 servers answer a UDP request with the address 0.0.0.0, meaning
"the address you reached me on". sing-box sent UDP to that address, which is the local machine.
Fix: It sends UDP to the server's address instead.
WireGuard: packets larger than the path were dropped
Commit: 1ed324a7b
What happened: sing-box marks outgoing UDP as "do not fragment". A WireGuard packet larger than the network path allows, as in a tunnel inside another tunnel, was dropped, and large transfers stalled.
Fix: The system may fragment WireGuard packets, as official WireGuard allows. udp_fragment
still overrides it.
WireGuard: a stuck detour froze the tunnel
Commits: 304bde1fd, dd032dfd7
What happened: WireGuard's connection through a detour was dialed while holding the lock that
sending and receiving wait on, with no time limit. A detour that hung froze the whole tunnel. The
same code also had a data race.
Fix: That dial gives up after 15 seconds and is retried, and the race is fixed.
WireGuard, AmneziaWG, MASQUE: slow failure for unreachable hosts
Commit: b1d063442
What happened: A TCP connection through these tunnels to a host that never answers waited one to two minutes before failing, against 15 seconds for a direct connection.
Fix: It fails after 15 seconds.
Clash API: FakeIP flush did nothing
Commit: 3949d2479
What happened: The Clash API's FakeIP flush cleared only the cache file. With the default settings, FakeIP mappings live in memory, so nothing was cleared.
Fix: The flush clears the FakeIP store in use and restarts address allocation.
HTTP transport: client cancellations logged as errors
Commit: 0ac410ae9
What happened: On the server side of the HTTP transport, a client that cancelled its stream was logged as a failure.
Fix: It is treated as a closed connection.
Shadowsocks 2022: "bad request salt"
Commit: fb981ebba
What happened: With Shadowsocks 2022, a reply could be read before the first upload finished. The connection then failed with "bad request salt", most often when the first upload was large.
Fix: The request header is written while connecting, before the connection is used.
Cache file: entries lost when saving failed
Commit: 6927625a1
What happened: When the cache file could not be written (a full disk, a read-only file), the pending DNS cache and FakeIP entries were dropped from memory as well.
Fix: They are kept and saved at the next attempt.
DNS: duplicate queries after a failure
Commit: 42acc7903
What happened: Identical DNS queries wait for the one already sent. When that query failed, every waiting query went to the server separately.
Fix: The waiting queries share one new query.
DNS outbound: connections never closed
Commit: dfb54ab49
What happened: A DNS session through the dns outbound never reported that it ended. The
connection stayed in the connection list and statistics, and some inbounds waited for it until
shutdown.
Fix: The end of the session is reported.
Routing: UDP sessions leaked after a routing error
Commit: 7ea28f847
What happened: When a UDP session from Shadowsocks, multiplex or UDP-over-TCP could not be routed, the code handling it waited until shutdown.
Fix: It returns after the error.
Startup held by a slow multiplex connection
Commit: 0cb109659
What happened: Starting the service could wait up to 15 seconds for a multiplexed connection being opened through a slow or dead server.
Fix: That work happens after startup.
XHTTP: deadlines silently ignored
Commits: 6534ce882, 1288cc7c6
What happened: XHTTP connections accepted time limits and ignored them, so a wait on a silent peer never ended. Closing a connection also lost the download side's error.
Fix: XHTTP connections report that they need help with time limits, and the router adds it, as for gRPC and HTTP. The error is returned.
VLESS encryption: hang on a silent server
Commit: 3db8eb988
What happened: With VLESS encryption, a server that accepted the connection and never answered held the connection, and any URL test through it, forever.
Fix: The handshake stops when its time limit ends.
URL test: tests kept running after a reload
Commit: 6360e3afb
What happened: After a configuration reload, the old URL test group's tests kept running for up to 15 seconds and wrote results for outbounds that might no longer exist.
Fix: Closing the group stops its tests.
TUN: DNS stalled the whole tunnel
Commit: d098fded1
What happened: DNS queries caught by the TUN were handled on the tunnel's packet loop. With a DNS server behind a dead proxy, each query held every packet of the tunnel for 10 seconds.
Fix: Each query is handled separately, at most 256 at once.
libbox: Connections was not safe for concurrent use
Commit: 767a337ef
What happened: A platform app applies connection events on its command client's thread, and
its UI filters, sorts and lists the same Connections object on the main thread. Nothing guarded
the map and slices they share, so the connection list could crash the app or show broken entries.
Fix: Every exported method of Connections now takes a lock.
Balancer: sticky sessions landed on dead members
Commit: 299f7c824
What happened: When the member remembered for a session was dead, sticky-sessions picked
again with a seed that was often identical on every retry. After the last retry it used the pick
without checking it, so about half of the new sessions went to a dead member.
Fix: Each retry draws with a new seed. If no retry finds a live member, the first live member is used. A dead member is used only when none is alive.
Balancer: untested members counted as the fastest ones
Commit: 26038512b
What happened: consistent-hashing and sticky-sessions read the raw test delay. A member that
was never tested has a delay of 0, so it looked like the fastest member and got traffic. A delay
restored from the cache was trusted as if it were fresh.
Fix: These strategies now use the same adjusted delay as lowest-delay and round-robin. An
untested member counts as timed out, and a cached delay gets a penalty.
Balancer: a large delay ratio overflowed
Commit: 9352492aa
What happened: The largest acceptable delay is the smallest delay × delay_acceptable_ratio.
It was stored in a 16-bit number, which wraps past 65,535 ms. A cached delay gets a 20,000 ms
penalty, so with a ratio of 3 two cached members gave a limit of about 9,000 ms, and round-robin
then used only one of them.
Fix: The limit is computed in one place and capped just below the timeout delay. A timed-out member is still never accepted.
Balancer: round-robin reported no selected member
Commit: c4c581f38
What happened: A round-robin balancer always reported an empty current member, so the app and
the Clash API showed nothing selected.
Fix: It reports the member the next TCP connection goes to.
Balancer: closing did not stop the worker
Commit: e3a6b22fa
What happened: Close did nothing, so the worker that follows URL-test results kept running
until the whole service stopped. After a configuration reload, the old balancer kept updating
itself.
Fix: Close stops the worker.
SSH: no background connection after start
Commit: 69fa3a66e
What happened: The SSH outbound connects in the background after start and reconnects when the connection drops, so it is ready and tested before any traffic. After the move to sing-box v1.15's startup stages, this step was never run. The outbound stayed not ready, and the URL test skipped it, until traffic arrived.
Fix: The background connection starts again once the service has started.
Startup: some outbounds were never started, and the balancer crashed
Commit: ac35f63e4
What happened: sing-box v1.15 starts components in stages. Outbounds written for the older
startup (balancer, psiphon, dnstt and gooserelay) were only closed at shutdown and never
started. A balancer that was never started has no strategy, and its first connection crashed the
whole process.
Fix: These outbounds go through the startup stages again: preparation, start, and the step after the service has started.
WARP: an incomplete profile crashed the process
Commit: 35779caa3
What happened: The WARP endpoint starts in the background, so any crash there takes the whole process down. Three cases crashed it:
- the profile request failed and there was no outbound to retry through;
- the profile (cached, written in the config, or from the API) had no peers or no ports;
- the profile had no interface addresses.
Fix: Each case now reports an error. The peer address and port are picked with checks, in this
order: the configured server and server_port, a port listed in the profile, or the port in the
host name. A missing address family is skipped.
TUIC: a misspelled UDP relay mode was accepted
Commit: 36861907a
What happened: Any udp_relay_mode other than quic, such as quick or Native, silently
meant native.
Fix: An empty value or native means native, and quic means QUIC. Any other value fails
when the configuration loads.
XHTTP: lost dial errors and accepted rejected uploads
Commit: fee26e298
What happened:
- When the download stream could not connect, the error was lost. The caller got a reader that never returned data, so the connection hung until it timed out.
- On HTTP/2 and HTTP/3, an upload the server rejected (any status other than 200) counted as sent.
- The closed flag was read and written from different goroutines without synchronization.
Fix: The dial error reaches the caller. On HTTP/2 and HTTP/3, a status other than 200 is an error, as in Xray. The closed flag is atomic.
REALITY: a long short_id crashed the process
Commit: f6f378e81
What happened: A REALITY short_id is at most 8 bytes, which is 16 hex digits. A longer value
overflowed the buffer it was decoded into and crashed the process, on both the client and the
server.
Fix: A short_id longer than 16 hex digits is a configuration error.
VLESS: a failed handshake leaked the connection
Commit: ea3c70fa5
What happened: When the TLS handshake or the VLESS encryption handshake failed, the underlying TCP connection was never closed. A failed encryption handshake also replaced the connection with an empty value, which could crash later code. This applied to both TCP and UDP.
Fix: The original connection is closed on any handshake failure, and it is replaced only after a successful handshake.
Trojan: a disabled TLS block crashed the process
Commit: 27c97f0d8
What happened: A Trojan outbound with a tls block where enabled is false built a TLS dialer
with no TLS settings, and the first connection crashed the process.
Fix: The TLS dialer is built only when TLS is enabled.