Compare commits

...
160 Commits
Author SHA1 Message Date
ginuerzh 4f143aa694 fix(tun): drop non-IP frames at the hub instead of writing them to the device
A frame that is not a parseable IPv4 or IPv6 packet is exactly what the
kernel refuses. Measured on a real up tun device: the version nibble is
the only thing it inspects, and anything but 4 or 6 comes back EINVAL at
any length -- 1421, 1500 and 2000-byte frames with a valid header all go
through untouched. That error left fromSpoke, unwound Handle, and took a
54-minute peer stream down with it.

The spoke has always refused these frames before its device write, which
is why a phone logs "unknown packet, discarded" all day and never dies
while the hub had no equivalent. destinationOf returning !ok is exactly
the kernel's reject condition, so that branch now drops and counts the
frame instead of handing it to the device.

This stops the malformed frame from killing the service; it does not fix
what put one on the wire. The candidate cause is concurrent serveEdge
writers racing on one local conn with no send lock on streamConn.Write,
which is still unproven. Silently accepted sub-MTU spliced frames also
remain -- the kernel does not validate TotalLen either.
2026-10-11 02:30:15 +00:00
ginuerzh a654aee7af feat(stats): the hub's LAN routing counters
LanRouted, LanDenied and LanWithdrawn sit beside the probe counters on the
same struct: a hub's peer-LAN routing is counted where every other service
counter lives, and where the API and the stats view already read from.
2026-10-09 13:31:47 +00:00
ginuerzh c2c78f3279 feat(tun): install prefix routes into the p2p handler
A hub that decides which peer LANs exist is handed this handler as a
handler.Handler, so the table has to be reachable by shape: the route type
is exported and the p2p handler takes the same call the socket one does,
installing into the peerTable its packet path already reads.
2026-10-09 11:01:04 +00:00
ginuerzh 9e3ce7cb68 fix(tun): gate prefix routes on the authenticated stream peer 2026-10-09 07:18:54 +00:00
ginuerzh bb682a90c9 fix(tun): resolve a prefix route's requester from the packet source 2026-10-09 06:57:23 +00:00
ginuerzh be86cfef74 feat(tun): route hub packets by prefix after exact match 2026-10-09 06:48:08 +00:00
ginuerzh 640e239280 feat(tun): relay spoke-to-spoke packets directly between peers
The p2p hub wrote every spoke packet to its tun device, relying on the
host kernel's same-interface forwarding to hairpin it back to the
destination peer. Hosts without that forwarding silently dropped
peer-to-peer traffic. fromSpoke now resolves the destination through
the route table first and delivers to the owning peer's stream;
unroutable, self-addressed, and hub-local packets keep the device path.

Tests cover direct delivery (v4/v6/large), self-send drops, unrouted
fallback, and hub-own-address fallback. The suite passes under -race
except TestRunDeviceProbeReportsSent, which races on a clean tree.
2026-10-07 11:26:09 +00:00
ginuerzh 98f64ea13d feat(tun): drop p2p stream on refused registration, bound first-inbound wait
A hub that refuses a spoke registration (bad passphrase, allowlist,
self-loop) used to stay silent and keep the stream: the spoke sat
'running' with zero traffic and nothing to look at. Return
ErrRegistrationRefused instead so the stream is torn down — the spoke
redials and re-registers, which also recovers on its own once the hub
is fixed. Malformed frames and bare echoes still return nil and leave
the stream up, so transport noise cannot turn into a reconnect storm.

Without a keepalive period the client reader had no deadline at all and
waited forever on a silent link. Bound it with a 30s first-inbound
one-shot (firstInboundTimeout, cleared by any inbound byte); the
keepalive>0 path is untouched.
2026-10-07 09:28:32 +00:00
ginuerzh 295a2c2296 docs: add tun device probe implementation plan 2026-10-07 05:07:13 +00:00
ginuerzh 1d42c2ec66 feat(tun): add device write-side self-probe
A running tun entrypoint cannot tell a dead device fd from an idle link: both look like frozen stats. The probe writes a self-addressed IPv4/UDP packet (spokeIP->spokeIP, magic+seq) into the device every 30s and reads it back on its own bound socket; sent/acked deltas go to the caller through the metadata probeReport callback. Enabled by metadata probe (default off, gost path unchanged); new probeSent/probeAcked counters (101/102) in x stats.
2026-10-07 05:07:13 +00:00
ginuerzh 3e533c61c7 Merge pull request #126: emit periodic session snapshots and byte deltas 2026-10-07 02:08:41 +00:00
ginuerzh 89188536d3 Merge pull request #133: let concurrent redials converge on one session 2026-10-07 01:47:38 +00:00
ginuerzh 239da867c3 Merge pull request #132: authorize every request against the bypass 2026-10-07 01:43:22 +00:00
ginuerzh 17909c4f95 fix(nodes): explicit reference check, trim lookup, deep-copy global definitions
IsReference no longer uses reflect.DeepEqual; ResolveConfig trims the name before registry lookup; cloneConfig deep-copies all nested fields so hop parsing never mutates shared globals. Also documents the intentional fail-fast vs warn-and-skip error semantics.
2026-10-07 01:36:45 +00:00
ginuerzh 8f6a725e7e Merge pull request #130: Global nodes registry for reusing nodes across hops 2026-10-07 01:30:30 +00:00
ginuerzh 312187d3d8 test(bypass): cover empty whitelist bypass-all after #129 2026-10-07 01:22:47 +00:00
ginuerzh bacaa9af89 Merge pull request #129 from iBug/bypass 2026-10-07 01:21:06 +00:00
ginuerzh a939758583 fix(tun): survive oversized packets without panic or stream teardown
A 20546-byte spoke datagram hit wbuf[:writeOffset+len(p)] (cap 16400)
and panicked the whole hub process. Three parts:

- tunDevice.Write rejects packets bigger than its buffers with the new
  exported ErrPacketTooLarge instead of reslicing past the end. The
  kernel would have refused the write anyway (tun MTU 1420), so nothing
  deliverable is lost.
- Buffers follow the device MTU (max 16KB): newTunDevice(ifce, mtu),
  so raising tun.mtu for legitimate jumbo UDP automatically makes such
  packets writable. The relay/KCP/stream path already passes 64KB.
- fromSpoke drops an oversized datagram with a windowed count and
  keeps the peer stream up; only a real device failure still tears
  the stream down.

Tests: tun write guard, normal passthrough, large-MTU accept, and
stream survival with rate-limited warnings; all written first and
watched fail.
2026-10-07 01:19:06 +00:00
ginuerzh 9ec8bdbfeb fix(tun): summarize unrouted-packet floods; log peer stream lifetimes
An unrouted destination under load produced one warn line per packet,
burying the event that caused the routing gap. Count misses and report
the total once per window instead; the first miss is still logged at
once.

Also log how long a peer stream lived and how much arrived on it when
it ends, so a dying peer's story (failed to connect vs failed mid-
transfer) is visible without correlating route drops.

Also ignore .memory/.
2026-10-07 01:18:38 +00:00
ginuerzh 4bbf81c9f4 feat(p2p): let a chain node ask the tunnel for a network
The tun link needs the tunnel network "ip" rather than the "udp" its dialer
would otherwise derive: "ip" is the datagram link, marked session-scoped on
the host so it ends on a lost peer session and the consumer re-dials (see
the p2p companion change). Add WithTunnelNetwork to override the derived
network and MDKeyP2PNetwork ("p2p.network") for a chain node to set it, and
have streamconn frame "ip" like "udp" so the GOST-side conn owns the 2-byte
framing on both carriers. An empty network keeps the derived value, so every
existing node is unchanged.

Gate: go build ./... and go vet ./... clean.
2026-10-06 00:36:49 +08:00
lueyoungandClaude Opus 5.5 6911ecd304 fix(mtls,mtcp,mws): let concurrent redials converge on one session
When no live session exists, each request dials a conn of its own and
registers it in Dial, then builds the session on it in Handshake, each
step under sessionMutex. A Dial that finds a registration still waiting
for its handshake takes it for dead (IsClosed reports true while
session is nil) and replaces it, so the earlier request's Handshake no
longer finds its own conn and fails with "unrecognized connection".
Since c210b74 the replacing Dial also closes that conn.

Once requests queue for the lock, another request's Dial can sit
between every Dial and its Handshake, and no session is completed for
as long as requests keep arriving. Seen on a three-hop relay chain
whose last hop is mtls: after the client host woke from sleep, nearly
every request on a chain with ~28 concurrent clients failed this way
for 80 minutes, until the load happened to drop.

Dial now closes the base conn only of a session that was established
and died; a session still waiting for its handshake belongs to the
request that dialed it, whose Handshake either builds the session on
it or closes it. Handshake no longer fails on a mismatch: if the
registered session is live it uses that and closes its own conn,
otherwise it builds the session on its own conn and registers it, and
the other request's Handshake then finds a live session to use.

The tests dial twice before either handshake, in both orders, against
the package's own listener. On master they fail with "unrecognized
connection", and with "use of closed network connection" when only
Handshake is changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:50:41 -04:00
ginuerzh 429c6a6199 fix(tun): drop keepalive frames from the client read loop
A registration handshake or keepalive echo is protocol, not a packet,
and must never reach the device. The client read loop only special-cased
the 20-byte bare echo, so a 20+N*16-byte registration frame from the
peer was misread as IPv4 ('G' is 0x47) and injected into the kernel
device as junk. The hub (p2pHub.fromSpoke) and the socket server already
filter this shape; the client now does too.

Covered by TestTransportClientDropsKeepaliveFrames, which fails against
the old shape (the device receives the frame verbatim).
2026-10-04 01:51:14 +00:00
ginuerzhandClaude Opus 4.8 1e17b38512 docs(tun): correct a stale comment on the p2p hub's authorizer
The comment above newPeerTable claimed "the authorizer is an option rather
than an argument", written when NewP2PHandler did not take one. It now does,
so the comment contradicted the signature directly above it, and it still
referred to an "auther argument" that no longer exists.

Both facts are true but at different levels, which is what made the old
wording hard to read: the authorizer is a variadic option on newPeerTable
(whose other callers all mean "no address authorization"), while
NewP2PHandler takes it as an argument so a hub's owner has one place to
pass it. The auther is nil throughout, since the p2p peer allowlist has
already decided who may connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-03 23:53:59 +00:00
ginuerzh d2678b8f5f feat(tun): the p2p hub authorizes addresses instead of credentials 2026-10-03 22:57:58 +00:00
ginuerzh 3d49aef879 feat(tun): let a hub decide which addresses a peer may claim 2026-10-03 17:10:21 +00:00
Mike-7777777 dc9c7444b9 sniffing,forwarder: authorize every request against the bypass
The bypass was evaluated once per connection, which authorizes the first
request and nothing after it. A keep-alive request naming another host
re-dialed without a check, a request served from the HTTP cache needed no
dial and so passed no check, a request arriving after a reload was still
judged by the policy that had been in force when the connection opened,
and an HTTP/2 :authority was never checked at all. With MITM enabled the
same holes applied to HTTPS, since the decrypted stream is handed to the
HTTP path.

State the invariant instead of patching the sites one by one: every
request forwarded to an upstream is authorized against the bypass in
effect at the time of that request. CheckBypass is that one check, called
from the four request-handling paths in the two sniffer copies. In the
HTTP loop it replaces the pre-loop check rather than adding to it, so the
file ends up with one check site instead of two.

A reload now reaches established connections at their next request. That
is a behaviour change, and it is what closes the cache and reload paths.
Tunnels stay authorized at establishment: a TLS connection without MITM,
a websocket after upgrade and a CONNECT tunnel have no later request to
check.

CheckBypass is nil-receiver safe because h2Handler can be built without
handle options, and a data path should not panic over that.

Measured cost against a 269-pattern bypass: 645 ns for a host that
matches nothing (full wildcard scan), 149 ns for an exact hit.
2026-10-03 18:46:11 +02:00
ginuerzh f1cd7c945a tun: the registration handshake is not a keepalive option
A tun client registers its device's addresses with the far side's tun
server by sending the handshake frame. That frame is what tells the server
which addresses this device holds, and it is the only thing that can: a
server routes by destination address, so without it the link is one-way —
traffic leaves and nothing comes back.

keepalive() has always sent it unconditionally and returned early when no
period was configured, so the period only ever decided whether it
repeated. The call site did not: it was gated on the udp network or a
configured period, which on a p2p link ("ip") meant no period, no
handshake, no registration at all. That is the shape a p2p hub needs
least — its route table has no TTL to refresh, so the repeat buys nothing
there and the one-shot is the whole of it.

The gate is gone; the handshake always runs. The period keeps its meaning
where it has one: a server reached over UDP is connectionless, so silence
is the only signal that a peer has left, and the repeat is what lets its
route expire.

Found by a user asking why the keepalive switch was needed at all. It was
not, on a p2p hub — and turning it off silently produced the one-way link
the entrypoint's own comment warns about.
2026-10-03 22:45:29 +08:00
ginuerzh bc137fbd83 relay,socks: one reverse-tunnel datagram frame per write
gost#911: two concurrent UDP source sockets over one RUDP reverse tunnel
over relay+TLS corrupted echo responses and made the host tear the tunnel
down and rebind it every second.

The tunnel is a single stream shared by every endpoint multiplexed over it.
A datagram frame is not written to that stream atomically —
UDPDatagram.WriteTo issues three separate Write calls (RSV/FRAG, SOCKS5
address, payload). Each Write is concurrency-safe on its own, but with
several endpoints writing, frames interleave between those calls. A torn
frame also leaves half a header in the stream, which the far end reads as
io.ErrUnexpectedEOF and answers by rebuilding the whole tunnel.

Hold a mutex for the whole frame instead of just the writes the datagram
happens to make. Same defect in both udpTunConn copies.

Reproduced with the reporter's script: relay+tls concurrent phase went from
44% loss with corrupted payloads and 1.0s gaps to 0.02% loss, no corruption,
0.003s gaps. Plain relay likewise. Single-endpoint high-rate loss (~35% at
14k pps) is unaffected UDP backpressure, not this bug.
2026-10-03 15:32:38 +08:00
iBug c67b2ec1d2 feat: Global nodes registry for reusing nodes across hops 2026-10-02 22:23:34 +08:00
ginuerzh 8787aff108 tun: a handler that owns a p2p hub and bridges streams into it
p2p.go was a complete engine with nothing driving it: no handler.Handler,
so no GOST service could reach the hub and a spoke's registration frame had
no path to one at all.

NewP2PHandler takes the tun device conn and an auther, builds the peerTable
(no TTL — a stream close says a peer is gone outright, where a socket peer
has only silence to guess from), the peerRouter and the p2pHub, and starts
the device reader. Handle installs one accepted stream under its remote
address — the peer key the p2p host stamped on it, replacing whatever a
reconnect left behind — and feeds its datagrams to the hub until the read
fails. Teardown is peerGone and nothing else: it reclaims a peer's routes
only while that stream still holds its key, so a stale stream ending cannot
cut off the live connection its peer already replaced it with.

ownNets comes off the device conn's context, the two steps server.go's Handle
already uses, because a nil answer silently disables the self-route guard.

Handle returns the read error unwrapped. io.EOF, net.ErrClosed and
context.Canceled are what isServerError calls an ordinary end, so a peer
reconnecting would otherwise land in someone's error log.

The p2p half of the compat suite now runs against the real handler and the
real hub rather than reporting itself unavailable: the same six assertions,
the same two cases skipped without an auther. A second p2p case covers the
reconnect, which is the only way the suite would notice a peerGone that
dropped routes unconditionally — a hub that did so passes every shared case.
2026-10-02 20:05:07 +08:00
ginuerzh 6e84d375c1 tun: one assertion set against both hubs, so a spoke cannot tell them apart
The socket server and the p2p hub are two answers to the same question, and
the design rests on a spoke being unable to tell which one it reached: the
same frame, the same magic, the same passphrase, the same registered
addresses, the same reply it refreshes its read deadline from. A spoke
configured for one is supposed to work against the other.

That claim is only worth something if the *same* assertions run against
both. Two separately-written suites would be allowed to drift, and nobody
would notice until someone deployed one against the other — which is a
field failure with nothing in any log to explain it. So the assertions are
written once here, against a compatHub that names no transport, and each
case is run once per implementation. The duplication that is left is
structural: how to build a hub and how it delivers, which is the part that
genuinely differs.

The socket half drives server.go's own transportServer over a real UDP link
rather than a copy of its loops; a compat suite that asserted against a
reimplementation would only prove the reimplementation agreed with itself.

The p2p half skips, and says why: nothing yet turns an inbound gRPC Tunnel
stream into a peerStream and feeds it to p2pHub.fromSpoke, so a spoke's
registration frame has no path to that hub. The skip is visible and the
six shared cases are named in it, because an absent assertion is not a
skipped one.

Three deliberate divergences turn the suite red, each from a real behavior
rather than a stub: registering a route for a frame the auther refused,
delivering to whichever peer spoke last, and echoing nothing after a
registration that took.
2026-10-02 19:14:20 +08:00
ginuerzh e95b830670 tun: a p2p hub reads the device once and locks every write to it
The route table answers a destination with a name, and the socket server
turns that name into a UDP address. A p2p hub needs the other answer: the
name is a peer key, and the thing it resolves to is a stream, not an
address. That is the whole of the second delivery — resolveUDPAddr is
unreachable here by construction, and putting it back on this path would be
a design error rather than a bug.

The shape is forced by the device, not chosen. tunDevice shares one read
buffer and one write buffer across every call and takes no lock: two
readers would have the second overwrite the first's target mid-call, and two
writers would copy through the same write buffer at once. It is safe today
only because the socket server happens to run one goroutine per side. A p2p
hub runs one per peer, so it has to reach the same guarantee a different
way: run() is the only goroutine that reads, and wmu is the only way to
write. This is the socket server's shape arrived at from the other side —
not a bridge per stream. Which goroutine read a packet carries no meaning,
because the route is what says where it goes.

Two locks, for two different hazards. The device's wmu guards the shared
device buffer. peerStream's own mu guards one peer's stream, whose conn
frames every write onto a byte stream and serializes only its read buffer:
two writers would put one frame's length prefix against the other's
payload, and every frame length would then be read from the wrong offset.
The -race run is what proves both are doing their job.

withdraw removes a stream only while it still holds its key. A peer that
reconnected already installed a replacement under that name, so reclaiming
routes for it on the old stream's teardown would cut off the live
connection — which is what makes a flapping peer unreachable rather than
merely briefly absent.

The keepalive is answered on the stream it arrived on, because the table
registers but never writes. Drop the echo and every keepalive:true spoke
misses the reply its read deadline is refreshed from and redials on its own
timeout, with nothing in any log: client.go's registration gate tests for
"udp" and a p2p link is "ip", so a spoke that never registers is already
indistinguishable from silence. Hence the warning naming the destination.

The concurrency tests are mutation-checked: with the device lock removed the
first fails on overlapping writes in round 0, and with the stream lock
removed the second fails on a spliced frame. Both were caught by the
assertions, not only by the race detector — the first harness was a
datagram pipe, which is atomic per message and so passed with the lock gone
until it was replaced by a byte stream, which is what the p2p conn really
is.
2026-10-02 17:56:32 +08:00
ginuerzh 64b14930b3 tun: the socket server keeps its behavior, on the shared router
Its keepalive handling, route lookups and both delivery loops move onto
peerTable unchanged: the same magic-header reply, the same gateway
fallback, the same discard for an unrouted packet. Only the route's
value is now a name, resolved at the point of delivery. A test that
needed updating to pass would mean the socket path moved when it must
not have, so the line-by-line diff is the evidence here — no test in
this package exercises server.go.

The route table's reverse index is gone. It was a second copy of a fact
already in the route's name, and two maps cannot be made consistent:
concurrent set calls could leave the index naming one peer while the
route belonged to another, and dropPeer would then delete the winner's
route on the loser's teardown. dropPeer now scans the routes themselves
and deletes only entries still owned by the peer it is dropping.

A lock would have been the wrong fix: lookup runs per delivered packet
and dropPeer once per stream close, so the cost would have landed on the
hot path to settle a race that only matters at reclamation.
2026-10-02 16:56:30 +08:00
ginuerzhandClaude Opus 4.8 70b52694eb tun: the route table holds a name, not an address
The keepalive protocol, the auther check, the TTL and destination lookup
are the same whichever way datagrams arrive, so they belong together and
know nothing about transport. A route's value becomes a string: a UDP
address for the socket server, a peer key for the p2p hub. net.Addr fit
neither — it is an endpoint, and one of these is not.

dropPeer is the p2p half of reclamation. The socket server expires a
route by TTL, guessing from silence that a peer is gone; a stream close
says so exactly, so the hub drops a peer's routes when its stream ends
and keeps no timer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:13:25 +08:00
ginuerzhandClaude Opus 4.8 8683da6a65 mux: a stream's deadline must not reach the shared session conn
streamConn embeds the session's net.Conn and overrides Read/Write/Close,
but not the deadline methods, so those reached the connection every
stream shares. Any deadline set by one stream then outlived it and
decided when the others could read.

net/http makes this reachable: tearing down a connection parks a
pending read with SetReadDeadline(time.Unix(1, 0)) and clears it only
when a read was actually in flight. Without a read in flight the shared
conn is left with a deadline in 1970, and the next Accept on the
session fails immediately with an i/o timeout — reported as a listener
error even though the session was healthy.

Delegate the three deadline methods to the stream, which has always had
its own. The test drives one request end to end over a stream, applies
that teardown deadline, and asserts the session still hands out the
next stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:44:14 +08:00
ginuerzh 2306f45bf9 service: a closed listener is not a failure to warn about
A remote listener (rtcp/runix/rudp) wraps an accept failure in core's
AcceptError — which always answers Temporary() — to say "rebuild the transport
and try again", and a closed pipe under it is one of the failures that recovers
that way, so the retry stays exactly as it was. What was wrong is what it said: a
stop arrives down the same path, and a warning that promises a retry reads like a
hiccup in the log of a service that is simply being stopped. The retry is logged
at debug when the error means a closed listener, pipe or socket; every other
failure still warns, and the tail still logs real failures at error.

No behaviour change: the retry, its backoff and the state transitions are
untouched — the classification decides only the log level.
2026-10-01 14:28:33 +08:00
ginuerzh 85c257ad65 service,handler/tun: a deliberate stop is not an error
Stopping a service closes its listener, and a closed listener answers Accept
with core's listener.ErrClosed — which the guard never matched (it knew only
net.ErrClosed), so every stop reached the log at error level and read like a
crash. The tun handler logged the same shape a second time: its client wrapped a
closed device with %s, throwing the cause away, so the caller could not tell a
device closed under the reader from a real read failure. %w keeps the chain and
the handler logs the closed case at debug.
2026-10-01 13:28:40 +08:00
ginuerzh ec4800293d config/parsing/logger: say when a log file has no rotation
A configured output path without a rotation block is a plain file that nothing
rotates: it grows until the disk does something about it. The default logger —
the one an unconfigured program gets — always carries a rotation, so this only
happens to a hand-written config, and the parser is the only place that sees
both the path and the rotation that is missing. Reported on stderr, once, at
parse time; nothing about the behaviour changes.
2026-09-29 16:18:39 +08:00
ginuerzh 1fadb8fe38 config/parsing/logger: a log file that cannot be opened is not fatal
ParseLogger reported the failure through logger.Default(), which is nil until a
caller installs one — and this call is usually what installs it. An unwritable
path therefore dereferenced a nil logger and panicked; inside a c-shared library
that aborts the process, which on Android took an app down a second or two after
start with nothing readable left behind (an app's stderr goes nowhere, and the
tombstone carries no abort text).

Measured with Output "file" — a path, not a mode — in an app whose working
directory is "/" and not writable.

The failure goes to stderr instead, which is always there, and the default
output stays in place. The regression test forks a child process, because a
fresh one is the only place the default logger is still unset: TestMain skips
installing its logger for that child.
2026-09-29 16:03:21 +08:00
iBug 660af7ce59 feat(bypass): Make network respect whitelist 2026-09-26 22:00:37 +08:00
ginuerzh ce290f9143 feat(listener/tun): adopt a host-provided device fd on Android
An unprivileged Android app cannot open /dev/net/tun, so the device can only
come from a VpnService: the Java side creates and configures it (addresses,
routes, MTU, DNS via VpnService.Builder) and hands its fd over as the "fd"
metadata, either as a fixed descriptor or — for an embedder, which is what the
platform's own VPN is — as a provider resolved when each device is built, so a
device rebuilt after its session ended never copies a closed or recycled
descriptor.

The device is created with tun.CreateUnmonitoredTUNFromFD (no netlink listener,
no setMTU — the builder already set it), which makes the Linux variant, with
its netlink and resolvectl calls, inapplicable: it is now linux && !android.
The interface lookup in the accept loop was fatal on any error; it is
informational, and on Android the device may not be listed in this process.
2026-09-24 15:56:46 +08:00
ginuerzh 1776e1a4ef p2p: rename TunnelProvider to Tunnel and OpenTunnelStream to Dial 2026-09-22 15:17:10 +08:00
ginuerzh a9cc544470 docs: widen the rename plan to TunnelProvider -> Tunnel 2026-09-22 15:09:37 +08:00
ginuerzh 73baffc689 docs: plan the TunnelProvider.Dial rename 2026-09-22 15:06:42 +08:00
ginuerzh 5b85e90db8 chore(x): bump plugin to v0.8.1 and widen the p2p inner dialer list
Bump github.com/go-gost/plugin to v0.8.1, which reports transport stats
over the p2p Status RPC (direct/derp peer gauges plus punch/stream
counters), and pick up the matching go.sum entries.

SupportedDialer: also accept wss, grpc, http2, h2, ssh, sshd and mwss as
inner protocols on top of a p2p tunnel. The list stays fail-closed; each
entry is a dialer verified to dial one plain conn and use it as its base.
2026-09-20 21:23:22 +08:00
ginuerzh 3627030098 Revert "fix(forwarder): authenticate HTTP node with Proxy-Authorization"
This reverts commit 34fbff3d3c.
2026-09-20 20:51:30 +08:00
ginuerzh 34fbff3d3c fix(forwarder): authenticate HTTP node with Proxy-Authorization
Node auth on the HTTP forwarder path read the origin credential
(Authorization) and challenged with 401/WWW-Authenticate. When the
forwarded backend also used Basic auth, the node consumed the credential
meant for the backend, so the two could not coexist.

Read the proxy credential (Proxy-Authorization) instead and challenge
with 407/Proxy-Authenticate. Authorization is left untouched so it
reaches the backend, and Proxy-Authorization is stripped before the
upstream write since it is hop-by-hop (RFC 9110 11.7.2).
2026-09-20 20:43:11 +08:00
Leonov Vladislav 1c42c638a6 recorder: emit periodic session snapshots and byte deltas
Handlers write one recorder entry when a session ends, so long-lived
SOCKS5 tunnels, streaming HTTP responses and UDP associations stay
invisible while they run and report their traffic as one late record.

With recorder.period set, a session now emits interim records on the
interval while it is active and one final record when it ends. Each
record carries the cumulative counters alongside the increments since the
previous one, so a consumer sums records instead of replacing them, plus
sessionID, recordIndex and phase. A session that ends inside one period
still writes a single record, as it does today.

One goroutine per handler samples every live session on the tick and
writes its record straight to the recorder. There is no queue and no
retry loop, and none is needed: a session moves its cursor past a record
only once the recorder has taken it, so a refused write costs no bytes.
They stay in the delta and go out with the next record. Holding on to
what the sink cannot take right now is the sink's job.

Interim and Finish take a context and return an error, so a handler
learns what became of every record it asked for. Start writes nothing, so
it takes neither. Close stops the sampling and reports the sessions its
owner had not finished; nothing is buffered, so there is nothing to
drain.

With recorder.period unset or non-positive the previous behaviour is
kept: one record per session, written inline, in the old shape. An object
its handler never stamped, and one whose record mode is off, is dropped
in both modes.
2026-09-15 15:05:02 +03:00
ginuerzh 06197bd987 fix(handler/tun): run keepalive on p2p links and expire stale routes
Three fixes found while wiring the p2p hub topology:

- The client keepalive was gated on network == "udp", but on a p2p link
  raddr is the peer's base64 key (no port), so SplitHostPort fails and
  network is "ip": the keepalive never ran, a tun server never learned the
  client's route, and the read deadline was never armed. Gate on the udp
  network OR a configured keepalive period; the udp branch keeps the one-shot
  registration handshake it always sent.
- Liveness was refreshed only by the keepalive echo; a transient channel
  hiccup can drop that single packet and redial a healthy tunnel after
  3xperiod. Refresh the read deadline after any successful read instead.
- Routes were never removed: a departed client's IP -> addr stayed forever
  and the device kept blackholing to a dead address. Expire a route once its
  last keepalive is older than 3x the configured period, checked lazily on
  the write path (no sweeper). The TTL is opt-in: without a server-side
  keepalive/ttl, routes keep their current never-expiring behavior.
2026-09-13 14:50:29 +08:00
ginuerzh a5605b935c chore(x): bump kcp-go to v5.6.72 and smux to v1.5.57 2026-09-12 12:07:28 +08:00
ihipop 8c999f1f5b feat(socks5): add optional UDP device binding (#127) 2026-09-12 10:37:46 +08:00
ginuerzh 7a4db061c9 fix(handler/sni): clear sniffing read deadline before the data path
The read deadline set for ClientHello sniffing was reset via defer, so it
remained on the client connection until Handle returned, i.e. for the whole
session. xnet.Pipe is called without a read timeout for TLS/HTTP, so nothing
refreshed it and any connection was torn down after readTimeout (15s default)
regardless of traffic. Clear the deadline immediately after sniffing, matching
the relay/router/tunnel/socks4 handlers.

Regression test asserts the deadline is zero when the upstream dial fires.

Fixes #904
2026-09-12 10:23:53 +08:00
ginuerzh a3e82c086c chore(x): bump github.com/go-gost/plugin to v0.8.0 2026-09-12 09:39:40 +08:00
ginuerzh ad702b4cbe test(p2p): feed chunks concurrently in the reassembly test
The test fed all 715 chunks before the first Read; with the cap-1 pump
handoff and a 64-slot fake stream the feeder blocks long before the
reader starts — a deterministic deadlock the earlier "passing" runs
masked (a `go test | tail` pipeline hid the exit code). Feed from a
goroutine.
2026-09-12 09:39:40 +08:00
ginuerzh 2cb1365d46 fix(p2p): release the datagram assembly buffer at frame boundaries
readDatagram kept appending into one backing array; an idle conn held
onto the largest frame's array indefinitely (bounded, but anchored until
a later append crossed capacity). Drain it to nil once a frame boundary
empties it, and assert the release plus the received-bytes-only bound in
the tests.
2026-09-12 09:21:59 +08:00
ginuerzh 4f3261a9d9 feat(p2p): move tunnel data onto the gRPC Tunnel stream
TunnelProvider becomes OpenTunnelStream: OpenTunnel authorizes the tunnel
and issues a single-use id, the Tunnel stream carries the data. The new
x/p2p/streamconn conn wraps the stream as a net.Conn with real
Set*Deadline support (bounded Recv pump, idempotent Close, framed mode
for udp). The tunnel base dialer collapses to the stream; its failure
path closes the conn nil-safely instead of leaking.
2026-09-12 08:19:21 +08:00
ginuerzh 988c9afe28 fix(masque): reap idle CONNECT-UDP associations
udp.Relay.Run ignored ctx cancellation, so an idle MASQUE association (or a
disconnecting client) leaked its target UDP socket and both copy goroutines,
and service shutdown blocked on the stuck handlers. quic-go's
ReceiveDatagram only unblocks on ctx cancellation, never on stream close, so
closing the datagram conn could not release the read either.

Make Relay.Run cancellation-aware and close both conns on exit, add
WithReadTimeout for an idle read deadline, give DatagramConn a cancelable
context that Close cancels, and apply the parsed idleTimeout to the UDP
relay.

Ref: https://github.com/go-gost/x/issues/128
2026-09-11 21:40:12 +08:00
ginuerzh 30f5a0b2c8 docs: add the pending issue and plan write-ups
Sixteen issue analyses, fourteen feature plans, and three superpowers
specs/plans that were sitting untracked.
2026-09-11 20:51:35 +08:00
ginuerzh 5bf06ef9ac docs(plans): drop the plans moved to the p2p repo
These four p2p phase plans now live in the p2p repo's docs/, alongside the
work they describe.
2026-09-11 20:50:46 +08:00
ginuerzh 8c255d8ec4 docs(plans): add the p2p tun link plan
Design and implementation notes for moving the tun-to-tun link out of the
p2p host: GOST owns the device, the host supplies a datagram tunnel. Records
how each review finding was closed and what the e2e run covered.
2026-09-11 20:43:00 +08:00
ginuerzh 54ea0220a9 feat(p2p): datagram tunnel endpoints for tun-to-tun links
OpenTunnel carries a network: "udp" asks the plugin for a datagram endpoint
(one datagram per write) instead of a byte stream, which is what a tun
handler needs — a stream endpoint coalesces and splits packets. tcp stays
the default, so existing deployments are untouched.

The tun handler could not reach client mode without a service forwarder, so
a chain with no forwarder now means client mode with an empty dial address:
the link is transparent and the peer comes from the chain node (a p2p node's
addr is its peer key). That combination used to be dead config, so it is
logged. A forwarder still wins when present, and server mode is unchanged.

Alongside: the udp dialer no longer hard-asserts *net.UDPConn (a wrapped
base dialer yields a plain conn), udp joins the p2p dialer whitelist, and a
udp tunnel announces its client with an empty datagram right after dialing —
the endpoint learns where to send peer packets only from a packet it
receives, so without it a link whose peer speaks first would never come up.

Requires plugin v0.7.0 for the new field.
2026-09-11 20:42:29 +08:00
ginuerzh f70b114578 plugin: manage external plugin subprocess lifecycle
Add Command []string to PluginConfig and a new x/plugin package that
spawns plugin subprocesses (deduplicated by argv) and terminates them
on Shutdown. Linux uses Pdeathsig, Windows a kill-on-close job object,
so children follow an abrupt parent exit.

Wire Spawn into every config/parsing plugin constructor.
2026-09-07 17:40:20 +08:00
ginuerzh 4cd6cd5c30 add p2p M2 hole-punch plan (STUN + KCP + smux direct path) 2026-09-07 15:16:48 +08:00
ginuerzh dcd02f8d52 fix(quic): don't close shared transport on invalid datagram
cipherConn.ReadFrom returned decrypt errors straight to quic-go, which
treats any packet-socket error as fatal and closes the shared QUIC
transport. A single malformed datagram (short, wrong key, tampered tag)
thus killed the whole listener while the process kept running.

Discard unauthenticated/invalid peer datagrams and keep reading; only
surface real socket errors and bad local AES keys. Also fix WriteTo to
return the caller-supplied length instead of the encrypted nonce+tag
length, per the net.PacketConn contract.
2026-09-06 23:03:35 +08:00
ginuerzh d7f0b88086 docs(plans): add p2p mux + token plan (phase one) 2026-09-06 13:35:18 +08:00
ginuerzh 71c1566b00 docs(plans): add p2p DERP relay plan (phase two) 2026-09-06 13:34:24 +08:00
ginuerzh 8127c99153 feat(p2p): lazy tunnel open, whitelist the tls/ws/mux inner dialer family
The tunnel is now opened inside the base dialer on first use: mux
inners (mtcp/mtls/mws) hit their session cache without touching the
base, so a cached session spends no OpenTunnel and leaks no tunnel
per dial. SupportedDialer grows to tcp/tls/ws/mtcp/mtls/mws (each
verified end to end; tls/ws handshakes trigger via the forwarded
Handshaker, mux inners reuse one tunnel as a session). Error paths
release the tunnel exactly once: base-dial failure resets the base
state, inner failure after a successful base dial is closed by the
wrapper, tunnelConn close clears the base state.

Plugin bumped to v0.6.1; QUIC evolution note (transparency principle,
no automatic inner substitution) recorded in the stub plan.
2026-09-06 00:17:10 +08:00
ginuerzh 967053c690 fix(mtcp): re-init session in Handshake when it has no mux session yet
A session Dialed but not yet Handshaken has session.session == nil; a
concurrent Dial treated it as dead (IsClosed() is true for nil sessions),
deleted it and closed the base conn under the first caller's Handshake.
Re-initializing on the nil-session case self-heals instead of failing
the identity check.
2026-09-06 00:17:10 +08:00
ginuerzh fe9d9c9574 add p2p plugin-backed tunnel dialer for chain nodes
Any dialer can now declare its base transport to run through a tunnel
opened by a named p2p plugin component: a top-level p2ps: section
registers TunnelProviders (gRPC plugin), and chain node metadata
"p2p: <name>" wraps the node's dialer so it dials the plugin's
OpenTunnel endpoint instead of the network directly. The inner dialer
keeps its protocol (tls/ws/mux) and Handshake, which the wrapper
forwards so it still triggers at Transport.Handshake.

- x/p2p: tunnelDialer (fail-closed, per-dial tunnel with idempotent
  CloseTunnel on conn close), SupportedDialer whitelist (tcp only;
  fail closed for unverified inner dialers), gRPC TunnelProvider
- config: P2PConfig, parsing, loader registration (reload-safe via
  unregister+Close), mergeConfig wiring
- dialer wrapper registered before Init so the chain inits once
- pin github.com/go-gost/plugin v0.6.0 (p2p proto); dependency bumps
  are a consequence of the new import
2026-09-05 21:25:13 +08:00
ginuerzh dadbbdf5c7 docs(plans): harden p2p stub plan after review
- inner dialer whitelist becomes prerequisite 3 (fail-closed, tcp only)
- record dead-session tunnel-recall fix and the known IsClosed race
- loopback default bind declared as the security boundary for the
  unauthenticated control channel (active-dial SSRF surface)
- contract scope narrowed to TCP-semantic byte pipes; UDP/datagram
  engines (hole punching) are an explicit future collision point
2026-09-05 16:39:27 +08:00
ginuerzh c210b74106 fix(mtcp,mws,mtls): close dead session's base conn before dropping it
Dial() deleted a dead muxSession without closing session.conn, leaking
the base TCP conn on every dead session (and, once a p2p base conn is
injected, leaking the plugin-side tunnel). session.Close() is a no-op
when session.session is nil, so the base conn is closed explicitly.
2026-09-05 16:39:27 +08:00
ginuerzh dac42ba39c fix(tun,tungo): release tun/tap device on listener close (gost#901)
The tun/tungo listeners never tore down the active device on Close, so
reloading (API config change or SIGHUP) with the same device name hit
"device or resource busy": the kernel kept the old interface because its
fd was still open, and the listen loop leaked devices created around
shutdown, spamming errors and blocking SIGTERM.

Track the accepted conn on the listener and close it (idempotently via
sync.Once folding cancel + fd close) from both the loop's shutdown branch
and Close(), with a send-race guard so a device created just as Close runs
is still released. Closing the fd removes the interface, so rebind with the
same name now succeeds and the process exits cleanly.
2026-09-05 11:48:52 +08:00
ginuerzh 53269265e1 refactor(config): swap yaml.v3 encoder for goccy/go-yaml
gopkg.in/yaml.v3 is archived/unmaintained upstream. Its only use in x is
Config.Write()'s YAML encoding, which is replaced with the already-present
goccy/go-yaml (compiled in via gin) — no new dependency. goccy output is
wire-equivalent (same keys/structure/omitempty); viper reads it back
identically, confirmed by the config round-trip tests. yaml.v3 drops to an
indirect dep (retained only by the inspector module in the workspace).
2026-09-02 18:00:43 +08:00
ginuerzh cfccd22fc9 refactor(ss,tap): migrate tap off upstream go-shadowsocks2 onto the fork
Drop the classic github.com/shadowsocks/go-shadowsocks2 dependency. tap and
internal/util/ss were its only remaining users after the ss/ssu handlers
moved to the go-gost fork. The fork's shadowaead is byte-identical on wire
(classic AEAD: same md5-KDF, HKDF-SHA1 subkey, salt sizes), so tap now uses
a small stateless ss.AEADPacketConn built on fork Pack/Unpack, and
ss.ShadowCipher becomes a fork builder (dummy/none -> existing none.Cipher).
Removes the undocumented tap handler key metadata and the dead ShadowConn
helper in the process.
2026-09-02 17:42:09 +08:00
ginuerzh de301e8cfb refactor(handler/http): drop govalidator dep, inline DNS name check
Replace the sole govalidator.IsDNSName call in normalizeRequest with a local
isDNSName helper that mirrors the library's exact behavior (empty/size guard,
net.ParseIP exclusion, and the same label regex), removing the
asaskevich/govalidator dependency from x.
2026-09-02 17:01:58 +08:00
ginuerzh e119a973f2 refactor(routing): drop predicate dep, parse matcher rules via go/ast
Replace the vulcand/predicate dependency in the routing rules parser with a
direct go/parser/go/ast implementation covering the same grammar (matcher
calls, parentheses, &&/||/!). This removes the predicate -> gravitational/
trace -> logrus logging chain from x, since trace was only used to build
errors. Public API (NewParser, Parser, TreeBuilder, Parse, CheckRule,
ParseMatchers) is unchanged and the existing test suite passes as-is.
2026-09-02 16:58:35 +08:00
ginuerzh 66960e7dd6 fix(redirect): skip pre-sniffing IP bypass check in whitelist mode
Domain-only whitelist bypass never matched the bare destination IP, so the
pre-sniffing bypass check (fe394a8) rejected connections before SNI sniffing
could match the sniffed host (gost#899).

- skip the pre-sniffing check when bypass IsWhitelist(); the sniffer applies
  the whitelist against the sniffed host
- restore the post-sniffing bypass check on dstAddr (removed by fe394a8) so
  whitelist mode still rejects non-matching hosts on the sniffingFallback
  path and when sniffing is disabled
- bypassGroup.IsWhitelist() now reports true only for all-whitelist groups
  instead of always false (sole external consumer is the redirect handler)
- rewrite handler tests to drive a real TLS ClientHello through Init()'d
  metadata: whitelisted SNI is forwarded, non-whitelisted SNI gets ErrBypass
2026-08-31 21:45:28 +08:00
ginuerzh 14fed91c62 httpcache: guard Turbo short-circuit responses, add StoreKey
Cacheable() now refuses X-Turbo-Short-Circuit responses (partial <meta>
payloads meant to merge client-side, would serve blank pages on later
hits). StoreKey() lets callers whose request identity is rewritten before
store (e.g. mirror Host routing) pin the key so Lookup and Store agree.

Bump github.com/go-gost/core to v0.6.1.
2026-08-29 09:23:26 +08:00
ginuerzh 2299c43bb7 forwarder: HTTP header rewrite (regex + plugin) and hostPattern wiring
Implement the header counterpart of the existing body rewrite:

- rewriteHeaderBlock applies rewrite rules to an http.Header in place.
  Regex mode matches header names case-insensitively (per HTTP spec) and
  rewrites each value independently; an empty replacement drops the value,
  and a fully-empty header is deleted. Plugin mode serializes the header
  block (http.Header.Write + trailing CRLF, since ReadMIMEHeader needs the
  blank line), sends it to a core/rewriter.Rewriter plugin, and parses the
  result back with clear+copy (h is a map; reassignment would not escape).
- rewrite_metadata.go consolidates the hand-rolled plugin metadata maps
  (baseMetadata + three SSE inline maps) into rewriteMeta() with common
  sid/direction/uri fields and a new kind (body|header), additive for
  existing plugins.
- sniffer_http.go wires rewriteReqHeader before the request is written
  upstream (and refreshes the recorder header clone) and rewriteRespHeader
  after static response-header overrides, before the recorder clone.
- hostPattern: derive req.Host from the request path via regex submatch
  expansion (Host = $1-style template), for Host-routed mirror origins.
- parse: parseHeaderRewrites gates on the config string (regexp.Compile("")
  yields a non-nil match-all, so gating on the compiled regexp would turn
  an empty name into a silent no-op rule).

Fixes found by e2e: reqHeaderRewrites was assigned after its guard, so the
request header rewrite never ran; tests cover the header round-trip,
plugin gating, metadata kind, and hostPattern expansion.
2026-08-29 08:37:44 +08:00
ginuerzh dd65d46926 fix(handler/http): sync Gost-Target override into req.URL.Host to prevent policy bypass
A valid Gost-Target / X-Gost-Target header replaced only req.Host while
leaving req.URL.Host untouched. Destination-policy checks (bypass) evaluated
the header-derived authority, but http.Transport dialed the unchanged
absolute-URL authority. A client could label a forbidden URL with an allowed
authority and reach the blocked destination (403 -> 200 with backend body).

setRequestHost now applies the decoded target to both req.Host and req.URL.Host
so policy evaluation and transport dialing always resolve to the same authority.
2026-08-27 20:16:22 +08:00
ginuerzh 2e9befc39c fix(handler/http): resolve origin-form requests via Host header (#679)
Origin-form requests ("GET / HTTP/1.1" with only a Host header, as
sent by nginx proxy_pass to an upstream proxy) failed RoundTrip with
"http: no Host in request URL": normalizeRequest infers URL.Scheme
but leaves URL.Host empty, and http.Transport requires a non-empty
URL.Host. Also affected every keep-alive request after the first,
which bypass normalizeRequest entirely.

Fill scheme+Host from req.Host in the shared proxyRoundTrip path,
covering both initial and subsequent requests.

e2e-verified: raw origin-form request proxied by Host header
(gost/tests/e2e TestHTTPProxyOriginForm).
2026-08-27 00:02:13 +08:00
ginuerzh 93bd2ea760 fix(hop): parenthesize host OR and normalize wildcard in filter->matcher rule
filterToMatcherRule produced `Host(x) || Host(.x) && Proto(...) && ...`,
but the matcher DSL (vulcand/predicate) binds && tighter than ||, so the
apex Host(x) branch bypassed the protocol/path conjuncts — a request to
the apex with a non-matching protocol/path matched where the legacy
filter (AND of host/protocol/path) rejected it.

Also normalize `*.example.com` to `.example.com` before the leading-dot
branch: legacy ParseNode did this so both forms matched apex+subdomains;
without it, `*.example.com` reached Host() as a wildcard matching only
subdomains, dropping apex coverage that `.example.com` still had.

- Parenthesize the host OR when protocol/path conjuncts follow.
- Strip leading `*` + ensure leading dot, mirroring legacy normalization.
- Update TestFilterToMatcherRule expectations; add dot/star combined cases.
- Add TestFilterToMatcherRule_Precedence: runtime guard that apex+wrong
  proto/path is rejected and apex/subdomain+valid is accepted, for both
  `.example.com` and `*.example.com` forms.
2026-08-26 21:02:50 +08:00
ginuerzh 9292e0af3e fix(tunnel, forward): route tunneled hostname through hop filter.host
The tunnel bind conns (bindConn/bindUDPConn) now expose their metadata (the
tunneled hostname) via the xctx.Context interface, so the service accept loop
forwards it into the handler context. handleRawForwarding selects a hop node by
that hostname when a hop is configured, so a node with a matching filter.host is
picked; otherwise it falls back to dialing the hostname directly. Fixes
reverse-proxy-tunnel TCP routing (gost#898) where the docs example failed with
"node not available" because the mux path never set the host metadata.
2026-08-26 20:28:37 +08:00
ginuerzh 6ad7269644 refactor(hop): normalize deprecated node filter into matcher at parse time
Fold the deprecated per-node filter (host/protocol/path) into an equivalent
matcher DSL rule during config parsing, so hop.Select keeps a single
filtering path (routing.Matcher). Legacy filter configs stay accepted.

- filterToMatcherRule() maps host->Host(), protocol->Proto(), path->PathPrefix();
  a leading-dot host emits `Host(x) || Host(.x)` to preserve the legacy
  apex+subdomain coverage. Converted rules get priority=-1 to keep the
  always-through-selector behavior of the legacy path.
- Remove isEligible/checkHost/checkProtocol/checkPath from x/hop.
  core/ chain.NodeFilterSettings is left untouched (dead but harmless).
- Migrate hop tests to matchers; add TestFilterToMatcherRule.
2026-08-26 20:26:26 +08:00
ginuerzh 4ea4efaba9 fix(forwarder, sniffing): assert BasicProxy recording via captured output
Since 486a7766 HandleHTTP records on an internal clone of the passed
recorder object, TestHandleHTTP_BasicProxy's assertions on the original
ro were always empty, failing every run. Assert SrcAddr/DstAddr on the
captured recorder payload instead, and drain the response body before
closing the net.Pipe so HandleHTTP completes the write and exits cleanly.
2026-08-25 22:17:05 +08:00
ginuerzh faeede7881 feat(forwarder): delete JSON body fields via json-: rewrite match
Add a json-: match prefix to http.rewriteRequestBody/rewriteResponseBody
that deletes the matched JSON field (sjson.DeleteBytes) instead of
replacing it, keeping the existing value-regex conditional semantics.
2026-08-25 21:54:27 +08:00
ginuerzh a38a701dc4 refactor(sniffing): register protocol handlers via init() registries
Replace the 5 duplicated switch statements that dispatch sniffed
connections (forward/local, forward/remote, http CONNECT, sni, relay)
with per-package registries keyed by proto string. Protocol handlers
self-register via init(), following the house registration pattern, so
adding a protocol no longer requires touching every dispatch site.

Unify the two HandleOptions types (forwarder lacked dialTLS; sniffing
lacked hop/node/httpKeepalive) into one exported struct in sniffing;
forwarder aliases it. Unknown protocols fall through to raw forwarding
exactly as before; SSH remains detection-only. redirect/tcp's special
fallback-replay dispatch is intentionally left untouched.

Adds Dispatch helpers and 8 registry tests (register/get/duplicate/
unknown/known) in both packages. Net -151 lines.
2026-08-25 21:35:45 +08:00
ginuerzh 0132cefaec fix(api): save config back to the loaded file path (gost/x#123)
POST /config without an explicit path= always wrote gost.yaml/gost.json to
the process CWD, silently leaving the config loaded via -C untouched. Track
the last file read by ReadFile and default the save target to it, deriving
the output format from the file extension so a JSON config is saved back as
JSON. Falls back to the CWD default when no config file was loaded.
2026-08-21 21:43:17 +08:00
ginuerzh 25cd60d42d fix(connector/ss): own the target addr instead of aliasing the pooled buffer (gost#896)
WrapConn stores the target and only writes it on first I/O, but the
connector encoded it into a bufpool buffer that was returned to the pool
when Connect returned. A later Connect could reuse the backing array and
overwrite the bytes, misrouting the first connection. Allocate the target
per connection instead of sharing a pooled buffer.

Regression test drives two Connect calls and asserts the first conn's
wire target survives a second Connect reusing the pool.
2026-08-21 21:42:43 +08:00
ginuerzh f8ca4908f3 fix(hop): skip failed nodes before priority short-circuit, last-resort fallback
Failed nodes (http.failCodes marker) were still selected when the
priority short-circuit fired or when FailFilter saw a single-node tier,
so failover never happened for a high-priority matcher node returning
429. Check IsFailed first in the candidate-pool scan, and when every
candidate is failed return the first node matching the request gates as
a best-effort last resort instead of nil.

Refactors the fail-code check into shared selector.IsFailed (cee264e)
wired through FailFilterSettingsOption (64ea518).
2026-08-18 23:42:08 +08:00
ginuerzh cee264e184 refactor(selector): share IsFailed dead-node check with FailFilter
Extract the per-item failed-state decision (failure count >= maxFails,
last failure within failTimeout, metadata overrides) from FailFilter
into an exported IsFailed helper, and reuse it in the hop's
pre-selection dead-node filter instead of a local duplicate.
2026-08-18 23:21:34 +08:00
ginuerzh 64ea5184f8 fix(hop): drop marked-failed nodes before priority selection
http.failCodes (and probe) marking had no effect when the failing node
had a strictly higher matcher priority: the priority short-circuit
returned the top node directly, and FailFilter no-ops on a single-node
tier (len(vs) <= 1 guard), so the marked node was always re-selected
and failover never happened.

Pre-filter nodes marked failed (same Count >= maxFails && within
failTimeout criteria as FailFilter) out of the candidate pool before
priority selection. Single-node hops keep their lone node (safety net),
and an all-failed pool returns nil as before.

Wired maxFails/failTimeout from the hop selector config into the hop
via FailFilterSettingsOption.
2026-08-18 23:15:59 +08:00
ginuerzh fc5fc052e4 chore(deps): bump go-shadowsocks2 to v0.1.4
Pulls in the classic-AEAD UDP [addr][payload] response-framing fix that
restores shadowsocks UDP interop with third-party servers/clients
(go-shadowsocks2, shadowsocks-libev). Fixes go-gost/gost#894.
2026-08-18 22:17:20 +08:00
ginuerzh e6aeba8701 fix(listener/redirect/udp): set SO_REUSEADDR on tproxy listener socket (gost#413)
Match the dialUDP socket so per-packet tproxy sockets can bind a
destination port overlapping this listener without "address already in use".
2026-08-18 21:55:12 +08:00
ginuerzh cc02cff00c fix(logger): deduplicate keys across WithFields chains (gost#895)
slog.Logger.With does not deduplicate keys, so chained WithFields calls
(e.g. service -> listener/handler, each setting "kind") emitted duplicate
JSON keys. slogLogger now rebuilds from a pristine base logger with
accumulated attrs deduplicated by key, last write wins.
2026-08-18 21:49:11 +08:00
ginuerzh 18df26e9bc Merge branch 'master' of github.com:go-gost/x 2026-08-17 23:05:14 +08:00
Vincent Yang a9be7e6dce fix(masque): repair server CONNECT-UDP relay (#122)
The MASQUE handler's CONNECT-UDP path was broken in two ways, so no
standards-compliant client (e.g. Surge) could establish UDP tunnels:

1. Missing Capsule-Protocol response header. HTTPStream() flushes the
   response internally and commits the headers, but it was called before
   Header().Set("Capsule-Protocol", "?1"), so the mandatory RFC 9298
   header was silently dropped and clients rejected the tunnel.

2. WriteTo on a pre-connected socket. A direct UDP dial via the router
   returns a *net.UDPConn that satisfies net.PacketConn but is
   pre-connected; the relay called WriteTo on it, which fails with "use
   of WriteTo with pre-connected connection", so no packet reached the
   target.

Set the Capsule-Protocol header before hijacking the stream (dialing the
target first, matching handleConnectTCP), and always wrap the
router-returned conn so writes go through Conn.Write. Verified with a
standards-compliant HTTP/3 MASQUE client: CONNECT-UDP now returns
Capsule-Protocol: ?1 and datagrams relay end to end; CONNECT-TCP
unaffected.
2026-08-17 23:05:00 +08:00
ginuerzhandgithub 0e0eab0c52 fix(cmd): restore unix socket port-forward semantics
Commit b3b5986 crammed the URL path into the listen/connect address and
disabled forward mode for path-based protocols, breaking the
`unix://a.sock/b.sock` (listen on a, forward to b) port-forward form and
producing an invalid `a.sockb.sock` address.

Distinguish the two URL forms by host presence:
- three-slash (unix:///abs.sock, empty host) → path is the absolute socket
  address, no forward target;
- two-slash (unix://a.sock/b.sock) → host is the socket and the path is a
  forward target (service) or ignored (node).

Add regression tests for both forms.

Co-Authored-By: Claude (claude-opus-4-8[1m])
2026-08-17 22:51:00 +08:00
Dean Chen 98e9651361 cmd: keep absolute unix socket paths for path-based schemes (#121)
Path-based address selection used the dialer/listener type after registry
fallback, so unix remapped to tcp skipped EscapedPath and dropped
addrs like /var/run/docker.sock from three-slash URLs.

Decide path-based protocols from the original scheme components.

Signed-off-by: Dean Chen <862469039@qq.com>
2026-08-17 22:29:17 +08:00
PahaKush e4e36ca095 x: stream websocket reads instead of buffering each frame (#120)
websocketConn.Read called Conn.ReadMessage, which is NextReader plus
io.ReadAll. io.ReadAll starts at 512 bytes and grows by doubling, so every
frame allocated a fresh slice and a chain of intermediate ones: a stream of
64KB frames costs about 2.5 bytes of garbage per byte carried, even though
the caller already supplied a buffer to copy into.

On a relay+wss tunnel moving 750MB this was 2526KB of garbage per MB of
payload and 757 GC cycles in 15 seconds against a 1.5MB live heap. An
allocation profile attributed 99.92% of all process garbage to io.ReadAll
under ReadMessage; a CPU profile spent 15% in memclrNoHeapPointers and ~21%
in the collector, against 8% in AES-GCM.

Use NextReader and copy straight into the caller's buffer. The reader is
kept across calls and dropped at end of message, so a message larger than
the caller's buffer is still delivered across successive reads.

This also fixes a zero-length message surfacing as a (0, nil) read, which
io.Copy counts as no progress and rejects after 100 in a row.

Benchmark on 64KB frames:

    before  39267 ns/op  1668.97 MB/s  138444 B/op  19 allocs/op
    after   19920 ns/op  3289.98 MB/s      80 B/op   3 allocs/op
2026-08-17 22:28:48 +08:00
Niek van der Maas 746811eea5 fix: support SOCKS5 UDP associations over MASQUE (#119)
* fix: support SOCKS5 UDP over MASQUE

* fix: complete MASQUE SOCKS5 proxy support

* fix: isolate MASQUE UDP destinations
2026-08-17 22:28:20 +08:00
ginuerzh 7f72a81864 x: fix SSH authorized_keys parse failing on trailing newline (gost#699)
ParseAuthorizedKeysFile looped over ssh.ParseAuthorizedKey, but x/crypto's
parser splits on '\n' and returns rest='\n' for a trailing newline. The next
iteration called ParseAuthorizedKey('\n'), which skips whitespace and returns
'ssh: no key found', aborting the whole parse. Any normal authorized_keys file
(ends with newline, or has comments/blank lines) failed despite being valid.

Break the loop once rest is exhausted so all keys are parsed.
2026-08-01 18:24:10 +08:00
ginuerzh 2112532e0a x: set TCP_NODELAY on ws/wss/tls transports to cut relay UDP latency
Disable Nagle on the underlying TCP socket for relay+ws, relay+wss, and
relay+tls listeners/dialers. Small latency-sensitive datagrams (e.g.
WireGuard UDP over a relay tunnel) were being coalesced by Nagle until a
peer ACK returned, adding up to one RTT per datagram and inflating
round-trip latency (go-gost/gost#858). wstunnel sets TCP_NODELAY on its
tunnel socket by default and keeps latency flat; gost did not.

Add ApplyNoDelay helper and WrapNoDelayListener (mirrors ApplyKeepalive),
applied on the raw *net.TCPConn before the proxyproto/tls wrapper chain.
2026-08-01 16:59:54 +08:00
ginuerzh b2d901e70f x: add Redis TCP sniffing with protocol-based routing
Add RESP protocol detection to the sniffing framework so plaintext
Redis traffic on a port-reuse listener can be identified and routed by
protocol. Sniff() now peeks 1 byte first to match RESP type bytes
(*+-:$) without blocking, then falls through to the existing 5-byte
TLS/HTTP/SSH detection.

ParseRedisMetadata consumes exactly one RESP message via io.TeeReader
and records command/key in HandlerRecorderObject.Redis. HandleRedis in
both the sniffing (injected-dial) and forwarder (hop-selected) packages
replays the captured first message and pipes the rest, so pipelined
commands beyond the first are forwarded transparently.

Wired into all 5 dispatch sites: sni, forward/local, forward/remote,
http CONNECT, and relay tunnel handlers. forwarder paths route via
resolveRedisNode using ProtocolSelectOption(ProtoRedis), enabling
`protocol: redis` hop selection.

Part of two-phase plan (2026-07-29-sniffing-redis-postgres.md);
PostgreSQL deferred to phase 2.
2026-08-01 16:03:23 +08:00
ginuerzh b106f764a6 x: bump quic-dissector to v0.1.0, remove merged plan docs
Bump quic-dissector dependency from pseudo-version to tagged v0.1.0.
Remove docs/plans that have been implemented and merged.
2026-08-01 16:03:23 +08:00
ginuerzh edb0b5d651 x: extract shared QUIC sniffing helpers, add redirect UDP QUIC sniff
Extract PopulateQUICClientHello/PopulateQUICServerHello into
internal/util/sniffing/quic.go to deduplicate the QUIC ClientHello/ServerHello
population logic that was copy-pasted across forward/local (Handle, handleRawDatagram)
and redirect/udp handlers (3 sites → 2 helpers).

- handler/redirect/udp: add QUIC ClientHello + ServerHello sniff with
  sniffing/sniffing.timeout metadata, populating ro.Proto/ro.TLS for recording.
- handler/forward/local: replace inline QUIC blocks with shared helpers;
  use h.md.bufferSize instead of hardcoded 8192 for sniff reads.
- internal/util/sniffing: add ProtoQUIC constant.

Bump quic-dissector dependency to v0.1.0.
2026-08-01 16:03:23 +08:00
dependabot[bot] e3e41abd66 build(deps): bump github.com/pion/dtls/v3 from 3.1.1 to 3.1.4 (#118)
Bumps [github.com/pion/dtls/v3](https://github.com/pion/dtls) from 3.1.1 to 3.1.4.
- [Release notes](https://github.com/pion/dtls/releases)
- [Commits](https://github.com/pion/dtls/compare/v3.1.1...v3.1.4)

---
updated-dependencies:
- dependency-name: github.com/pion/dtls/v3
  dependency-version: 3.1.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 15:59:34 +08:00
Anivice IvesandYour Name 221144fab3 fixed the issue where the pull request to exit and subsequent application writes to fail immediately in gost (#117)
Co-authored-by: Your Name <you@example.com>
2026-08-01 15:59:08 +08:00
ginuerzh 6b1c6757f6 x: record cache-hit status in HTTP recorder object 2026-07-29 23:02:18 +08:00
ginuerzh 3157436140 x: add Redis backend for cache
Adds a RedisCache config block and redisCache implementation for the
generic cache.Cache interface, enabling distributed HTTP response
caching across gost instances.

go-redis/redis/v8 was already a dependency (used by recorder and
internal loader) — no new imports needed.
2026-07-29 22:39:19 +08:00
ginuerzh 0ab5363f60 x: distinguish client vs server errors in service handler loop
The service Serve loop counted every handler error — including client-side
disconnects (io.EOF, net.ErrClosed, context cancellation) and policy
decisions (rate limiting, bypass) — as server handler errors, inflating
the error counter and log noise. isServerError() now gates the error-level
log, error counter, and stats increment; non-server errors are logged at
debug level only.
2026-07-29 21:44:12 +08:00
ginuerzh 88670baeae x: don't overwrite ro.Host when TLS SNI is empty
The previous empty-SNI fix clobbered ro.Host with the listener's local
address. For handlers that set ro.Host before HandleTLS (SOCKS5 via the
CONNECT address, SS via target), that destroyed the real destination used
for recording and bypass. Now ro.Host is only set when SNI is present;
the caller's pre-set value is left intact otherwise.
2026-07-29 21:42:56 +08:00
ginuerzh 226420a622 x: allow empty TLS SNI in sniffing handlers
Clients connecting to HTTPS by raw IP (e.g. https://1.1.1.1) don't
send SNI per RFC 6066. Both HandleTLS methods errored immediately
on empty SNI, closing the connection. Now they fall through to the
normal routing path — hop selection in the forwarder, or the caller's
dial closure in the sniffing sniffer (e.g. HTTP CONNECT) — which
handles the empty host gracefully or fails naturally.
2026-07-28 23:52:29 +08:00
ginuerzh 3cd4fad4bf x: close forwarder hop probe goroutines on service shutdown
Same leak class as the previous chainGroup fix: a forwarder built from
a hopGroup or inline nodes starts probe goroutines at parse time, but
nothing closed them — every SIGHUP reload leaked them permanently.

- parseForwarder returns an io.Closer for service-owned hops (hop
  group or inline nodes); registry-owned named hops return nil since
  the registry closes them on unregister
- ParseService registers the closer via ClosersOption and closes it
  when construction fails partway
- hopGroup.Close no longer closes the underlying hops: they come from
  the HopRegistry and are shared with other referents; closing them
  here would kill shared state
2026-07-28 23:45:40 +08:00
ginuerzh 291fa48fae x: add -race test coverage for chainGroup matcher/probe/close paths
Adds tests for the new chainGroup code paths the matcher+probe review flagged
as untested: matcher filtering in selectChain, probe goroutine cleanup on
Close(), and probe marking the entry marker on cmd failure. All run clean under
`CGO_ENABLED=1 go test -race`.
2026-07-28 23:36:08 +08:00
ginuerzh 6e9ff675b5 x: extract duplicated cmd-probe logic into probe.RunCmdProbe
chainGroup and hopGroup had verbatim copies of the cmd-probe block (timeout
default, CmdProber run, marker Mark/Reset, log). Move it to
internal/probe.RunCmdProbe(cfg, marker, log) so both call sites share one
implementation (cleanup5 from the chainGroup review).
2026-07-28 23:31:15 +08:00
ginuerzh 506edffc93 x: skip eligible allocation when no matchers; always run FailFilter
In selectChain: precompute whether any entry has a matcher and, when none do,
pass entries straight to the selector instead of allocating a new slice on every
Route() call. Drop the single-eligible-entry shortcut so the selector's
FailFilter always runs and can exclude probe-failed chains (design6).

Combined fix for efficiency4 (hot-path allocation) and design6 (single-match
bypass of FailFilter/probe markers) from the chainGroup review.
2026-07-28 23:26:54 +08:00
ginuerzh 6248d4c0f8 x: derive probe goroutine context from lifecycle ctx (bug2)
probeEntry used context.Background() for Route/Dial, so an in-flight probe
could not be cancelled by Close()/reload. Thread the lifecycle ctx through
probeEntry so a stuck route aborts when the group is torn down.

Also promote mapstructure to a direct dependency (now imported by config.go).
2026-07-28 23:25:28 +08:00
ginuerzh 50f4a6b096 x: close chainGroup probe goroutines on service shutdown
chainGroup.Close() existed but was never called: the group is wrapped
in a chain.Router held by the listener/handler, and service.Close()
only closed the handler, observer, and listener. Every SIGHUP reload
leaked the old config's probe goroutines permanently.

Wire the close path end to end:
- Router.Close() closes its chain when it implements io.Closer
- service ClosersOption registers io.Closers called on shutdown
- ParseService passes both routers via ClosersOption, and closes
  them on construction failure so probes started early don't leak
2026-07-28 23:14:27 +08:00
ginuerzhandClaude 30ba6d64a5 x: add matcher + probe to chainGroup with independent entry marker
chainGroup now selects chains in two stages: a matcher filter (by host/network)
builds an eligible pool, then the group selector applies FailFilter + strategy.
Each entry carries its own failure marker so chain-level probe marking is
independent of node-level failCodes markers (probe Reset() can't undo a node
failCodes Mark()).

Config gains ChainGroupEntry (chain/matcher/probe) replacing the old []string,
with a mapstructure decode hook preserving backward-compatible "chains: [a, b]"
string lists and time.Duration string parsing. Probe goroutines run per-entry
and mark the entry on failure.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:20:34 +08:00
ginuerzh 72db96d4cd x: add hopGroup for conditional hop-level selection with probe/failCodes marker separation
hopGroup wraps named hops with per-entry matcher + probe, implementing
hop.Hop for drop-in compatibility with the existing Forwarder.Forward().

Core design: hopEntry.marker (probe, hop-level) is independent of
node.Marker() (failCodes, node-level) so probe Reset() cannot undo a
failCodes Mark() — the two mechanisms no longer conflict on the single
chain.Node marker.

- hop/group.go: HopEntry + hopGroup + probe infrastructure
- config/config.go: ForwardHopGroupConfig, ForwardHopConfig types
- config/parsing/service/parse.go: parseHopGroup, HopGroup > Hop > Name > Nodes precedence
- config/parsing/node/parse.go: export parseProbeConfig → ParseProbeConfig
2026-07-27 22:08:34 +08:00
ginuerzh 8edf82af9b hop: fix backup-priority interaction — primary beats backup regardless of priority
Stage 3 shortcut now gates on isBackupNode(maxPriNode) instead of
hasBackup — the old check disabled priority short-circuit whenever any
backup existed anywhere in the pool, even when the winning node was a
primary.

Stage 4 tier loop gains hasAnyPrimary scan + backup-tier skip — a
lower-priority primary always beats a higher-priority backup tier.
Nil-selector path now returns the first node of the first non-skipped
tier instead of unconditionally returning maxPriNode.
2026-07-27 22:06:37 +08:00
ginuerzh 839f2a280b chore: bump deps (tls-dissector v0.3.1, quic-go v0.60.0, webtransport-go v0.11.1) 2026-07-26 22:58:52 +08:00
ginuerzh c23a2f1e53 fix(redirect/tcp): sniffingFallback with empty SNI falls through to raw forwarding
When sniffing detects TLS but the ClientHello has no SNI, HandleTLS
errors with 'tls: sni is empty, closing connection'. Previously this
error was returned unconditionally even with sniffingFallback enabled.

Now, bytes consumed by the sniffer are captured via io.TeeReader and
replayed through io.MultiReader when sniffingFallback is true, allowing
the connection to fall through to raw forwarding to the original dstAddr.
2026-07-26 22:27:44 +08:00
ginuerzh 0ffc000d0d fix(forwarder/sniffer): update ho.recorderObject after ro copy to prevent nil HTTP panic
The HandleHTTP ro clone (486a776) only updated the local ro variable, but
dial() reads ho.recorderObject directly, getting the original with nil HTTP
field, causing nil pointer dereference at ro.HTTP.StatusCode access.
2026-07-26 22:09:55 +08:00
ginuerzh 486a77665d fix: prevent double-recording when HTTP response cache is enabled
The cache-hit path (serveCachedResponse) recorded on the same HandlerRecorderObject
as the calling handler's deferred Record(), producing two records per request.
Copy ro at HandleHTTP entry so the sniffer's internal recording stays isolated
from the caller's defer. Also zero ro.Time when the sniffer takes over, so the
handler's defer Record() skips the now-redundant connection-level record.

Fixes 10 files across sniffer and all 8 handlers that support sniffed HTTP forwarding.
2026-07-26 20:11:36 +08:00
ginuerzh db3c7a375c chore: update swagger spec 2026-07-26 17:58:12 +08:00
ginuerzh 9aab47bcd2 x/api: add cache CRUD web API endpoints 2026-07-26 17:57:11 +08:00
ginuerzh 3474501784 x: add generic cache component and HTTP response caching
Implements the cache.Cache interface with in-memory backend (TTL,
maxSize/maxBytes bounds, oldest/LRU eviction, background cleanup).
Adds httpcache layer for HTTP response policy (per-status TTL,
serve-stale, method/body filtering). Wires into config parser,
loader, registry, and all four sniffing handlers (forward/local,
forward/remote, sni, http) so HTTP responses are cached before
dialing upstream.

Bumps core dependency to v0.6.0 for the new cache.Cache interface.
2026-07-26 17:26:00 +08:00
ginuerzh b92c1b130a fix(dialer): propagate request ctx to QUIC OpenStreamSync and align keepalive/idle defaults
The issue #777 analysis was written against v3.2.4; commits 5484717 and
9fab332 already fixed the global mutex stall, the no-timeout OpenStreamSync,
and the missing IsClosed reuse check. This addresses the remaining real
problems:

- P1 (HIGH): quicDialer.Dial received the Router's 15s-timeout ctx but
  GetConn built its own context.Background()+30s, dropping request
  cancellation/deadline. Thread ctx into GetConn so OpenStreamSync honors
  the upstream deadline. Mirrored to the icmp dialer (identical structure).
- P2 (MEDIUM): listener QUIC keepalive defaulted OFF while the dialer
  defaulted ON, so a server behind NAT without an explicit keepalive=true
  could let its return-path mapping expire. Make the listener default ON
  like the dialer.
- P3 (LOW): set an explicit MaxIdleTimeout default (90s) on both dialer
  and listener so dead sessions are detected sooner; still overridable via
  maxIdleTimeout=.

Docs updated to mark the already-fixed root causes and rename the remaining
items P1-P3.

Fixes go-gost/gost#777
2026-07-26 12:51:04 +08:00
ginuerzh 431fe759dc x: matcher.bodySize defaults to DefaultMatcherBodySize (1MB) when unset
bodySize=0 now uses DefaultMatcherBodySize (1MB) instead of disabling
body reading, so BodyRegexp/BodyJSON matchers work out of the box.
MaxMatcherBodySize raised to 10MB. bodySize=-1 opt-out removed
(no real use case).
2026-07-25 21:46:48 +08:00
ginuerzh 4d15ca54af fix: rtcp listener tracks server-negotiated bind address for prefix fallback
The rtcp listener now stores the address returned by Router.Bind() separately
from the requested bind address. This allows callers to distinguish between
the requested prefix (may be rejected by the relay) and the actual server-
assigned address (used for the entrypoint URL).
2026-07-25 18:47:16 +08:00
ginuerzh 6096be44af Merge branch 'master' of github.com:go-gost/x 2026-07-25 15:58:30 +08:00
ginuerzh bf6f7713d8 chore: bump github.com/go-gost/core to v0.5.5 2026-07-25 15:55:17 +08:00
ginuerzh 6a44b6f018 fix(dialer): don't force pure-Go DNS resolver on Android 2026-07-25 15:44:21 +08:00
ginuerzh 641536de3d docs: add fail-codes design plan 2026-07-25 14:36:59 +08:00
ginuerzh edd6e5f3f2 x: add failCodes support for HTTP status-based node fail marking 2026-07-25 14:36:46 +08:00
ginuerzh 88b03e0721 x: add numeric comparison operators (gt/ge/lt/le/eq/ne) to Header/Query/BodyJSON matchers
Adds a 3-parameter form to Header(), Query(), and BodyJSON() matchers
for numeric comparisons: Header("Content-Length", "gt", "1024"). Extracts
the duplicated comparison setup and dispatch into shared compareOp and
parseCompareParams helpers. Includes tests for all six operators across
header, query, and body JSON matchers.
2026-07-25 13:43:17 +08:00
ginuerzh 7a190938aa chore: go get -u all
- tls-dissector v0.2.0 → v0.3.0
- golang.org/x/crypto v0.53.0 → v0.54.0
- golang.org/x/net v0.56.0 → v0.57.0
- golang.org/x/sys v0.46.0 → v0.47.0
- golang.org/x/text v0.38.0 → v0.40.0
- google.golang.org/grpc v1.79.3 → v1.82.1
- oTel v1.39.0 → v1.43.0
- gonum v0.16.0 → v0.17.0
2026-07-24 23:11:58 +08:00
ginuerzh bc06c7f5da x: wire MetadataFeature (relay v0.7.0) for tunnel recording mode negotiation
Tunnel connectors send record.mode via MetadataFeature in relay requests.
The handler parses MetadataFeature, stores on tunnel during bind, and
propagates to entrypoint services where recorder context applies it
(per-request Gost-Record header still takes precedence).

- connector/tunnel: send record.mode MetadataFeature in Connect/Bind
- connector/tunnel: parse record.mode from metadata config
- handler/tunnel: parse MetadataFeature from relay request features
- handler/tunnel: pass connMetadata through handleBind → pool.SetMetadata
- handler/tunnel: SetMetadata/GetMetadata on ConnectorPool + Tunnel
- handler/tunnel/entrypointsvc: apply pool metadata to recorder context
- deps: bump relay v0.6.2 → v0.7.0
- sniffer_sse.go: whitespace gofmt fixes
2026-07-24 22:19:41 +08:00
ginuerzh d0287998cf feat: record selected node name in HandlerRecorderObject.Node
Populate the existing Node field on HandlerRecorderObject after every
hop.Select() call in forwarder code paths. Previously the field was
never set, though it was already consumed by tunnel entrypoint HTTP
handler (ephttp.go:145) and tested for round-trip in recorder tests.
2026-07-24 14:20:10 +08:00
ginuerzh 8602043e8d x: add BodyJSON matcher and json: rewrite prefix
- routing: add BodyJSON(path, regexp) matcher using gjson for JSON path
  field lookup instead of regex against raw body bytes
- config: detect 'json:<path>[=regex]' prefix in Match field for
  rewriteRequestBody/rewriteResponseBody, using gjson query + sjson set
- forwarder: add shouldApply() helper for rewrite type matching; JSON
  rewrites match only application/json content type
2026-07-24 12:28:08 +08:00
ginuerzh 7952b4f55d x: add Gost-Record header for client-controlled recording level
Supports three modes via Gost-Record request header:
- (absent)/full: full recording with body (existing behavior)
- headers: metadata only, skip body/payload capture
- off: no recording at all

Header is stripped before forwarding. Per-request granularity
via HandlerRecorderObject.RecordMode field propagating through
the ro clone chain to all sub-paths (WS frames, H2, sniffing).
2026-07-17 15:39:07 +08:00
ginuerzh 7c44ba8125 hop: inline priority tracking and backup detection into Select scan
Collapse the separate anyBackupNode scan and partitionByPriority allocation
into the single candidate loop. Replace the old short-circuit + tiered logic
with four explicit stages:

  1. Single-pass candidate scan — priority stats + backup detection
  2. Priority short-circuit — top node wins when strictly higher, no backups
  3. Uniform fast path — skip sort when all priorities equal (common case)
  4. Tiered fallthrough — sort once, feed each priority tier to selector

Removes two helper functions and an extra O(n) allocation. Fixes hasBackup
to use mdutil.GetBool for consistency with BackupFilter.
2026-07-17 14:50:19 +08:00
ginuerzh 58ab312950 hop: scope selector to highest-priority tier with fallthrough
When nodes match with different priorities (more-specific vs broader
matchers), select only within the highest-priority tier first.
Round-robin / FailFilter still apply within a tier for load balancing.
If the top tier has no surviving node (all dead), fall through to the
next tier, preserving fallback semantics.
2026-07-17 14:08:09 +08:00
ginuerzh f5127f010c fix(forwarder): pass Probe config to node in parseForwarder
ForwardNodeConfig has a Probe field but parseForwarder was missing it
when converting to NodeConfig, silently dropping probe configuration
for forwarder nodes. cmd probe works regardless (no transport needed),
tcp/http probes are limited to the default tcp dialer (plain TCP).
2026-07-16 23:15:16 +08:00
ginuerzh 2edc6d045a chore: bump core to v0.5.4 (node liveness probe types) 2026-07-16 21:22:33 +08:00
ginuerzh 7b088f8f42 feat(socks5): add Tor RESOLVE/RESOLVE_PTR extension command support (gost#524)
Adds SOCKS5 handler and connector support for Tor-specific RESOLVE (0xF0)
and RESOLVE_PTR (0xF1) commands, enabling tools like torsocks and
tor-resolve to work through GOST when forwarding to a Tor SOCKS5 proxy.

- Define CmdResolve (0xF0) and CmdResolvePTR (0xF1) in x/internal/util/socks
- Add ContextWithSocks5Cmd/Socks5CmdFromContext for per-request command override
- Connector reads context command, sends it upstream, returns resolveConn
- resolveConn wraps net.Conn and exposes resolved addr via Metadata()
- Handler dispatches 0xF0/0xF1 to handleResolve/handleResolvePTR
- Gate behind enableTor metadata (tor=true), reject with CmdUnsupported by default
- 9 unit tests across ctx/, connector/, handler/ packages
2026-07-16 21:10:13 +08:00
ginuerzh f59eac64d9 probe: add cmd probe type with cross-platform shell support
- Add CmdProber to x/internal/probe/cmd.go with shellCmd() for Windows/Unix
- Add cmd probe dispatch in x/chain/node.go (early-return, no transport needed)
- Add Command field to config.ProbeConfig and parseProbeConfig mapping
- Add 5 CmdProber unit tests (success, failure, timeout, not-found, pipeline)
- Add 2 chain integration tests (cmd probe + cmd disabled)
2026-07-16 20:27:21 +08:00
ginuerzh 46fc054ad5 x: implement node-level liveness probe
- x/chain/node.go: StartNodeProbe goroutine — periodic TCP/HTTP probing
- x/internal/probe/probe.go: HTTPProber with status code validation
- x/selector/latency.go: LatencyFilter, LowestLatencyStrategy (generic)
- x/config: ProbeConfig, MaxLatency, parseProbeConfig, lowestlatency strategy
- x/hop/hop.go: Close() cascade to node.Close()
- x/chain/chain.go: Chain.Close() cascade to hop.Close()

Tests: 4 latency selector tests, 4 node probe tests, 4 HTTP prober tests,
1 hop failover integration test.
2026-07-16 20:27:21 +08:00
Denis Galeev c91a414d68 feat(metrics): add gost_service_status gauge (#116)
Expose a gost_service_status{host,state} gauge with the number of
services per state (ready/running/failed/closed), collected at scrape
time via a registered callback that aggregates over the service
registry. Lets alerting catch dead/failed services without scraping the
config API.

The callback is registered via RegisterServiceStatusFunc and wired by
the app against registry.ServiceRegistry(). The collector registers on
the custom defaultRegistry (which the metrics endpoint serves from),
not the global prometheus registry.
2026-07-16 20:26:53 +08:00
ginuerzh 84f83f6776 chore: remove obsolete utls fingerprint plan
The plan was either implemented or abandoned; the doc is no longer needed.
2026-07-13 23:17:55 +08:00
ginuerzh c16a2804e0 docs(bypass): fix inverted whitelist mode description
The whitelist comments described the opposite of actual behavior: in
whitelist mode matching addresses go through the proxy chain and others
bypass, not the other way around.
2026-07-13 23:17:32 +08:00
ginuerzh 255f9ff6ec chore: add .gitignore for build/test artifacts and local state
Excludes Go test binaries (*.test), coverage output, generated certs,
stray Node tooling, and index/IDE/agent dirs (.codegraph, .claude,
.remember) so they don't get committed.
2026-07-13 23:15:14 +08:00
ginuerzh c5c8f0903c feat(mux): make mux layer pluggable and add Yamux backend
Convert the concrete *mux.Session into a mux.Session interface backed by
smux and yamux, selected via the `mux.type` metadata key. Empty/unspecified
defaults to "smux", so existing deployments are unchanged. Yamux's stream
window is exposed via `mux.maxStreamWindow`.

Also fix TestHandler_observeStats: the retry_after_observer_error subtest
hung because stats.Events() drains (IsUpdated is Swap(false)) and no fresh
event was generated for the recovery tick's fall-through flush. Inject a real
client2 event so the recovery tick has new data to flush.

Plan: docs/plans/2026-07-13-mux-pluggable-yamux.md
2026-07-13 23:12:27 +08:00
Denis Galeev b8f7c71910 feat(redirect): add sniffing.dialOriginalDst flag
When sniffing is enabled, the redirect handler dials the sniffed hostname (HTTP Host / TLS SNI). Add an opt-in metadata flag sniffing.dialOriginalDst that dials the original transparent-redirect destination address instead, while sniffing still runs for hostname-bypass matching and recording.
2026-07-11 21:15:26 +08:00
Denis Galeev fe394a8420 fix(redirect): check bypass on dstAddr before sniffing
IP/CIDR bypass rules were silently ignored when sniffing was enabled because the sniffer's internal bypass only matches sniffed hostnames. The post-sniffing dstAddr check was unreachable since HandleHTTP/HandleTLS return early.
2026-07-11 21:01:37 +08:00
ginuerzh d12663dfe3 fix(conn-limiter): add regression test for double-close guard
Verifies the atomic.Bool CAS in serverConn.Close() releases the limiter
slot exactly once, regardless of how many times Close() is called.
2026-07-11 20:23:32 +08:00
ginuerzh 108fd6a1f5 Merge pull request #113 from iproxy-online/fix/conn-limiter-double-decrement
fix(conn-limiter): release limiter slot once on Close
2026-07-11 20:11:55 +08:00
Denis Galeev f9fb5f443d fix(conn-limiter): release limiter slot once on Close
serverConn.Close() called limiter.Allow(-1) on every invocation. Close
is routinely called more than once (deferred + explicit teardown paths),
so each extra call leaked a limiter slot, progressively under-counting
the current connection count until the climiter cap admitted more
connections than configured.

Guard the release behind an atomic.Bool CAS so the slot is returned
exactly once, regardless of how many times Close is called.
2026-07-11 13:38:37 +03:00
318 changed files with 32159 additions and 2480 deletions
+23
View File
@@ -0,0 +1,23 @@
# Build & test artifacts
*.test
coverage.out
coverage*.out
coverage*.tmp
# Generated local TLS cert
cert.pem
# Stray Node tooling in a Go module
package.json
package-lock.json
# Index / IDE / agent state
.codegraph/
.claude/
.remember/
.memory/
.build/
__pycache__/
# Local test configs
play/
+6
View File
@@ -163,4 +163,10 @@ func Register(r *gin.Engine, opts *Options) {
config.POST("/rlimiters", createRateLimiter)
config.PUT("/rlimiters/:limiter", updateRateLimiter)
config.DELETE("/rlimiters/:limiter", deleteRateLimiter)
config.GET("/caches", getCacheList)
config.GET("/caches/:cache", getCache)
config.POST("/caches", createCache)
config.PUT("/caches/:cache", updateCache)
config.DELETE("/caches/:cache", deleteCache)
}
+29 -11
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-gost/core/observer/stats"
@@ -127,10 +129,11 @@ func getConfig(ctx *gin.Context) {
// swagger:parameters saveConfigRequest
type saveConfigRequest struct {
// output format, one of yaml|json, default is yaml.
// output format, one of yaml|json, default is derived from the file extension.
// in: query
Format string `form:"format" json:"format"`
// file path, default is gost.yaml|gost.json in current working directory.
// file path, default is the loaded config file, or gost.yaml|gost.json in
// the current working directory when no config file was loaded.
// in: query
Path string `form:"path" json:"path"`
}
@@ -155,16 +158,31 @@ func saveConfig(ctx *gin.Context) {
var req saveConfigRequest
ctx.ShouldBindQuery(&req)
file := "gost.yaml"
switch req.Format {
case "json":
file = "gost.json"
default:
req.Format = "yaml"
file := req.Path
if file == "" {
// Default to the config file that was loaded (via -C), falling back to
// the current working directory when the config came from elsewhere.
file = config.ConfigFile()
}
if req.Path != "" {
file = req.Path
if file == "" {
file = "gost.yaml"
if req.Format == "json" {
file = "gost.json"
}
}
switch strings.ToLower(req.Format) {
case "json":
req.Format = "json"
case "yaml":
req.Format = "yaml"
default:
// Derive the format from the file extension so a loaded JSON config
// is saved back as JSON (ReadFile forces format from extension).
if strings.ToLower(filepath.Ext(file)) == ".json" {
req.Format = "json"
} else {
req.Format = "yaml"
}
}
f, err := os.Create(file)
+273
View File
@@ -0,0 +1,273 @@
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
parser "github.com/go-gost/x/config/parsing/cache"
"github.com/go-gost/x/registry"
)
// swagger:parameters getCacheListRequest
type getCacheListRequest struct{}
// successful operation.
// swagger:response getCacheListResponse
type getCacheListResponse struct {
// in: body
Data cacheList
}
type cacheList struct {
Count int `json:"count"`
List []*config.CacheConfig `json:"list"`
}
func getCacheList(ctx *gin.Context) {
// swagger:route GET /config/caches Cache getCacheListRequest
//
// Get cache list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getCacheListResponse
var req getCacheListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Caches
ctx.JSON(http.StatusOK, Response{
Data: cacheList{
Count: len(list),
List: list,
},
})
}
// swagger:parameters getCacheRequest
type getCacheRequest struct {
// in: path
// required: true
Cache string `uri:"cache" json:"cache"`
}
// successful operation.
// swagger:response getCacheResponse
type getCacheResponse struct {
// in: body
Data *config.CacheConfig
}
func getCache(ctx *gin.Context) {
// swagger:route GET /config/caches/{cache} Cache getCacheRequest
//
// Get cache.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getCacheResponse
var req getCacheRequest
ctx.ShouldBindUri(&req)
var resp getCacheResponse
for _, c := range config.Global().Caches {
if c == nil {
continue
}
if c.Name == req.Cache {
resp.Data = c
break
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createCacheRequest
type createCacheRequest struct {
// in: body
Data config.CacheConfig `json:"data"`
}
// successful operation.
// swagger:response createCacheResponse
type createCacheResponse struct {
Data Response
}
func createCache(ctx *gin.Context) {
// swagger:route POST /config/caches Cache createCacheRequest
//
// Create a new cache, the name of the cache must be unique in cache list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: createCacheResponse
var req createCacheRequest
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Data.Name)
if name == "" {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache name is required"))
return
}
req.Data.Name = name
if registry.CacheRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
return
}
v := parser.ParseCache(&req.Data)
if v == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
return
}
if err := registry.CacheRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
c.Caches = append(c.Caches, &req.Data)
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters updateCacheRequest
type updateCacheRequest struct {
// in: path
// required: true
Cache string `uri:"cache" json:"cache"`
// in: body
Data config.CacheConfig `json:"data"`
}
// successful operation.
// swagger:response updateCacheResponse
type updateCacheResponse struct {
Data Response
}
func updateCache(ctx *gin.Context) {
// swagger:route PUT /config/caches/{cache} Cache updateCacheRequest
//
// Update cache by name, the cache must already exist.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: updateCacheResponse
var req updateCacheRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Cache)
if !registry.CacheRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
return
}
req.Data.Name = name
v := parser.ParseCache(&req.Data)
if v == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
return
}
registry.CacheRegistry().Unregister(name)
if err := registry.CacheRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
for i := range c.Caches {
if c.Caches[i].Name == name {
c.Caches[i] = &req.Data
break
}
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters deleteCacheRequest
type deleteCacheRequest struct {
// in: path
// required: true
Cache string `uri:"cache" json:"cache"`
}
// successful operation.
// swagger:response deleteCacheResponse
type deleteCacheResponse struct {
Data Response
}
func deleteCache(ctx *gin.Context) {
// swagger:route DELETE /config/caches/{cache} Cache deleteCacheRequest
//
// Delete cache by name.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: deleteCacheResponse
var req deleteCacheRequest
ctx.ShouldBindUri(&req)
name := strings.TrimSpace(req.Cache)
if !registry.CacheRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
return
}
registry.CacheRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
caches := c.Caches
c.Caches = nil
for _, s := range caches {
if s.Name == name {
continue
}
c.Caches = append(c.Caches, s)
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
+633 -437
View File
File diff suppressed because it is too large Load Diff
+40 -46
View File
@@ -12,8 +12,8 @@
// Matching modes:
// - Blacklist (whitelist=false): matching addresses bypass the proxy.
// This is the default.
// - Whitelist (whitelist=true): only matching addresses bypass the proxy;
// all other addresses go through the proxy chain.
// - Whitelist (whitelist=true): matching addresses go through the proxy
// chain; all other addresses bypass the proxy.
package bypass
import (
@@ -44,13 +44,13 @@ var ErrBypass = errors.New("bypass")
type options struct {
// whitelist toggles between blacklist and whitelist mode.
// When false (default): matching addresses bypass the proxy.
// When true: only matching addresses bypass the proxy.
// When true: matching addresses use the proxy and all others bypass it.
whitelist bool
// network restricts this bypass to a specific network protocol.
// When set, the incoming network must match before address matchers
// are evaluated. Recognized values include "tcp" and "udp".
// An empty value disables the network check.
// When set, the incoming network must match as part of the base rule.
// Recognized values include "tcp" and "udp". An empty value
// disables the network check.
network string
// matchers holds static patterns provided at construction time
@@ -80,8 +80,8 @@ type options struct {
type Option func(opts *options)
// WhitelistOption sets whether the bypass operates in whitelist mode.
// In whitelist mode, only matching addresses bypass the proxy;
// all others go through the proxy chain.
// In whitelist mode, matching addresses go through the proxy chain;
// all others bypass the proxy.
func WhitelistOption(whitelist bool) Option {
return func(opts *options) {
opts.whitelist = whitelist
@@ -89,8 +89,8 @@ func WhitelistOption(whitelist bool) Option {
}
// NetworkOption restricts this bypass to a specific network protocol.
// When set, Contains returns false when the incoming network does not
// match, regardless of address matchers. Accepts values like "tcp" or "udp".
// Network and address predicates are combined before whitelist mode is
// applied to the resulting match. Accepts values like "tcp" or "udp".
func NetworkOption(network string) Option {
return func(opts *options) {
opts.network = network
@@ -214,8 +214,8 @@ type localBypass struct {
// NewBypass creates and initializes a local Bypass instance.
//
// In blacklist mode (the default), addresses matching any pattern bypass the
// proxy. In whitelist mode (set via WhitelistOption(true)), only matching
// addresses bypass the proxy and all others go through the chain.
// proxy. In whitelist mode (set via WhitelistOption(true)), matching addresses
// go through the proxy chain and all others bypass the proxy.
//
// If a reload period is configured, patterns from external loaders are
// refreshed automatically in the background.
@@ -427,43 +427,28 @@ func (p *localBypass) Contains(ctx context.Context, network, addr string, opts .
return decision == decisionBypass
}
// decide returns the bypass decision for the given address, applying the
// whitelist or blacklist mode to the pattern match result.
// decide returns the bypass decision for the given address. Network and
// address predicates form one base match, then whitelist mode is applied once
// to that combined result.
func (p *localBypass) decide(network string, addr string) bypassDecision {
p.mu.RLock()
defer p.mu.RUnlock()
if p.options.network != "" && p.options.network != network {
matched := p.options.network == "" || p.options.network == network
if p.patterns != nil {
matched = matched && p.patterns.matchAny(addr)
} else if p.options.network == "" {
// A bypass with no predicates has no base match.
matched = false
}
// Whitelist is a top-level negation of the normal blacklist decision:
// blacklist mode proxies a failed combined match, while whitelist mode
// proxies a successful combined match.
if matched == p.options.whitelist {
return decisionProxy
}
if p.patterns == nil {
if p.options.network != "" {
if p.options.whitelist {
return decisionProxy
}
return decisionBypass
}
return decisionProxy
}
matched := p.patterns.matchAny(addr)
if p.options.whitelist {
// Whitelist mode: the pattern set specifies addresses that MUST use the proxy.
// Matched addresses go through the proxy; unmatched addresses bypass.
if matched {
return decisionProxy
}
return decisionBypass
}
// Blacklist mode: the pattern set specifies addresses that should bypass the proxy.
// Matched addresses bypass; unmatched addresses go through the proxy.
if matched {
return decisionBypass
}
return decisionProxy
return decisionBypass
}
func (p *localBypass) IsWhitelist() bool {
@@ -552,8 +537,17 @@ func (p *bypassGroup) evaluate(ctx context.Context, network, addr string, opts .
return decisionProxy
}
// IsWhitelist always returns false for a group, since the group may contain
// a mix of whitelist and blacklist rules.
// IsWhitelist reports whether the group acts as a pure whitelist: every
// member is a whitelist rule. A mixed group is neither pure whitelist nor
// pure blacklist, so it reports false.
func (p *bypassGroup) IsWhitelist() bool {
return false
if len(p.bypasses) == 0 {
return false
}
for _, bp := range p.bypasses {
if !bp.IsWhitelist() {
return false
}
}
return true
}
+69 -12
View File
@@ -667,6 +667,20 @@ func TestBypassGroup_IsWhitelist(t *testing.T) {
assert.False(t, g.IsWhitelist())
}
// gost#899: in whitelist mode a domain-only allowlist must not reject the
// destination IP before sniffing runs (the sniffer checks the sniffed host).
func TestBypassGroup_WhitelistDomainNoMatch(t *testing.T) {
g := BypassGroup(
NewBypass(
WhitelistOption(true),
MatchersOption([]string{"example.com"}),
LoggerOption(xlogger.Nop()),
),
)
assert.True(t, g.Contains(context.Background(), "tcp", "93.184.216.34:443"))
assert.False(t, g.Contains(context.Background(), "tcp", "example.com:443"))
}
// --- NewBypass reload error in background ---
func TestNewBypass_ReloadError(t *testing.T) {
@@ -751,7 +765,40 @@ func TestContains_NetworkMatch_WhitelistBypass(t *testing.T) {
assert.True(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
}
func TestContains_NetworkMismatch_ReturnsFalse(t *testing.T) {
func TestContains_WhitelistNegatesCombinedMatch(t *testing.T) {
tests := []struct {
name string
whitelist bool
network string
addr string
wantProxy bool
}{
{name: "blacklist both match", network: "tcp", addr: "192.168.1.1", wantProxy: false},
{name: "blacklist network fails", network: "udp", addr: "192.168.1.1", wantProxy: true},
{name: "blacklist matcher fails", network: "tcp", addr: "10.0.0.1", wantProxy: true},
{name: "blacklist both fail", network: "udp", addr: "10.0.0.1", wantProxy: true},
{name: "whitelist both match", whitelist: true, network: "tcp", addr: "192.168.1.1", wantProxy: true},
{name: "whitelist network fails", whitelist: true, network: "udp", addr: "192.168.1.1", wantProxy: false},
{name: "whitelist matcher fails", whitelist: true, network: "tcp", addr: "10.0.0.1", wantProxy: false},
{name: "whitelist both fail", whitelist: true, network: "udp", addr: "10.0.0.1", wantProxy: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(tt.whitelist),
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
)
defer b.Close()
gotProxy := !b.Contains(context.Background(), tt.network, tt.addr)
assert.Equal(t, tt.wantProxy, gotProxy)
})
}
}
func TestContains_NetworkMismatch_BlacklistReturnsFalse(t *testing.T) {
b := newSyncedBypass(
NetworkOption("tcp"),
MatchersOption([]string{"192.168.1.1"}),
@@ -775,7 +822,7 @@ func TestContains_NetworkMismatch_EmptyAddr(t *testing.T) {
assert.False(t, b.Contains(context.Background(), "tcp", ""))
}
func TestContains_NetworkMismatch_WhitelistReturnsFalse(t *testing.T) {
func TestContains_NetworkMismatch_WhitelistBypasses(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("tcp"),
@@ -783,9 +830,9 @@ func TestContains_NetworkMismatch_WhitelistReturnsFalse(t *testing.T) {
)
defer b.Close()
// Network does not match → bypass returns false even in whitelist mode
assert.False(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
// Network fails the combined predicate, then whitelist mode negates it.
assert.True(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestContains_PatternSetPassesThroughNetworkFilter(t *testing.T) {
@@ -832,15 +879,15 @@ func TestNetworkOnly_Whitelist_ProxiesMatchingNetwork(t *testing.T) {
assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1"))
}
func TestNetworkOnly_Whitelist_NonMatchingNotAffected(t *testing.T) {
func TestNetworkOnly_Whitelist_NonMatchingBypasses(t *testing.T) {
b := newSyncedBypass(
WhitelistOption(true),
NetworkOption("tcp"),
)
defer b.Close()
// network: tcp 是作用域限定,非 TCP 流量不受影响
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
// The network predicate fails, then whitelist mode negates it.
assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestNetworkOnly_NoNetwork_BehavesAsBefore(t *testing.T) {
@@ -852,6 +899,16 @@ func TestNetworkOnly_NoNetwork_BehavesAsBefore(t *testing.T) {
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestNetworkOnly_NoNetwork_WhitelistBypassesAll(t *testing.T) {
b := newSyncedBypass(WhitelistOption(true))
defer b.Close()
// No predicates → base match is false, whitelist negates it → bypass all.
// This flipped from proxy-all before #129; lock in the new behavior.
assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1"))
assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
}
func TestNetworkOnly_EmptyAddr_ReturnsFalse(t *testing.T) {
b := newSyncedBypass(NetworkOption("tcp"))
defer b.Close()
@@ -1022,9 +1079,9 @@ func TestPatternSet_MatchAny_Empty(t *testing.T) {
func TestPatternSet_MatchAny_MixedTypes(t *testing.T) {
ps := classifyPatterns([]string{
"192.168.1.1", // address
"10.0.0.0/8", // CIDR
"*.example.com", // wildcard
"192.168.1.1", // address
"10.0.0.0/8", // CIDR
"*.example.com", // wildcard
"172.16.0.1-172.16.0.255", // IP range
}, xlogger.Nop())
assert.True(t, ps.matchAny("192.168.1.1"))
@@ -1129,7 +1186,7 @@ func TestDecide_NilPatterns(t *testing.T) {
func TestEvaluate_AllBlacklistAllMatch(t *testing.T) {
g := BypassGroup(alwaysContains{}, alwaysContains{}).(*bypassGroup)
assert.Equal(t, decisionBypass, g.evaluate(context.Background(), "tcp", "any", ))
assert.Equal(t, decisionBypass, g.evaluate(context.Background(), "tcp", "any"))
}
func TestEvaluate_BlacklistNoneMatch(t *testing.T) {
+241
View File
@@ -0,0 +1,241 @@
// Package cache provides in-memory implementations of the core cache.Cache
// interface.
package cache
import (
"context"
"sync"
"time"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/logger"
)
// EvictionPolicy selects which entry to remove when the cache is full.
type EvictionPolicy string
const (
// EvictOldest removes the entry inserted least recently (by insertion time).
EvictOldest EvictionPolicy = "oldest"
// EvictLRU removes the entry accessed least recently.
EvictLRU EvictionPolicy = "lru"
)
// Options configures a memory cache.
type Options struct {
// DefaultTTL is applied to entries stored without an explicit TTL.
// A non-positive value means entries never expire by default.
DefaultTTL time.Duration
// MaxSize bounds the number of entries. 0 means unbounded.
MaxSize int
// MaxBytes bounds the total size of stored data in bytes. 0 means unbounded.
MaxBytes int64
// CleanupInterval sets the period for the background expiry sweep.
// A non-positive value disables the background goroutine.
CleanupInterval time.Duration
// Eviction is the policy used when MaxSize or MaxBytes is exceeded.
// Defaults to EvictOldest.
Eviction EvictionPolicy
// Logger is used for debug logging. Optional.
Logger logger.Logger
}
type entry struct {
data []byte
expiration time.Time // zero = never expires
created time.Time
lastAccess time.Time
}
// memoryCache is a concurrency-safe in-memory cache.Cache implementation.
type memoryCache struct {
mu sync.RWMutex
entries map[string]*entry
currentBytes int64
defaultTTL time.Duration
maxSize int
maxBytes int64
eviction EvictionPolicy
logger logger.Logger
stopCleanup chan struct{}
stopOnce sync.Once
}
// NewMemoryCache creates an in-memory cache. If opts.CleanupInterval is
// positive, a background goroutine periodically removes expired entries; it is
// stopped by Close.
func NewMemoryCache(opts Options) cache.Cache {
eviction := opts.Eviction
if eviction != EvictLRU {
eviction = EvictOldest
}
c := &memoryCache{
entries: make(map[string]*entry),
defaultTTL: opts.DefaultTTL,
maxSize: opts.MaxSize,
maxBytes: opts.MaxBytes,
eviction: eviction,
logger: opts.Logger,
stopCleanup: make(chan struct{}),
}
if opts.CleanupInterval > 0 {
go c.cleanupLoop(opts.CleanupInterval)
}
return c
}
// Get returns the entry for key. An expired-but-present entry is returned with
// cache.ErrNotFound reserved for absent keys, so callers can serve stale.
func (c *memoryCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
c.mu.Lock()
e, ok := c.entries[key]
if ok {
e.lastAccess = time.Now()
}
c.mu.Unlock()
if !ok {
return nil, cache.ErrNotFound
}
// Return a copy so callers can't mutate the stored buffer.
data := make([]byte, len(e.data))
copy(data, e.data)
return &cache.Entry{Data: data, Expiration: e.expiration}, nil
}
// Set stores data under key. A non-positive TTL uses the cache default; a
// non-positive default means the entry never expires.
func (c *memoryCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
var so cache.SetOptions
for _, opt := range opts {
opt(&so)
}
ttl := so.TTL
if ttl <= 0 {
ttl = c.defaultTTL
}
var expiration time.Time
if ttl > 0 {
expiration = time.Now().Add(ttl)
}
cp := make([]byte, len(data))
copy(cp, data)
now := time.Now()
e := &entry{
data: cp,
expiration: expiration,
created: now,
lastAccess: now,
}
c.mu.Lock()
defer c.mu.Unlock()
// Replace: account for the size delta of an existing key.
if old, ok := c.entries[key]; ok {
c.currentBytes -= int64(len(old.data))
}
c.entries[key] = e
c.currentBytes += int64(len(cp))
c.evictLocked()
return nil
}
// Delete removes key from the cache.
func (c *memoryCache) Delete(ctx context.Context, key string) error {
c.mu.Lock()
if e, ok := c.entries[key]; ok {
c.currentBytes -= int64(len(e.data))
delete(c.entries, key)
}
c.mu.Unlock()
return nil
}
// Close stops the background cleanup goroutine. Safe to call more than once.
// Implements io.Closer so the registry stops the goroutine on hot reload.
func (c *memoryCache) Close() error {
c.stopOnce.Do(func() { close(c.stopCleanup) })
return nil
}
func (c *memoryCache) cleanupLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.stopCleanup:
return
case <-ticker.C:
c.mu.Lock()
now := time.Now()
for k, e := range c.entries {
if !e.expiration.IsZero() && now.After(e.expiration) {
c.currentBytes -= int64(len(e.data))
delete(c.entries, k)
}
}
c.mu.Unlock()
}
}
}
// evictLocked enforces the size/byte bounds. It first drops expired entries,
// then evicts by policy until within bounds. Caller must hold c.mu.
func (c *memoryCache) evictLocked() {
if !c.overCapacityLocked() {
return
}
// First pass: drop expired entries — they're free wins.
now := time.Now()
for k, e := range c.entries {
if !e.expiration.IsZero() && now.After(e.expiration) {
c.currentBytes -= int64(len(e.data))
delete(c.entries, k)
}
}
// Second pass: evict by policy until within bounds.
for c.overCapacityLocked() && len(c.entries) > 0 {
var victim string
var victimTS time.Time
first := true
for k, e := range c.entries {
ts := e.created
if c.eviction == EvictLRU {
ts = e.lastAccess
}
if first || ts.Before(victimTS) {
victim = k
victimTS = ts
first = false
}
}
if e, ok := c.entries[victim]; ok {
c.currentBytes -= int64(len(e.data))
delete(c.entries, victim)
}
}
}
func (c *memoryCache) overCapacityLocked() bool {
if c.maxSize > 0 && len(c.entries) > c.maxSize {
return true
}
if c.maxBytes > 0 && c.currentBytes > c.maxBytes {
return true
}
return false
}
+205
View File
@@ -0,0 +1,205 @@
package cache
import (
"context"
"io"
"testing"
"time"
"github.com/go-gost/core/cache"
)
func TestSetGet(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
ctx := context.Background()
err := c.Set(ctx, "k", []byte("hello"))
if err != nil {
t.Fatalf("Set: %v", err)
}
e, err := c.Get(ctx, "k")
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(e.Data) != "hello" {
t.Fatalf("data = %q, want %q", e.Data, "hello")
}
if e.Expired() {
t.Fatal("fresh entry reported expired")
}
if e.Expiration.IsZero() {
t.Fatal("expiration is zero")
}
}
func TestGetNotFound(t *testing.T) {
c := NewMemoryCache(Options{})
_, err := c.Get(context.Background(), "nosuch")
if err != cache.ErrNotFound {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestGetExpiredStillReturned(t *testing.T) {
c := NewMemoryCache(Options{})
ctx := context.Background()
if err := c.Set(ctx, "k", []byte("data"), cache.WithTTL(1*time.Nanosecond)); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
e, err := c.Get(ctx, "k")
if err != nil {
t.Fatal("expired entry should still be returned for serve-stale")
}
if !e.Expired() {
t.Fatal("expected expired")
}
}
func TestWithTTLOverride(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
ctx := context.Background()
if err := c.Set(ctx, "k", []byte("x"), cache.WithTTL(50*time.Millisecond)); err != nil {
t.Fatal(err)
}
time.Sleep(60 * time.Millisecond)
e, _ := c.Get(ctx, "k")
if !e.Expired() {
t.Fatal("WithTTL should override default")
}
}
func TestDelete(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
ctx := context.Background()
c.Set(ctx, "k", []byte("x"))
if err := c.Delete(ctx, "k"); err != nil {
t.Fatal(err)
}
if _, err := c.Get(ctx, "k"); err != cache.ErrNotFound {
t.Fatalf("err = %v, want ErrNotFound after delete", err)
}
}
func TestDeleteAbsentKey(t *testing.T) {
c := NewMemoryCache(Options{})
if err := c.Delete(context.Background(), "nosuch"); err != nil {
t.Fatalf("Delete absent key: %v", err)
}
}
func TestNeverExpires(t *testing.T) {
c := NewMemoryCache(Options{}) // no default TTL
ctx := context.Background()
c.Set(ctx, "k", []byte("data"))
time.Sleep(10 * time.Millisecond)
e, _ := c.Get(ctx, "k")
if e.Expired() {
t.Fatal("entry with no TTL should never expire")
}
if e.TTL() != -1 {
t.Fatalf("TTL = %v, want -1", e.TTL())
}
}
func TestMaxSizeEviction(t *testing.T) {
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictOldest})
ctx := context.Background()
c.Set(ctx, "a", []byte("1"))
c.Set(ctx, "b", []byte("2"))
c.Set(ctx, "c", []byte("3")) // evicts oldest (a)
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
t.Fatal("a should have been evicted")
}
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
t.Fatal("b should remain")
}
if _, err := c.Get(ctx, "c"); err == cache.ErrNotFound {
t.Fatal("c should remain")
}
}
func TestMaxSizeLRU(t *testing.T) {
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictLRU})
ctx := context.Background()
c.Set(ctx, "a", []byte("1"))
c.Set(ctx, "b", []byte("2"))
c.Get(ctx, "a") // touch a
c.Set(ctx, "c", []byte("3")) // LRU-evicts b (a's lastAccess is newer)
if _, err := c.Get(ctx, "b"); err != cache.ErrNotFound {
t.Fatal("b should have been evicted (LRU)")
}
if _, err := c.Get(ctx, "a"); err == cache.ErrNotFound {
t.Fatal("a should remain (touched)")
}
}
func TestMaxBytesEviction(t *testing.T) {
c := NewMemoryCache(Options{MaxBytes: 4})
ctx := context.Background()
c.Set(ctx, "a", []byte{1, 2}) // 2 bytes, total=2
c.Set(ctx, "b", []byte{3, 4}) // 2 bytes, total=4
c.Set(ctx, "c", []byte{5}) // 1 byte, total=5 > 4 → evicts oldest (a)
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
t.Fatal("a should have been evicted (maxBytes)")
}
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
t.Fatal("b should remain")
}
}
func TestClose(t *testing.T) {
c := NewMemoryCache(Options{CleanupInterval: 10 * time.Millisecond})
closer := c.(io.Closer)
if err := closer.Close(); err != nil {
t.Fatal(err)
}
if err := closer.Close(); err != nil {
t.Fatal("second Close should be a no-op")
}
}
func TestCloseStopsBackgroundScan(t *testing.T) {
c := NewMemoryCache(Options{
DefaultTTL: 5 * time.Millisecond,
CleanupInterval: 10 * time.Millisecond,
})
ctx := context.Background()
c.Set(ctx, "k", []byte("data"))
time.Sleep(20 * time.Millisecond)
e, _ := c.Get(ctx, "k")
// Without Close, background scanner cleared expired entries. With Close
// before sleep, the entry stays. We test that Close means the goroutine
// stops and does NOT panic another background prune. Just exercising the
// code path.
if e.Expired() {
t.Log("expired (expected if scanner ran)")
}
c.(io.Closer).Close()
time.Sleep(30 * time.Millisecond) // goroutine should have stopped
// No panic = pass
}
func TestDataCopied(t *testing.T) {
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
ctx := context.Background()
data := []byte("mutable")
c.Set(ctx, "k", data)
data[0] = 'X'
e, _ := c.Get(ctx, "k")
if string(e.Data) != "mutable" {
t.Fatal("Get returns internal data, not a copy")
}
// Mutate Get's returned slice — original should be unchanged
e.Data[0] = 'Y'
e2, _ := c.Get(ctx, "k")
if string(e2.Data) != "mutable" {
t.Fatal("Get returns shared slice")
}
}
+119
View File
@@ -0,0 +1,119 @@
package cache
import (
"context"
"time"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/logger"
"github.com/go-redis/redis/v8"
)
// defaultKeyPrefix is prepended to cache keys when RedisOptions.KeyPrefix is empty.
const defaultKeyPrefix = "gost:cache:"
// RedisOptions configures a Redis cache.
type RedisOptions struct {
// Addr is the Redis server address (host:port).
Addr string
// DB is the Redis database number.
DB int
// Username for Redis authentication. Optional.
Username string
// Password for Redis authentication. Optional.
Password string
// DefaultTTL is applied to entries stored without an explicit TTL.
// A non-positive value means entries never expire by default.
DefaultTTL time.Duration
// KeyPrefix is prepended to all cache keys. Defaults to "gost:cache:".
KeyPrefix string
// Logger is used for debug logging. Optional.
Logger logger.Logger
}
// redisCache is a cache.Cache backed by a Redis server.
//
// Redis expires keys natively, so an expired entry is a true miss here — the
// interface's serve-stale contract (returning expired entries from Get) does
// not apply to this backend.
type redisCache struct {
client *redis.Client
defaultTTL time.Duration
keyPrefix string
logger logger.Logger
}
// NewRedisCache creates a Redis-backed cache. Close releases the underlying
// client connections.
func NewRedisCache(opts RedisOptions) cache.Cache {
keyPrefix := opts.KeyPrefix
if keyPrefix == "" {
keyPrefix = defaultKeyPrefix
}
return &redisCache{
client: redis.NewClient(&redis.Options{
Addr: opts.Addr,
Username: opts.Username,
Password: opts.Password,
DB: opts.DB,
}),
defaultTTL: opts.DefaultTTL,
keyPrefix: keyPrefix,
logger: opts.Logger,
}
}
// Get returns the entry for key, or cache.ErrNotFound if the key is absent
// (including keys Redis has already expired).
func (c *redisCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
k := c.keyPrefix + key
pipe := c.client.Pipeline()
getCmd := pipe.Get(ctx, k)
ttlCmd := pipe.PTTL(ctx, k)
if _, err := pipe.Exec(ctx); err != nil {
if err == redis.Nil {
return nil, cache.ErrNotFound
}
return nil, err
}
entry := &cache.Entry{
Data: []byte(getCmd.Val()),
}
if ttl := ttlCmd.Val(); ttl > 0 {
entry.Expiration = time.Now().Add(ttl)
}
return entry, nil
}
// Set stores data under key. A non-positive TTL (after applying the default)
// stores the entry without expiration.
func (c *redisCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
var options cache.SetOptions
for _, opt := range opts {
opt(&options)
}
ttl := options.TTL
if ttl <= 0 {
ttl = c.defaultTTL
}
if ttl < 0 {
ttl = 0 // redis: 0 = no expiration
}
return c.client.Set(ctx, c.keyPrefix+key, data, ttl).Err()
}
// Delete removes key from the cache. Deleting an absent key is a no-op.
func (c *redisCache) Delete(ctx context.Context, key string) error {
return c.client.Del(ctx, c.keyPrefix+key).Err()
}
// Close releases the Redis client. Implements io.Closer so the registry
// closes the client on hot reload.
func (c *redisCache) Close() error {
return c.client.Close()
}
+197 -8
View File
@@ -30,12 +30,16 @@ package chain
import (
"context"
"io"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
"github.com/go-gost/core/routing"
"github.com/go-gost/core/selector"
"github.com/go-gost/x/internal/probe"
)
var (
@@ -111,6 +115,17 @@ func (c *Chain) Name() string {
return c.name
}
// Close stops all probe goroutines by cascading to each hop's Close,
// which in turn cascades to node.Close() for every node.
func (c *Chain) Close() error {
for _, h := range c.hops {
if closer, ok := h.(io.Closer); ok {
closer.Close()
}
}
return nil
}
// Route builds a route by selecting one node from each hop. If a node
// supports multiplexing, the route is split — nodes before it form a
// sub-route that is copied into the transport for reuse.
@@ -148,14 +163,20 @@ func (c *Chain) Route(ctx context.Context, network, address string, opts ...chai
}
type chainGroup struct {
chains []chain.Chainer
selector selector.Selector[chain.Chainer]
entries []*ChainEntry
selector selector.Selector[chain.Chainer]
logger logger.Logger
cancelFunc context.CancelFunc
}
// NewChainGroup creates a chain group that selects one Chainer from the
// given list using the configured selector (round-robin by default).
func NewChainGroup(chains ...chain.Chainer) *chainGroup {
return &chainGroup{chains: chains}
entries := make([]*ChainEntry, len(chains))
for i, c := range chains {
entries[i] = NewChainEntry(c, nil, nil)
}
return &chainGroup{entries: entries}
}
func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGroup {
@@ -163,17 +184,185 @@ func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGrou
return p
}
// WithGroupEntries sets the chain entries for the group (replaces defaults)
// and starts probe goroutines for entries that have probe configurations.
func (p *chainGroup) WithGroupEntries(entries ...*ChainEntry) *chainGroup {
// Cancel any previous probe goroutines before replacing entries.
if p.cancelFunc != nil {
p.cancelFunc()
}
p.entries = entries
ctx, cancel := context.WithCancel(context.Background())
p.cancelFunc = cancel
for _, e := range entries {
if e.probe != nil {
go p.runEntryProbe(ctx, e)
}
}
return p
}
// WithGroupLogger sets the logger for the group.
func (p *chainGroup) WithGroupLogger(log logger.Logger) *chainGroup {
p.logger = log
return p
}
func (p *chainGroup) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
if chain := p.next(ctx); chain != nil {
return chain.Route(ctx, network, address, opts...)
if c := p.selectChain(ctx, network, address, opts...); c != nil {
return c.Route(ctx, network, address, opts...)
}
return nil
}
func (p *chainGroup) next(ctx context.Context) chain.Chainer {
if p == nil || len(p.chains) == 0 {
func (p *chainGroup) selectChain(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Chainer {
if p == nil || len(p.entries) == 0 {
return nil
}
return p.selector.Select(ctx, p.chains...)
var options chain.RouteOptions
for _, opt := range opts {
opt(&options)
}
// When no entry has a matcher, the eligible pool equals all entries; the
// selector must still run (FailFilter excludes probe-failed chains), so
// skip the per-call allocation and pass entries directly.
anyMatcher := false
for _, e := range p.entries {
if e.matcher != nil {
anyMatcher = true
break
}
}
if !anyMatcher {
eligible := make([]chain.Chainer, len(p.entries))
for i, e := range p.entries {
eligible[i] = e
}
return p.selectFrom(ctx, eligible...)
}
// Stage 1: matcher filter — build eligible pool.
req := routing.Request{
Network: network,
Host: options.Host,
}
eligible := make([]chain.Chainer, 0, len(p.entries))
for _, e := range p.entries {
if e.matcher != nil {
if !e.matcher.Match(&req) {
continue
}
if p.logger != nil {
p.logger.Debugf("chain entry matched request %s %s", req.Network, req.Host)
}
}
eligible = append(eligible, e) // *ChainEntry as chain.Chainer, FailFilter reads e.Marker()
}
if len(eligible) == 0 {
return nil
}
// Stage 2: group selector — FailFilter + strategy.
return p.selectFrom(ctx, eligible...)
}
func (p *chainGroup) selectFrom(ctx context.Context, eligible ...chain.Chainer) chain.Chainer {
if p.selector != nil {
return p.selector.Select(ctx, eligible...)
}
return eligible[0]
}
// Close stops all probe goroutines.
func (p *chainGroup) Close() error {
if p.cancelFunc != nil {
p.cancelFunc()
}
return nil
}
// runEntryProbe periodically probes a chain and marks
// the chain entry (not the chain's nodes) on failure.
func (p *chainGroup) runEntryProbe(ctx context.Context, e *ChainEntry) {
cfg := e.probe
interval := cfg.Interval
if interval <= 0 {
interval = 30 * time.Second
}
p.probeEntry(ctx, e, cfg) // first probe immediately
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.probeEntry(ctx, e, cfg)
case <-ctx.Done():
return
}
}
}
func (p *chainGroup) probeEntry(ctx context.Context, e *ChainEntry, cfg *chain.ProbeConfig) {
// cmd probe runs a shell command — no chain routing needed.
if cfg.Type == chain.ProbeTypeCmd {
probe.RunCmdProbe(cfg, e.marker, p.logger)
return
}
// TCP/HTTP probe: route through the chain to probe target.
addr := cfg.Addr
if addr == "" {
e.marker.Mark()
if p.logger != nil {
p.logger.Debug("chain entry probe: no probe address configured")
}
return
}
// Route through the probe lifecycle context so cancellation (reload/close)
// aborts an in-flight probe instead of blocking forever on a stuck route.
route := e.chainer.Route(ctx, "tcp", addr)
if route == nil {
e.marker.Mark()
if p.logger != nil {
p.logger.Debug("chain entry probe: no route available")
}
return
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
probeCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := route.Dial(probeCtx, "tcp", addr)
if err != nil {
e.marker.Mark()
if p.logger != nil {
p.logger.Debugf("chain entry probe dial %s failed: %v", addr, err)
}
return
}
defer conn.Close()
if cfg.Type == chain.ProbeTypeHTTP {
if err := probe.NewHTTPProber(cfg).Probe(conn); err != nil {
e.marker.Mark()
if p.logger != nil {
p.logger.Debugf("chain entry probe http %s failed: %v", addr, err)
}
return
}
}
e.marker.Reset()
}
+77 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
"github.com/go-gost/core/recorder"
"github.com/go-gost/core/routing"
xlogger "github.com/go-gost/x/logger"
xmetadata "github.com/go-gost/x/metadata"
"github.com/stretchr/testify/assert"
@@ -787,7 +788,7 @@ func TestNewChainGroup(t *testing.T) {
ch2 := NewChain("ch2")
g := NewChainGroup(ch1, ch2)
require.NotNil(t, g)
assert.Len(t, g.chains, 2)
assert.Len(t, g.entries, 2)
}
func TestChainGroup_Route_NoChains(t *testing.T) {
@@ -1758,12 +1759,83 @@ func TestChainGroup_WithSelector(t *testing.T) {
assert.Len(t, rt.Nodes(), 1)
}
func TestChainGroup_Next_NilReceiver(t *testing.T) {
func TestChainGroup_SelectChain_NilReceiver(t *testing.T) {
var g *chainGroup
assert.Nil(t, g.next(context.Background()))
assert.Nil(t, g.selectChain(context.Background(), "", ""))
}
func TestChainGroup_Next_EmptyChains(t *testing.T) {
func TestChainGroup_SelectChain_EmptyChains(t *testing.T) {
g := NewChainGroup()
assert.Nil(t, g.next(context.Background()))
assert.Nil(t, g.selectChain(context.Background(), "", ""))
}
// hostMatcher matches requests whose Host equals the configured value.
type hostMatcher struct {
host string
}
func (m *hostMatcher) Match(req *routing.Request) bool {
return req != nil && req.Host == m.host
}
func TestChainGroup_SelectChain_MatcherFilter(t *testing.T) {
matchChain := NewChain("match")
skipChain := NewChain("skip")
entries := []*ChainEntry{
NewChainEntry(matchChain, &hostMatcher{host: "a.example.com"}, nil),
NewChainEntry(skipChain, &hostMatcher{host: "b.example.com"}, nil),
}
g := NewChainGroup().WithGroupEntries(entries...)
// Only the entry matching "a.example.com" should be eligible.
got := g.selectChain(context.Background(), "tcp", "a.example.com:80", chain.WithHostRouteOption("a.example.com"))
require.NotNil(t, got)
// selectChain returns the *ChainEntry (so the FailFilter can read its marker),
// not the inner *Chain.
ce, ok := got.(*ChainEntry)
require.True(t, ok)
assert.Equal(t, matchChain, ce.chainer)
// Non-matching host → no eligible entry → nil.
assert.Nil(t, g.selectChain(context.Background(), "tcp", "x.example.com:80", chain.WithHostRouteOption("x.example.com")))
}
func TestChainGroup_Close_StopsProbeGoroutines(t *testing.T) {
ch := NewChain("ch")
// cmd probe with a short interval; the entry marker count is the observable
// signal that the probe goroutine is (or is not) still running.
entry := NewChainEntry(ch, nil, &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "true",
Interval: 10 * time.Millisecond,
})
g := NewChainGroup().WithGroupEntries(entry)
// Let the probe tick a few times, then Close().
time.Sleep(50 * time.Millisecond)
require.NoError(t, g.Close())
before := entry.Marker().Count()
time.Sleep(60 * time.Millisecond)
after := entry.Marker().Count()
// A succeeding cmd probe ("true") never marks, so the count must stay
// stable after Close(): the goroutine has stopped ticking.
assert.Equal(t, before, after, "probe goroutine should stop ticking after Close()")
}
func TestChainGroup_Probe_MarksEntryOnCmdFailure(t *testing.T) {
ch := NewChain("ch")
entry := NewChainEntry(ch, nil, &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "false", // always exits non-zero → probe failure
Interval: time.Second,
})
g := NewChainGroup().WithGroupEntries(entry)
// First probe runs immediately; give it time to mark.
time.Sleep(50 * time.Millisecond)
assert.NotZero(t, entry.Marker().Count(), "failing cmd probe should mark the entry")
require.NoError(t, g.Close())
}
+44
View File
@@ -0,0 +1,44 @@
package chain
import (
"context"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/routing"
"github.com/go-gost/core/selector"
)
// ChainEntry wraps a chain.Chainer with an optional matcher and an independent
// failure marker for chain-level probe tracking. The entry's marker is
// separate from any node-level markers inside the chain so that probe
// Reset() cannot undo a node-level failCodes Mark().
type ChainEntry struct {
chainer chain.Chainer
matcher routing.Matcher
probe *chain.ProbeConfig
marker selector.Marker
}
// NewChainEntry creates a ChainEntry. matcher may be nil (unconditional
// catch-all). probe may be nil (no health check for this entry).
func NewChainEntry(c chain.Chainer, m routing.Matcher, probe *chain.ProbeConfig) *ChainEntry {
return &ChainEntry{
chainer: c,
matcher: m,
probe: probe,
marker: selector.NewFailMarker(),
}
}
// Route implements chain.Chainer so the entry can be passed directly to the
// group selector. The selector's FailFilter reads Marker() from the entry,
// not from the underlying chain — this keeps probe-driven marking independent
// of any node-level markers.
func (e *ChainEntry) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
return e.chainer.Route(ctx, network, address, opts...)
}
// Marker implements selector.Markable.
func (e *ChainEntry) Marker() selector.Marker {
return e.marker
}
+121
View File
@@ -0,0 +1,121 @@
package chain
import (
"context"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/internal/probe"
)
// StartNodeProbe starts a background goroutine that periodically probes the
// node's transport. On success the node's marker is reset; on failure the
// marker is incremented. cfg may be nil (no-op).
func StartNodeProbe(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
if cfg == nil {
return
}
if cfg.Addr == "" && cfg.Type != chain.ProbeTypeCmd {
return
}
if cfg.Type == chain.ProbeTypeCmd && cfg.Command == "" {
return
}
ctx, cancel := context.WithCancel(context.Background())
node.SetProbeCancel(cancel)
go runNodeProbe(ctx, node, cfg, log)
}
func runNodeProbe(ctx context.Context, node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
interval := cfg.Interval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
probeNode(node, cfg, log) // first probe immediately
for {
select {
case <-ticker.C:
probeNode(node, cfg, log)
case <-ctx.Done():
return
}
}
}
func probeNode(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
// cmd probe runs a shell command directly — no transport connection needed.
if cfg.Type == chain.ProbeTypeCmd {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
start := time.Now()
err := (&probe.CmdProber{Command: cfg.Command, Timeout: timeout}).Probe()
latency := time.Since(start)
if err != nil {
recordProbe(node, false, latency, err.Error(), log)
} else {
recordProbe(node, true, latency, "", log)
}
return
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
start := time.Now()
tr := node.Options().Transport
conn, err := tr.Dial(ctx, node.Addr)
if err != nil {
recordProbe(node, false, time.Since(start), err.Error(), log)
return
}
if hc, err2 := tr.Handshake(ctx, conn); err2 == nil {
conn = hc
} else {
conn.Close()
recordProbe(node, false, time.Since(start), err2.Error(), log)
return
}
latency := time.Since(start)
if cfg.Type == chain.ProbeTypeHTTP {
err = probe.NewHTTPProber(cfg).Probe(conn)
conn.Close()
if err != nil {
recordProbe(node, false, latency, err.Error(), log)
return
}
} else {
conn.Close()
}
recordProbe(node, true, latency, "", log)
}
func recordProbe(node *chain.Node, success bool, latency time.Duration, errStr string, log logger.Logger) {
result := &chain.ProbeResult{
Success: success,
Latency: latency,
Error: errStr,
Timestamp: time.Now(),
}
node.SetProbeResult(result)
if success {
node.Marker().Reset()
} else {
node.Marker().Mark()
}
log.Debugf("node probe %s: success=%v latency=%v err=%s", node.Name, success, latency, errStr)
}
+292
View File
@@ -0,0 +1,292 @@
package chain
import (
"context"
"errors"
"net"
"sync/atomic"
"testing"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
xlogger "github.com/go-gost/x/logger"
)
// testTransport implements chain.Transporter with real TCP dials.
type testTransport struct {
dialer *net.Dialer
options chain.TransportOptions
}
func (t *testTransport) Dial(ctx context.Context, addr string) (net.Conn, error) {
return t.dialer.DialContext(ctx, "tcp", addr)
}
func (t *testTransport) Handshake(ctx context.Context, conn net.Conn) (net.Conn, error) {
return conn, nil
}
func (t *testTransport) Connect(ctx context.Context, conn net.Conn, network, address string) (net.Conn, error) {
return nil, errors.New("not implemented")
}
func (t *testTransport) Bind(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) {
return nil, errors.New("not implemented")
}
func (t *testTransport) Multiplex() bool { return false }
func (t *testTransport) Options() *chain.TransportOptions { return &t.options }
func (t *testTransport) Copy() chain.Transporter { return t }
func TestNodeProbeTCP(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
// accept one connection, then close immediately (TCP probe only needs dial+handshake)
go func() {
for {
conn, _ := l.Accept()
if conn != nil {
conn.Close()
}
}
}()
addr := l.Addr().String()
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeTCP,
Addr: addr,
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
StartNodeProbe(node, cfg, xlogger.Nop())
// Wait for first probe
time.Sleep(300 * time.Millisecond)
pr := node.ProbeResult()
if pr == nil || !pr.Success {
t.Fatalf("expected successful probe, got %+v", pr)
}
// Close listener → next probe fails
l.Close()
time.Sleep(300 * time.Millisecond)
pr = node.ProbeResult()
if pr == nil || pr.Success {
t.Fatalf("expected failed probe after listener close, got %+v", pr)
}
if node.Marker().Count() == 0 {
t.Error("expected marker count > 0 after failure")
}
node.Close()
}
func TestNodeProbeClose(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
for {
conn, _ := l.Accept()
if conn != nil {
conn.Close()
}
}
}()
addr := l.Addr().String()
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeTCP,
Addr: addr,
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
StartNodeProbe(node, cfg, xlogger.Nop())
time.Sleep(150 * time.Millisecond)
// Snapshot result before close
lastSuccess := node.ProbeResult().Success
if !lastSuccess {
t.Fatal("expected initial success before close")
}
node.Close()
// Capture marker count right after close
countAfterClose := node.Marker().Count()
// Wait longer than probe interval — no more updates after close
time.Sleep(300 * time.Millisecond)
// Marker count should not increase after close (no more probes running)
if node.Marker().Count() != countAfterClose {
t.Errorf("marker changed after close: was %d, now %d", countAfterClose, node.Marker().Count())
}
}
func TestNodeProbeDisabled(t *testing.T) {
node := chain.NewNode("no-probe", "addr")
// nil config → no-op
StartNodeProbe(node, nil, xlogger.Nop())
if pr := node.ProbeResult(); pr != nil {
t.Error("expected nil probe result for disabled probe")
}
// empty addr → no-op
StartNodeProbe(node, &chain.ProbeConfig{}, xlogger.Nop())
if pr := node.ProbeResult(); pr != nil {
t.Error("expected nil probe result for empty addr")
}
}
func TestNodeProbeCmd(t *testing.T) {
// Cmd probe doesn't need a transport — StartNodeProbe skips the addr check.
node := chain.NewNode("cmd-test", "")
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "true",
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
StartNodeProbe(node, cfg, xlogger.Nop())
time.Sleep(300 * time.Millisecond)
pr := node.ProbeResult()
if pr == nil || !pr.Success {
t.Fatalf("expected cmd probe success, got %+v", pr)
}
if node.Marker().Count() != 0 {
t.Errorf("expected marker count 0 after success, got %d", node.Marker().Count())
}
// Switch to failing command
cfg2 := &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "false",
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
}
node.Close() // stop old probe
node2 := chain.NewNode("cmd-test-2", "")
StartNodeProbe(node2, cfg2, xlogger.Nop())
time.Sleep(300 * time.Millisecond)
pr2 := node2.ProbeResult()
if pr2 == nil || pr2.Success {
t.Fatalf("expected cmd probe failure for exit 1, got %+v", pr2)
}
if node2.Marker().Count() == 0 {
t.Error("expected marker count > 0 after cmd failure")
}
node2.Close()
}
func TestNodeProbeCmdDisabled(t *testing.T) {
// cmd probe without command → no-op
node := chain.NewNode("cmd-disabled", "")
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeCmd,
Command: "",
}
StartNodeProbe(node, cfg, xlogger.Nop())
if pr := node.ProbeResult(); pr != nil {
t.Error("expected nil probe result for empty command")
}
}
func TestNodeProbeHTTP(t *testing.T) {
// Start a small HTTP server
mux := &httpTestMux{}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go mux.serve(conn)
}
}()
addr := l.Addr().String()
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
node := chain.NewNode("test-http", addr, chain.TransportNodeOption(tr))
cfg := &chain.ProbeConfig{
Type: chain.ProbeTypeHTTP,
Addr: addr,
Interval: 100 * time.Millisecond,
Timeout: 2 * time.Second,
HTTPPath: "/health",
ExpectedStatus: 200,
}
// Case 1: server returns 200
mux.status.Store(200)
StartNodeProbe(node, cfg, xlogger.Nop())
time.Sleep(300 * time.Millisecond)
pr := node.ProbeResult()
if pr == nil || !pr.Success {
t.Fatalf("expected HTTP 200 probe success, got %+v", pr)
}
// Case 2: server returns 500
mux.status.Store(500)
time.Sleep(300 * time.Millisecond)
pr = node.ProbeResult()
if pr == nil || pr.Success {
t.Fatalf("expected HTTP 500 probe failure, got %+v", pr)
}
node.Close()
}
// httpTestMux is a minimal HTTP server that returns a configurable status.
type httpTestMux struct {
status atomic.Int32
}
func (m *httpTestMux) serve(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
// Parse just enough to construct a response
body := "HTTP/1.1 000 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
switch m.status.Load() {
case 200:
body = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
case 500:
body = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
}
_ = n
conn.Write([]byte(body))
}
+14
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"net"
"strings"
"time"
@@ -49,6 +50,19 @@ func (r *Router) Options() *chain.RouterOptions {
return &r.options
}
// Close releases resources held by the router's chain. A chain that owns
// background goroutines (e.g. a chainGroup running liveness probes) is closed
// here so its goroutines stop when the service shuts down or is reloaded.
func (r *Router) Close() error {
if r == nil {
return nil
}
if c, ok := r.options.Chain.(io.Closer); ok {
return c.Close()
}
return nil
}
// Dial establishes a connection to the target address through the configured
// chain. It resolves the address, records the dial event, retries up to
// Retries+1 times, and returns the resulting connection.
+38 -21
View File
@@ -397,15 +397,24 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
listener = schemes[1]
}
// For path-based protocols (Unix socket listeners and serial), use path as address
// Decide path-based from the scheme before registry remaps listener to tcp.
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
for _, s := range schemes {
if s == "unix" || s == "runix" || s == "serial" {
isPathBasedProtocol = true
break
}
}
// A path-based protocol with an empty host (three-slash URL like
// unix:///var/run/sock) uses the path as the absolute listen address and
// has no forward target. With a non-empty host (two-slash URL like
// unix://sock/other.sock), the host is the listen address and any path is a
// forward target (port-forward to another socket), handled below.
isPathAddr := isPathBasedProtocol && url.Host == ""
var addrs []string
if isPathBasedProtocol {
path := url.EscapedPath()
if url.Host != "" {
addrs = append(addrs, url.Host+strings.TrimPrefix(path, "/"))
} else if path != "" {
if isPathAddr {
if path := url.EscapedPath(); path != "" {
addrs = append(addrs, path)
} else {
addrs = append(addrs, url.Host)
@@ -436,8 +445,10 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
var nodes []*config.ForwardNodeConfig
// forward mode
// For path-based protocols, path is address not forward target
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathBasedProtocol {
// A path-based protocol with an empty host (e.g. unix:///abs.sock) treats
// the path as the listen address, not a forward target. With a host
// (e.g. unix://a.sock/b.sock) the path is a forward target.
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathAddr {
i := 0
for _, addr := range strings.Split(remotes, ",") {
addrs := xnet.AddrPortRange(addr).Addrs()
@@ -640,6 +651,18 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
dialer = schemes[1]
}
// Path-based address logic must use the requested scheme, not the
// registry fallback (unix/serial often remap to http/tcp when only
// the CLI registry is partially populated, or in unit tests).
isPathBasedProtocol := dialer == "unix" || dialer == "serial" ||
connector == "unix" || connector == "serial"
for _, s := range schemes {
if s == "unix" || s == "serial" || s == "runix" {
isPathBasedProtocol = true
break
}
}
md := mdx.NewMetadata(m)
if c := registry.ConnectorRegistry().Get(connector); c == nil {
@@ -725,20 +748,14 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
nodeMd[k] = v
}
// For path-based protocols (Unix socket dialers and serial), use path as address
// For path-based protocols (Unix socket dialers and serial), use path as address.
// Three-slash URLs (empty host) give the absolute socket path; two-slash URLs
// use the host as the socket. The path on a host form is not part of the node
// address (it is a forward target for services, ignored for nodes).
var nodeAddr string
isPathBasedProtocol := dialer == "unix" || dialer == "serial"
if isPathBasedProtocol {
path := url.EscapedPath()
if url.Host != "" {
// Two slashes: host + path = relative path
nodeAddr = url.Host + strings.TrimPrefix(path, "/")
} else if path != "" {
// Three slashes: path is absolute (keep leading slash)
nodeAddr = path
} else {
nodeAddr = url.Host
}
if isPathBasedProtocol && url.Host == "" {
// Three slashes: path is absolute (keep leading slash)
nodeAddr = url.EscapedPath()
} else {
nodeAddr = url.Host
}
+62 -6
View File
@@ -1765,10 +1765,10 @@ func TestBuildNodeConfig_TLSWithCert(t *testing.T) {
}
}
func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
func TestBuildNodeConfig_UnixAbsolutePath(t *testing.T) {
// http+unix scheme: connector=http, dialer=unix.
// Since unix is not in the test registry, dialer falls back to tcp,
// and the path-based address logic (dialer=="unix") is skipped.
// Dialer falls back to tcp when unix is not in the test registry, but
// the absolute socket path from a three-slash URL must still be kept.
rawURL := "http+unix:///var/run/socket"
u, _ := url.Parse(rawURL)
@@ -1780,12 +1780,68 @@ func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
if node.Dialer.Type != "tcp" {
t.Errorf("Dialer.Type = %q, want tcp (unix not in test registry)", node.Dialer.Type)
}
// Addr is url.Host ("") since no host component in three-slash unix URL
if node.Addr != "" {
t.Errorf("Addr = %q, want empty", node.Addr)
if node.Addr != "/var/run/socket" {
t.Errorf("Addr = %q, want /var/run/socket", node.Addr)
}
}
func TestBuildNodeConfig_UnixRelativeHost(t *testing.T) {
u, _ := url.Parse("unix://docker.sock")
node, err := buildNodeConfig(u, map[string]any{})
if err != nil {
t.Fatalf("buildNodeConfig: %v", err)
}
if node.Addr != "docker.sock" {
t.Errorf("Addr = %q, want docker.sock", node.Addr)
}
}
func TestBuildServiceConfig_UnixAbsolutePath(t *testing.T) {
// three-slash: path is the absolute socket listen address, no forward target
svcs, err := buildServiceConfig(mustURL("unix:///var/run/gost.sock"))
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Addr != "/var/run/gost.sock" {
t.Errorf("Addr = %q, want /var/run/gost.sock", svcs[0].Addr)
}
if svcs[0].Forwarder != nil {
t.Errorf("Forwarder = %+v, want nil (no forward target)", svcs[0].Forwarder)
}
}
func TestBuildServiceConfig_UnixPortForward(t *testing.T) {
// two-slash: host is the listen socket, path is a forward target socket
svcs, err := buildServiceConfig(mustURL("unix://gost.sock/gost2.sock"))
if err != nil {
t.Fatalf("buildServiceConfig: %v", err)
}
if len(svcs) != 1 {
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
}
if svcs[0].Addr != "gost.sock" {
t.Errorf("Addr = %q, want gost.sock", svcs[0].Addr)
}
if svcs[0].Forwarder == nil || len(svcs[0].Forwarder.Nodes) == 0 {
t.Fatalf("Forwarder = %+v, want a forward target node", svcs[0].Forwarder)
}
if got := svcs[0].Forwarder.Nodes[0].Addr; got != "gost2.sock" {
t.Errorf("forward Addr = %q, want gost2.sock", got)
}
}
func mustURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(err)
}
return u
}
func TestBuildServiceConfig_RelayTLSForward(t *testing.T) {
// relay+tls scheme: handler=relay, listener=tls.
// relay not in test registry → handler falls back to auto, listener falls back to tcp.
+149 -14
View File
@@ -4,12 +4,15 @@ import (
"encoding/json"
"io"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
yaml "github.com/goccy/go-yaml"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
var (
@@ -26,8 +29,21 @@ func init() {
var (
global = &Config{}
globalMux sync.RWMutex
// configFile remembers the last file loaded via ReadFile so the API can
// save back to the same location (issue go-gost/x#123).
configFile atomic.Value // string
)
// ConfigFile returns the path of the last config file loaded via ReadFile,
// or an empty string when the config came from stdin, a URL, or inline JSON.
func ConfigFile() string {
if s, ok := configFile.Load().(string); ok {
return s
}
return ""
}
func Global() *Config {
globalMux.RLock()
defer globalMux.RUnlock()
@@ -132,6 +148,7 @@ type TLSOptions struct {
type PluginConfig struct {
Type string `json:"type"`
Addr string `json:"addr"`
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
TLS *TLSConfig `yaml:",omitempty" json:"tls,omitempty"`
Timeout time.Duration `yaml:",omitempty" json:"timeout,omitempty"`
Token string `yaml:",omitempty" json:"token,omitempty"`
@@ -157,6 +174,7 @@ type SelectorConfig struct {
Strategy string `json:"strategy"`
MaxFails int `yaml:"maxFails" json:"maxFails"`
FailTimeout time.Duration `yaml:"failTimeout" json:"failTimeout"`
MaxLatency time.Duration `yaml:"maxLatency,omitempty" json:"maxLatency,omitempty"`
}
type AdmissionConfig struct {
@@ -284,6 +302,13 @@ type RecorderConfig struct {
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
// P2PConfig defines a named p2p tunnel provider, currently backed by an
// external gRPC plugin process.
type P2PConfig struct {
Name string `json:"name"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
type FileRecorder struct {
Path string `json:"path"`
Sep string `yaml:",omitempty" json:"sep,omitempty"`
@@ -321,6 +346,33 @@ type RewriterConfig struct {
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
// CacheConfig defines a named cache store. Memory and Redis backends are
// supported; other backends (file, plugin) are follow-ups.
type CacheConfig struct {
Name string `json:"name"`
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
}
// RedisCache configures the Redis cache backend.
type RedisCache struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
Key string `yaml:",omitempty" json:"key,omitempty"` // key prefix, default "gost:cache:"
}
// MemoryCache configures the in-memory cache backend.
type MemoryCache struct {
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
MaxSize int `yaml:"maxSize,omitempty" json:"maxSize,omitempty"`
MaxBytes int64 `yaml:"maxBytes,omitempty" json:"maxBytes,omitempty"`
CleanupInterval time.Duration `yaml:"cleanupInterval,omitempty" json:"cleanupInterval,omitempty"`
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
}
type LimiterConfig struct {
Name string `json:"name"`
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
@@ -365,9 +417,21 @@ type ForwarderConfig struct {
// Deprecated: use hop instead
Name string `yaml:",omitempty" json:"name,omitempty"`
// the referenced hop name
Hop string `yaml:",omitempty" json:"hop,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Nodes []*ForwardNodeConfig `json:"nodes"`
Hop string `yaml:",omitempty" json:"hop,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Nodes []*ForwardNodeConfig `json:"nodes"`
HopGroup *ForwardHopGroupConfig `yaml:"hopGroup,omitempty" json:"hopGroup,omitempty"`
}
type ForwardHopGroupConfig struct {
Hops []*ForwardHopConfig `yaml:",omitempty" json:"hops,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
}
type ForwardHopConfig struct {
Hop string `yaml:",omitempty" json:"hop,omitempty"`
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
}
type ForwardNodeConfig struct {
@@ -389,6 +453,7 @@ type ForwardNodeConfig struct {
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
@@ -407,6 +472,17 @@ type HTTPBodyRewriteConfig struct {
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
}
type HTTPHeaderRewriteConfig struct {
// Name is the header name regex, e.g. "(?i)^(location|set-cookie)$".
Name string
// Match is the header value regex.
Match string
// Replacement is the replacement string.
Replacement string
// Rewriter is the name of the rewriter plugin (via registry).
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
}
type NodeFilterConfig struct {
Host string `yaml:",omitempty" json:"host,omitempty"`
Protocol string `yaml:",omitempty" json:"protocol,omitempty"`
@@ -426,15 +502,31 @@ type NodeMatcherConfig struct {
Rule string `yaml:",omitempty" json:"rule,omitempty"`
Priority int `yaml:",omitempty" json:"priority,omitempty"`
// BodySize is the max request body prefix (bytes) exposed to BodyRegexp
// matchers. 0 (default) disables body reading for this node. Capped at
// chain.MaxMatcherBodySize. Only takes effect under an HTTP sniffing handler
// whose sniffer reads the body prefix before node selection.
// and BodyJSON matchers. 0 (default) uses DefaultMatcherBodySize (1MB).
// Capped at MaxMatcherBodySize (10MB). Only takes effect under an HTTP
// sniffing handler whose sniffer reads the body prefix before node selection.
BodySize int `yaml:",omitempty" json:"bodySize,omitempty"`
}
// ProbeConfig holds the configuration for a node-level liveness probe.
type ProbeConfig struct {
Type string `json:"type"`
Addr string `json:"addr,omitempty"`
Interval time.Duration `json:"interval"`
Timeout time.Duration `json:"timeout"`
HTTPPath string `yaml:"httpPath,omitempty" json:"httpPath,omitempty"`
HTTPHost string `yaml:"httpHost,omitempty" json:"httpHost,omitempty"`
HTTPHeaders map[string]string `yaml:"httpHeaders,omitempty" json:"httpHeaders,omitempty"`
ExpectedStatus int `yaml:"expectedStatus,omitempty" json:"expectedStatus,omitempty"`
Command string `yaml:"command,omitempty" json:"command,omitempty"`
}
type HTTPNodeConfig struct {
// rewrite host header
Host string `yaml:",omitempty" json:"host,omitempty"`
// regex matched against the request path to derive Host; Host becomes a
// replacement template ($1, $2, ...) when set.
HostPattern string `yaml:"hostPattern,omitempty" json:"hostPattern,omitempty"`
// Deprecated: use requestHeader instead
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
// additional request header
@@ -452,6 +544,14 @@ type HTTPNodeConfig struct {
// rewrite response body
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
// rewrite request header values
RewriteRequestHeader []HTTPHeaderRewriteConfig `yaml:"rewriteRequestHeader,omitempty" json:"rewriteRequestHeader,omitempty"`
// rewrite response header values
RewriteResponseHeader []HTTPHeaderRewriteConfig `yaml:"rewriteResponseHeader,omitempty" json:"rewriteResponseHeader,omitempty"`
// comma-separated response status codes marking the node failed, e.g. "429,5xx"
FailCodes string `yaml:"failCodes,omitempty" json:"failCodes,omitempty"`
// HTTP basic auth
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
}
@@ -502,6 +602,7 @@ type ServiceConfig struct {
Observer string `yaml:",omitempty" json:"observer,omitempty"`
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
Cache string `yaml:",omitempty" json:"cache,omitempty"`
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
@@ -589,9 +690,39 @@ type ChainConfig struct {
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
type ChainGroupEntry struct {
Chain string `yaml:",omitempty" json:"chain,omitempty"`
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
}
// gostDecodeHook is a composite mapstructure decode hook that handles custom
// type conversions needed by GOST config fields. viper's internal unmarshalling
// never calls yaml.Unmarshaler / json.Unmarshaler on leaf types, so the hook is
// the only way to support these.
func gostDecodeHook() mapstructure.DecodeHookFunc {
return func(f, t reflect.Type, data any) (any, error) {
// Plain string → ChainGroupEntry: backward compat for "chains: [a, b]".
if t == reflect.TypeOf(ChainGroupEntry{}) {
if s, ok := data.(string); ok {
return ChainGroupEntry{Chain: s}, nil
}
return data, nil
}
// String → time.Duration: accept "10s", "1m", etc. in YAML/JSON.
if t == reflect.TypeOf(time.Duration(0)) {
if s, ok := data.(string); ok {
return time.ParseDuration(s)
}
return data, nil
}
return data, nil
}
}
type ChainGroupConfig struct {
Chains []string `yaml:",omitempty" json:"chains,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
Chains []*ChainGroupEntry `yaml:",omitempty" json:"chains,omitempty"`
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
}
type HopConfig struct {
@@ -635,6 +766,7 @@ type NodeConfig struct {
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
}
@@ -642,6 +774,7 @@ type Config struct {
Services []*ServiceConfig `json:"services"`
Chains []*ChainConfig `yaml:",omitempty" json:"chains,omitempty"`
Hops []*HopConfig `yaml:",omitempty" json:"hops,omitempty"`
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
Authers []*AutherConfig `yaml:",omitempty" json:"authers,omitempty"`
Admissions []*AdmissionConfig `yaml:",omitempty" json:"admissions,omitempty"`
Bypasses []*BypassConfig `yaml:",omitempty" json:"bypasses,omitempty"`
@@ -652,6 +785,8 @@ type Config struct {
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
P2Ps []*P2PConfig `yaml:"p2ps,omitempty" json:"p2ps,omitempty"`
Caches []*CacheConfig `yaml:",omitempty" json:"caches,omitempty"`
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
@@ -670,7 +805,7 @@ func (c *Config) Load() error {
return err
}
return v.Unmarshal(c)
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
}
func (c *Config) Read(r io.Reader, configType string) error {
@@ -679,7 +814,7 @@ func (c *Config) Read(r io.Reader, configType string) error {
return err
}
return v.Unmarshal(c)
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
}
func (c *Config) ReadFile(file string) error {
@@ -688,7 +823,8 @@ func (c *Config) ReadFile(file string) error {
if err := v.ReadInConfig(); err != nil {
return err
}
return v.Unmarshal(c)
configFile.Store(file)
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
}
func (c *Config) Write(w io.Writer, format string) error {
@@ -701,9 +837,8 @@ func (c *Config) Write(w io.Writer, format string) error {
case "yaml":
fallthrough
default:
enc := yaml.NewEncoder(w)
enc := yaml.NewEncoder(w, yaml.Indent(2))
defer enc.Close()
enc.SetIndent(2)
return enc.Encode(c)
}
+48 -2
View File
@@ -6,9 +6,13 @@
package loader
import (
"fmt"
"strings"
"github.com/go-gost/core/admission"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/hosts"
@@ -21,14 +25,15 @@ import (
"github.com/go-gost/core/recorder"
reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver"
"github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/router"
"github.com/go-gost/core/sd"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
admission_parser "github.com/go-gost/x/config/parsing/admission"
auth_parser "github.com/go-gost/x/config/parsing/auth"
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
cache_parser "github.com/go-gost/x/config/parsing/cache"
chain_parser "github.com/go-gost/x/config/parsing/chain"
hop_parser "github.com/go-gost/x/config/parsing/hop"
hosts_parser "github.com/go-gost/x/config/parsing/hosts"
@@ -36,6 +41,7 @@ import (
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
logger_parser "github.com/go-gost/x/config/parsing/logger"
observer_parser "github.com/go-gost/x/config/parsing/observer"
p2p_parser "github.com/go-gost/x/config/parsing/p2p"
quota_parser "github.com/go-gost/x/config/parsing/quota"
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
@@ -44,6 +50,7 @@ import (
sd_parser "github.com/go-gost/x/config/parsing/sd"
service_parser "github.com/go-gost/x/config/parsing/service"
quota "github.com/go-gost/x/limiter/quota"
xp2p "github.com/go-gost/x/p2p"
"github.com/go-gost/x/registry"
)
@@ -256,6 +263,26 @@ func register(cfg *config.Config) error {
}
}
{
var entries []named[xp2p.Tunnel]
for _, c := range cfg.P2Ps {
entries = append(entries, named[xp2p.Tunnel]{c.Name, p2p_parser.ParseP2P(c)})
}
if err := registerGroup(entries, registry.P2PRegistry()); err != nil {
return err
}
}
{
var entries []named[cache.Cache]
for _, c := range cfg.Caches {
entries = append(entries, named[cache.Cache]{c.Name, cache_parser.ParseCache(c)})
}
if err := registerGroup(entries, registry.CacheRegistry()); err != nil {
return err
}
}
{
var entries []named[traffic.TrafficLimiter]
for _, c := range cfg.Limiters {
@@ -296,7 +323,26 @@ func register(cfg *config.Config) error {
}
}
// --- hops (references bypasses, resolvers, hosts from registries) ---
// --- nodes (definitions referenced by hops) ---
{
var entries []named[*config.NodeConfig]
for _, c := range cfg.Nodes {
if c == nil {
continue
}
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" {
return fmt.Errorf("global node name is required")
}
entries = append(entries, named[*config.NodeConfig]{c.Name, c})
}
if err := registerGroup(entries, registry.NodeRegistry()); err != nil {
return err
}
}
// --- hops (references nodes, bypasses, resolvers, hosts from registries) ---
{
var entries []named[hop.Hop]
+75
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
@@ -17,6 +18,9 @@ import (
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/registry"
_ "github.com/go-gost/x/connector/http"
_ "github.com/go-gost/x/dialer/tcp"
)
func TestMain(m *testing.M) {
@@ -465,6 +469,77 @@ func TestRegister_Hops(t *testing.T) {
}
}
func TestRegister_GlobalNodesBeforeHops(t *testing.T) {
nodeName := "test-global-node"
hopName := "test-global-node-hop"
t.Cleanup(func() {
registry.HopRegistry().Unregister(hopName)
registry.NodeRegistry().Unregister(nodeName)
})
cfg := &config.Config{
Nodes: []*config.NodeConfig{{
Name: nodeName,
Addr: "example.com:8080",
}},
Hops: []*config.HopConfig{{
Name: hopName,
Nodes: []*config.NodeConfig{{Name: nodeName}},
}},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
definition := registry.NodeRegistry().Get(nodeName)
if definition == nil {
t.Fatal("expected global node to be registered")
}
nodes := registry.HopRegistry().Get(hopName).(hop.NodeList).Nodes()
if len(nodes) != 1 || nodes[0].Name != definition.Name || nodes[0].Addr != definition.Addr {
t.Fatalf("hop nodes = %v, want global definition %q at %q", nodes, definition.Name, definition.Addr)
}
}
func TestRegister_GlobalNodeReferenceFromYAML(t *testing.T) {
const nodeName = "yaml-global-node"
const hopName = "yaml-global-hop"
t.Cleanup(func() {
registry.HopRegistry().Unregister(hopName)
registry.NodeRegistry().Unregister(nodeName)
})
cfg := &config.Config{}
err := cfg.Read(strings.NewReader(`
nodes:
- name: yaml-global-node
addr: global.example:8080
hops:
- name: yaml-global-hop
nodes:
- name: yaml-global-node
- name: inline-node
addr: inline.example:8081
`), "yaml")
if err != nil {
t.Fatalf("read YAML: %v", err)
}
if err := register(cfg); err != nil {
t.Fatalf("register: %v", err)
}
nodes := registry.HopRegistry().Get(hopName).(hop.NodeList).Nodes()
if len(nodes) != 2 {
t.Fatalf("hop nodes = %d, want 2", len(nodes))
}
if nodes[0].Name != nodeName || nodes[0].Addr != "global.example:8080" {
t.Fatalf("referenced node = %q at %q", nodes[0].Name, nodes[0].Addr)
}
if nodes[1].Name != "inline-node" || nodes[1].Addr != "inline.example:8081" {
t.Fatalf("inline node = %q at %q", nodes[1].Name, nodes[1].Addr)
}
}
func TestRegister_Chains(t *testing.T) {
r := registry.ChainRegistry()
name := "test-chain"
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
)
@@ -24,6 +25,9 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
)
@@ -27,6 +28,9 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
)
@@ -23,6 +24,9 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+44
View File
@@ -0,0 +1,44 @@
package cache
import (
"strings"
"github.com/go-gost/core/cache"
"github.com/go-gost/core/logger"
xcache "github.com/go-gost/x/cache"
"github.com/go-gost/x/config"
)
// ParseCache converts a CacheConfig into a cache.Cache. Memory and Redis
// backends are supported. Returns nil when cfg is nil or no backend is
// configured.
func ParseCache(cfg *config.CacheConfig) cache.Cache {
if cfg == nil {
return nil
}
if cfg.Memory != nil {
return xcache.NewMemoryCache(xcache.Options{
DefaultTTL: cfg.Memory.TTL,
MaxSize: cfg.Memory.MaxSize,
MaxBytes: cfg.Memory.MaxBytes,
CleanupInterval: cfg.Memory.CleanupInterval,
Eviction: xcache.EvictionPolicy(strings.ToLower(cfg.Memory.Eviction)),
Logger: logger.Default(),
})
}
if cfg.Redis != nil {
return xcache.NewRedisCache(xcache.RedisOptions{
Addr: cfg.Redis.Addr,
DB: cfg.Redis.DB,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DefaultTTL: cfg.Redis.TTL,
KeyPrefix: cfg.Redis.Key,
Logger: logger.Default(),
})
}
return nil
}
+26
View File
@@ -19,6 +19,8 @@ import (
"github.com/go-gost/x/internal/plugin"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xplugin "github.com/go-gost/x/plugin"
xs "github.com/go-gost/x/selector"
)
// ParseHop converts a HopConfig into a hop.Hop. It resolves plugin backends
@@ -31,6 +33,9 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
@@ -83,6 +88,16 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
continue
}
var err error
v, err = node_parser.ResolveConfig(v)
if err != nil {
// Fail the whole hop: static config is validated at startup,
// so a dangling reference is a hard error. (Contrast with
// chainHop.parseNode, which warns and skips bad nodes to
// keep hot-reloaded hops resilient.)
return nil, err
}
// Build a merged metadata map for inheritance without mutating
// the original node config.
merged := make(map[string]any)
@@ -158,10 +173,21 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
sel = selector_parser.DefaultNodeSelector()
}
maxFails, timeout := xs.DefaultMaxFails, xs.DefaultFailTimeout
if cfg.Selector != nil {
if cfg.Selector.MaxFails > 0 {
maxFails = cfg.Selector.MaxFails
}
if cfg.Selector.FailTimeout > 0 {
timeout = cfg.Selector.FailTimeout
}
}
opts := []xhop.Option{
xhop.NameOption(cfg.Name),
xhop.NodeOption(nodes...),
xhop.SelectorOption(sel),
xhop.FailFilterSettingsOption(maxFails, timeout),
xhop.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
xhop.ReloadPeriodOption(cfg.Reload),
xhop.LoggerOption(log.WithFields(map[string]any{
+77 -1
View File
@@ -4,13 +4,14 @@ import (
"io"
"testing"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry"
// Register connector and dialer implementations needed for node parsing.
_ "github.com/go-gost/x/connector/http"
@@ -185,6 +186,81 @@ func TestParseHop_WithNodeNilEntries(t *testing.T) {
}
}
func TestParseHop_GlobalNodeReference(t *testing.T) {
global := &config.NodeConfig{Name: "global-node", Addr: "example.com:8080"}
registry.NodeRegistry().Register(global.Name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
h, err := ParseHop(&config.HopConfig{
Name: "reference-hop",
Nodes: []*config.NodeConfig{{Name: global.Name}},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 || nodes[0].Name != global.Name || nodes[0].Addr != global.Addr {
t.Fatalf("referenced nodes = %v, want node %q at %q", nodes, global.Name, global.Addr)
}
}
func TestParseHop_GlobalNodeReferenceNotFound(t *testing.T) {
_, err := ParseHop(&config.HopConfig{
Name: "reference-hop",
Nodes: []*config.NodeConfig{{Name: "missing-global-node"}},
}, testLogger())
if err == nil {
t.Fatal("expected missing global node error")
}
}
func TestParseHop_NodeWithAdditionalFieldRemainsInline(t *testing.T) {
global := &config.NodeConfig{Name: "shared-name", Addr: "global.example:8080"}
registry.NodeRegistry().Register(global.Name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
h, err := ParseHop(&config.HopConfig{
Name: "inline-hop",
Nodes: []*config.NodeConfig{{
Name: global.Name,
Addr: "inline.example:8080",
}},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Addr != "inline.example:8080" {
t.Fatalf("node addr = %q, want inline node address", nodes[0].Addr)
}
}
func TestParseHop_GlobalNodeCreatesIndependentRuntimeNodes(t *testing.T) {
global := &config.NodeConfig{Name: "independent-node", Addr: "example.com:8080"}
registry.NodeRegistry().Register(global.Name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
parse := func(name string) *chain.Node {
h, err := ParseHop(&config.HopConfig{
Name: name, Nodes: []*config.NodeConfig{{Name: global.Name}},
}, testLogger())
if err != nil {
t.Fatalf("ParseHop: %v", err)
}
return h.(hop.NodeList).Nodes()[0]
}
if first, second := parse("hop-1"), parse("hop-2"); first == second {
t.Fatal("global node references must create independent runtime nodes")
}
if global.Connector != nil || global.Dialer != nil {
t.Fatal("parsing a reference must not mutate the global definition")
}
}
func TestParseHop_SockOptsMark(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "sockopts-hop",
+4
View File
@@ -12,6 +12,7 @@ import (
hosts_plugin "github.com/go-gost/x/hosts/plugin"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseHostMapper converts a HostsConfig into a hosts.HostMapper. It resolves
@@ -23,6 +24,9 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -11,6 +11,7 @@ import (
ingress_plugin "github.com/go-gost/x/ingress/plugin"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseIngress converts an IngressConfig into an ingress.Ingress. It resolves
@@ -22,6 +23,9 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -15,6 +15,7 @@ import (
xrate "github.com/go-gost/x/limiter/rate"
xtraffic "github.com/go-gost/x/limiter/traffic"
traffic_plugin "github.com/go-gost/x/limiter/traffic/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseTrafficLimiter converts a LimiterConfig into a traffic.TrafficLimiter.
@@ -26,6 +27,9 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+12 -1
View File
@@ -1,6 +1,7 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -44,10 +45,20 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
Compress: cfg.Log.Rotation.Compress,
}
} else {
// Nothing rotates this file, and it is the only place that sees both
// the path and the missing rotation — the default (an unconfigured
// logger) always carries one.
fmt.Fprintf(os.Stderr, "logger: %s has no rotation configured: it will grow without bound\n", cfg.Log.Output)
os.MkdirAll(filepath.Dir(cfg.Log.Output), 0755)
f, err := os.OpenFile(cfg.Log.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
logger.Default().Warn(err)
// Reported on stderr, not through logger.Default(): that logger
// is what this call is on its way to installing and is nil until
// it is — calling a method on it panics, which aborted the whole
// process on a log path that could not be opened (an app whose
// working directory is not writable, say). A log file that
// cannot be opened leaves the default output in place instead.
fmt.Fprintf(os.Stderr, "logger: open %s: %v (logging to stderr)\n", cfg.Log.Output, err)
} else {
out = f
}
+41 -1
View File
@@ -1,7 +1,10 @@
package logger
import (
"fmt"
"io"
"os"
"os/exec"
"testing"
"github.com/go-gost/core/logger"
@@ -9,8 +12,15 @@ import (
xlogger "github.com/go-gost/x/logger"
)
// probeEnv marks the child process TestParseLogger_UnopenableOutput spawns:
// that test needs a process in which no default logger was ever installed,
// which is the state the failure path used to dereference.
const probeEnv = "X_LOGGER_UNOPENABLE_PROBE"
func TestMain(m *testing.M) {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
if os.Getenv(probeEnv) != "1" {
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
}
m.Run()
}
@@ -140,3 +150,33 @@ func TestList_WithNames(t *testing.T) {
t.Fatal("expected empty list for unregistered names")
}
}
// TestParseLogger_UnopenableOutput: a log file that cannot be opened must leave
// the process logging somewhere, not panic. The failure used to be reported
// through logger.Default(), which is nil until a caller installs one — and this
// call is usually the one doing the installing — so an unwritable path (an app
// whose working directory is not writable, a typo'd directory) dereferenced a
// nil logger and took the whole process down.
//
// The state that matters is process-wide and TestMain sets it for every other
// case here, so the assertion runs in a child process: a fresh one is the only
// place the default logger is still unset, exactly as it is at startup.
func TestParseLogger_UnopenableOutput(t *testing.T) {
if os.Getenv(probeEnv) == "1" {
lg := ParseLogger(&config.LoggerConfig{Log: &config.LogConfig{
Output: "/nonexistent-dir-for-test/wisper.log",
Level: "info",
}})
if lg == nil {
fmt.Fprintln(os.Stderr, "ParseLogger returned nil for an unopenable output")
os.Exit(1)
}
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestParseLogger_UnopenableOutput", "-test.v")
cmd.Env = append(os.Environ(), probeEnv+"=1")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("a log file that cannot be opened must not be fatal:\n%s", out)
}
}
+415 -50
View File
@@ -4,7 +4,9 @@ import (
"fmt"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/connector"
@@ -20,14 +22,218 @@ import (
tls_util "github.com/go-gost/x/internal/util/tls"
mdx "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
xp2p "github.com/go-gost/x/p2p"
"github.com/go-gost/x/registry"
"github.com/go-gost/x/routing"
)
// MaxMatcherBodySize bounds the request body prefix (in bytes) exposed to
// body matchers via routing.Request.Body. It protects against unbounded
// buffering when a node opts in to body matching.
const MaxMatcherBodySize = 1 << 20 // 1MB
// DefaultMatcherBodySize is the default request body prefix (in bytes) exposed
// to body matchers when a node has BodyRegexp/BodyJSON matchers but no
// explicit bodySize.
const DefaultMatcherBodySize = 1 << 20 // 1MB
// MaxMatcherBodySize is the hard cap for matcher body prefix. Values above
// this are silently clamped. Protects against unbounded in-memory buffering
// when a node opts in to body matching.
const MaxMatcherBodySize = 10 << 20 // 10MB
// IsReference reports whether cfg contains only a non-empty name. Such a
// config references a definition from the top-level nodes section.
// Any additional field, even a zero-valued one set explicitly (e.g. an
// empty but non-nil connector), makes the config inline.
// NOTE: keep this in sync with NodeConfig — a new field must be added here,
// otherwise a config setting only that field would misresolve as a reference.
func IsReference(cfg *config.NodeConfig) bool {
if cfg == nil || strings.TrimSpace(cfg.Name) == "" {
return false
}
return cfg.Addr == "" && cfg.Network == "" && cfg.Bypass == "" &&
len(cfg.Bypasses) == 0 && cfg.Resolver == "" && cfg.Hosts == "" &&
cfg.Connector == nil && cfg.Dialer == nil &&
cfg.Interface == "" && cfg.Netns == "" && cfg.SockOpts == nil &&
cfg.Filter == nil && cfg.Matcher == nil && cfg.HTTP == nil &&
cfg.TLS == nil && cfg.Probe == nil && len(cfg.Metadata) == 0
}
// ResolveConfig resolves a name-only config through the global node registry
// and returns an independent copy of the definition. Inline configs are
// returned unchanged to preserve their existing parsing behavior.
func ResolveConfig(cfg *config.NodeConfig) (*config.NodeConfig, error) {
if cfg == nil {
return nil, nil
}
if !IsReference(cfg) {
return cfg, nil
}
name := strings.TrimSpace(cfg.Name)
cfg = registry.NodeRegistry().Get(name)
if cfg == nil {
return nil, fmt.Errorf("node %q not found", name)
}
return cloneConfig(cfg), nil
}
// cloneConfig returns a deep copy of cfg so that parsing (hop-level
// inheritance, connector/dialer type defaults, TLS ServerName defaulting)
// only ever mutates the copy, never the shared global definition.
// NOTE: keep this in sync with NodeConfig — a new reference-type field
// must be cloned here, otherwise hops would share it with the registry.
func cloneConfig(cfg *config.NodeConfig) *config.NodeConfig {
c := *cfg
c.Bypasses = append([]string(nil), cfg.Bypasses...)
if cfg.Metadata != nil {
c.Metadata = make(map[string]any, len(cfg.Metadata))
for k, v := range cfg.Metadata {
c.Metadata[k] = v
}
}
if cfg.Connector != nil {
connector := *cfg.Connector
connector.Auth = cloneAuth(cfg.Connector.Auth)
connector.TLS = cloneTLSConfig(cfg.Connector.TLS)
if cfg.Connector.Metadata != nil {
connector.Metadata = make(map[string]any, len(cfg.Connector.Metadata))
for k, v := range cfg.Connector.Metadata {
connector.Metadata[k] = v
}
}
c.Connector = &connector
}
if cfg.Dialer != nil {
dialer := *cfg.Dialer
dialer.Auth = cloneAuth(cfg.Dialer.Auth)
dialer.TLS = cloneTLSConfig(cfg.Dialer.TLS)
if cfg.Dialer.Metadata != nil {
dialer.Metadata = make(map[string]any, len(cfg.Dialer.Metadata))
for k, v := range cfg.Dialer.Metadata {
dialer.Metadata[k] = v
}
}
c.Dialer = &dialer
}
if cfg.SockOpts != nil {
sockOpts := *cfg.SockOpts
c.SockOpts = &sockOpts
}
if cfg.Filter != nil {
filter := *cfg.Filter
c.Filter = &filter
}
if cfg.Matcher != nil {
matcher := *cfg.Matcher
c.Matcher = &matcher
}
if cfg.HTTP != nil {
http := *cfg.HTTP
http.Header = cloneStringMap(cfg.HTTP.Header)
http.RequestHeader = cloneStringMap(cfg.HTTP.RequestHeader)
http.ResponseHeader = cloneStringMap(cfg.HTTP.ResponseHeader)
http.Rewrite = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.Rewrite...)
http.RewriteURL = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.RewriteURL...)
http.RewriteBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteBody...)
http.RewriteRequestBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteRequestBody...)
http.RewriteResponseBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteResponseBody...)
http.RewriteRequestHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteRequestHeader...)
http.RewriteResponseHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteResponseHeader...)
http.Auth = cloneAuth(cfg.HTTP.Auth)
c.HTTP = &http
}
if cfg.TLS != nil {
tls := *cfg.TLS
if cfg.TLS.Options != nil {
options := *cfg.TLS.Options
options.CipherSuites = append([]string(nil), cfg.TLS.Options.CipherSuites...)
options.ALPN = append([]string(nil), cfg.TLS.Options.ALPN...)
tls.Options = &options
}
c.TLS = &tls
}
if cfg.Probe != nil {
probe := *cfg.Probe
probe.HTTPHeaders = cloneStringMap(cfg.Probe.HTTPHeaders)
c.Probe = &probe
}
return &c
}
func cloneAuth(cfg *config.AuthConfig) *config.AuthConfig {
if cfg == nil {
return nil
}
auth := *cfg
return &auth
}
func cloneTLSConfig(cfg *config.TLSConfig) *config.TLSConfig {
if cfg == nil {
return nil
}
tls := *cfg
tls.ServerNames = append([]string(nil), cfg.ServerNames...)
if cfg.Options != nil {
options := *cfg.Options
options.CipherSuites = append([]string(nil), cfg.Options.CipherSuites...)
options.ALPN = append([]string(nil), cfg.Options.ALPN...)
tls.Options = &options
}
return &tls
}
func cloneStringMap(m map[string]string) map[string]string {
if m == nil {
return nil
}
c := make(map[string]string, len(m))
for k, v := range m {
c[k] = v
}
return c
}
// filterToMatcherRule converts a deprecated NodeFilterConfig (host/protocol/
// path) into an equivalent matcher DSL rule. Empty fields are omitted; an
// empty result means the node is unconditional. Path maps to PathPrefix
// (the legacy filter is a prefix match, not exact).
func filterToMatcherRule(filter *config.NodeFilterConfig) string {
if filter == nil {
return ""
}
var parts []string
// Normalize the legacy host forms first: `*.example.com` and
// `.example.com` both meant "apex AND subdomains" in the legacy filter.
// Strip the wildcard so the leading-dot branch below handles both.
host := filter.Host
if strings.HasPrefix(host, "*") {
host = host[1:]
if !strings.HasPrefix(host, ".") {
host = "." + host
}
}
if host != "" {
// A leading-dot host matches the apex AND its subdomains in the
// legacy filter (HasSuffix on the dot-stripped form), but matcher
// Host(.x) only matches subdomains. Emit both to preserve apex
// coverage. Parenthesize the OR when other conjuncts follow, since
// && binds tighter than || in the matcher DSL.
if strings.HasPrefix(host, ".") {
h := "Host(`" + host[1:] + "`) || Host(`" + host + "`)"
if filter.Protocol != "" || filter.Path != "" {
h = "(" + h + ")"
}
parts = append(parts, h)
} else {
parts = append(parts, "Host(`"+host+"`)")
}
}
if prot := filter.Protocol; prot != "" {
parts = append(parts, "Proto(`"+prot+"`)")
}
if path := filter.Path; path != "" {
parts = append(parts, "PathPrefix(`"+path+"`)")
}
return strings.Join(parts, " && ")
}
// ParseNode converts a NodeConfig into a *chain.Node. It resolves the
// connector and dialer from their registries, applies TLS settings, extracts
@@ -38,9 +244,43 @@ const MaxMatcherBodySize = 1 << 20 // 1MB
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
var out []chain.HTTPBodyRewriteSettings
for _, v := range vs {
pattern, _ := regexp.Compile(v.Match)
var pattern *regexp.Regexp
var rewriteType string
js, ok := strings.CutPrefix(v.Match, "json:")
del := false
if !ok {
// json-:<path> deletes the matched JSON field instead of replacing it.
if js, ok = strings.CutPrefix(v.Match, "json-:"); ok {
del = true
}
}
if ok {
// json:<path>[=<value-regex>] or json-:<path>[=<value-regex>]
path, valRegex, _ := strings.Cut(js, "=")
if valRegex == "" {
valRegex = ".*"
}
var err error
pattern, err = regexp.Compile(valRegex)
if err != nil {
log.Warnf("invalid JSON value regex %q for path %q: %v", valRegex, path, err)
continue
}
if del {
rewriteType = "json-:" + path
} else {
rewriteType = "json:" + path
}
} else {
if v.Match != "" {
pattern, _ = regexp.Compile(v.Match)
}
rewriteType = v.Type
}
rw := chain.HTTPBodyRewriteSettings{
Type: v.Type,
Type: rewriteType,
Pattern: pattern,
Replacement: []byte(v.Replacement),
MaxChunkSize: v.MaxChunkSize,
@@ -58,6 +298,38 @@ func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []c
return out
}
func parseHeaderRewrites(vs []config.HTTPHeaderRewriteConfig, log logger.Logger) []chain.HTTPHeaderRewriteSettings {
var out []chain.HTTPHeaderRewriteSettings
for _, v := range vs {
var name, pattern *regexp.Regexp
if v.Name != "" {
name, _ = regexp.Compile(v.Name)
}
if v.Match != "" {
pattern, _ = regexp.Compile(v.Match)
}
rw := chain.HTTPHeaderRewriteSettings{
Name: name,
Pattern: pattern,
Replacement: []byte(v.Replacement),
}
if v.Rewriter != "" {
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
}
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
}
// Gate on the config string, not the compiled regexp: regexp.Compile("")
// returns a non-nil match-all regexp, so an empty Name with no rewriter
// would otherwise become a silent no-op rule.
if v.Name != "" || rw.Rewriter != nil {
out = append(out, rw)
}
}
return out
}
func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
if cfg == nil {
return nil, nil
@@ -152,6 +424,25 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
return nil, fmt.Errorf("unregistered dialer: %s", dialCfg.Type)
}
// p2p: wrap the dialer so the node's base transport (the path to this
// node) goes through a tunnel opened by the named p2p plugin instead of
// a direct network dial. The inner dialer's protocol (tls/ws/mux) runs
// unchanged on top of the tunnel. Only whitelisted dialers qualify:
// the wrapper's contract is "inner dials one plain TCP stream via
// options.Dialer". The wrap happens before Init so the chain inits
// exactly once, through the wrapper.
if name := mdutil.GetString(md, parsing.MDKeyP2P); name != "" {
provider := registry.P2PRegistry().Get(name)
if provider == nil {
return nil, fmt.Errorf("unregistered p2p: %s", name)
}
if !xp2p.SupportedDialer(dialCfg.Type) {
return nil, fmt.Errorf("dialer %q does not support p2p", dialCfg.Type)
}
d = xp2p.NewTunnelDialer(d, provider,
xp2p.WithTunnelNetwork(mdutil.GetString(md, parsing.MDKeyP2PNetwork)))
}
if err := d.Init(mdx.NewMetadata(dialCfg.Metadata)); err != nil {
dialerLogger.Error("init: ", err)
return nil, err
@@ -180,55 +471,53 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
chain.NetworkNodeOption(cfg.Network),
}
if filter := cfg.Filter; filter != nil {
// convert *.example.com to .example.com
// convert *example.com to example.com
host := filter.Host
if strings.HasPrefix(host, "*") {
host = host[1:]
if !strings.HasPrefix(host, ".") {
host = "." + host
}
}
var (
rule string
priority int
bodySize int
)
settings := &chain.NodeFilterSettings{
Protocol: filter.Protocol,
Host: host,
Path: filter.Path,
}
opts = append(opts, chain.NodeFilterOption(settings))
// Deprecated filter: normalize into an equivalent matcher DSL rule so the
// runtime keeps a single filtering logic. Explicit -1 priority opts out of
// auto-priority and the priority short-circuit, preserving the legacy
// filter behavior (always through the selector).
if filter := cfg.Filter; filter != nil {
rule = filterToMatcherRule(filter)
priority = -1
bodySize = DefaultMatcherBodySize
}
if cfg.Matcher != nil {
priority := cfg.Matcher.Priority
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
// Priority 0 means "use default": automatically set to the
// rule length so longer (more specific) rules outrank shorter
// ones. Use a negative priority to opt out of this behavior
// and always go through the selector.
if priority == 0 {
priority = len(cfg.Matcher.Rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
if bodySize := cfg.Matcher.BodySize; bodySize > 0 {
if bodySize > MaxMatcherBodySize {
bodySize = MaxMatcherBodySize
}
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
}
opts = append(opts, chain.PriorityNodeOption(priority))
priority = cfg.Matcher.Priority
rule = cfg.Matcher.Rule
bodySize = cfg.Matcher.BodySize
}
if rule = strings.TrimSpace(rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, rule)
// Priority 0 means "use default": automatically set to the
// rule length so longer (more specific) rules outrank shorter
// ones. Use a negative priority to opt out of this behavior
// and always go through the selector.
if priority == 0 {
priority = len(rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
if bodySize <= 0 {
bodySize = DefaultMatcherBodySize
} else if bodySize > MaxMatcherBodySize {
bodySize = MaxMatcherBodySize
}
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
opts = append(opts, chain.PriorityNodeOption(priority))
if cfg.HTTP != nil {
settings := &chain.HTTPNodeSettings{
Host: cfg.HTTP.Host,
@@ -239,6 +528,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
settings.RequestHeader = cfg.HTTP.Header
}
if v := cfg.HTTP.HostPattern; v != "" {
settings.HostPattern, _ = regexp.Compile(v)
}
if auth := cfg.HTTP.Auth; auth != nil && auth.Username != "" {
settings.Auther = xauth.NewAuthenticator(
xauth.AuthsOption(map[string]string{auth.Username: auth.Password}),
@@ -265,6 +558,13 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
settings.RewriteRequestHeader = parseHeaderRewrites(cfg.HTTP.RewriteRequestHeader, log)
settings.RewriteResponseHeader = parseHeaderRewrites(cfg.HTTP.RewriteResponseHeader, log)
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
settings.FailCodes = parseFailCodes(v, nodeLogger)
}
opts = append(opts, chain.HTTPNodeOption(settings))
}
@@ -281,5 +581,70 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
}
opts = append(opts, chain.TLSNodeOption(tlsCfg))
}
return chain.NewNode(cfg.Name, cfg.Addr, opts...), nil
node := chain.NewNode(cfg.Name, cfg.Addr, opts...)
if cfg.Probe != nil {
if pc := ParseProbeConfig(cfg.Probe); pc != nil {
xchain.StartNodeProbe(node, pc, nodeLogger)
}
}
return node, nil
}
// ParseProbeConfig converts a config.ProbeConfig into a chain.ProbeConfig.
// Returns nil when the config is invalid (e.g. empty addr).
func ParseProbeConfig(cfg *config.ProbeConfig) *chain.ProbeConfig {
if cfg == nil || (cfg.Addr == "" && cfg.Type != "cmd") {
return nil
}
if cfg.Type == "cmd" && cfg.Command == "" {
return nil
}
pt := chain.ProbeTypeTCP
switch cfg.Type {
case "http":
pt = chain.ProbeTypeHTTP
case "cmd":
pt = chain.ProbeTypeCmd
}
interval := cfg.Interval
if interval <= 0 {
interval = 30 * time.Second
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &chain.ProbeConfig{
Type: pt,
Addr: cfg.Addr,
Interval: interval,
Timeout: timeout,
HTTPPath: cfg.HTTPPath,
HTTPHost: cfg.HTTPHost,
HTTPHeaders: cfg.HTTPHeaders,
ExpectedStatus: cfg.ExpectedStatus,
Command: cfg.Command,
}
}
// parseFailCodes parses a comma-separated status code list, e.g. "429,5xx".
// Tokens ending in "xx" become hundred-level wildcards (5xx → 5, matching
// 500-599). Invalid tokens are logged and skipped.
func parseFailCodes(s string, log logger.Logger) chain.FailCodes {
var codes chain.FailCodes
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if len(part) == 3 && strings.HasSuffix(part, "xx") {
if prefix, err := strconv.Atoi(part[:1]); err == nil && prefix > 0 {
codes = append(codes, prefix) // < 100 → wildcard
continue
}
} else if code, err := strconv.Atoi(part); err == nil && code >= 100 {
codes = append(codes, code)
continue
}
log.Warnf("failCodes: invalid token %q", part)
}
return codes
}
+172
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/registry"
// Register connector and dialer implementations needed for node parsing.
_ "github.com/go-gost/x/connector/http"
@@ -387,6 +388,44 @@ func TestParseNode_WithHTTPDeprecatedHeader(t *testing.T) {
}
}
func TestParseNode_WithHeaderRewrite(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "hdr-node",
Addr: "example.com:8080",
HTTP: &config.HTTPNodeConfig{
RewriteRequestHeader: []config.HTTPHeaderRewriteConfig{
{Name: "(?i)^referer$", Match: ".*", Replacement: ""},
},
RewriteResponseHeader: []config.HTTPHeaderRewriteConfig{
{Name: "(?i)^location$", Match: "https://github.com", Replacement: "https://127.0.0.1:8080"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n == nil {
t.Fatal("expected non-nil node")
}
settings := n.Options().HTTP
if settings == nil {
t.Fatal("expected HTTP settings")
}
if len(settings.RewriteRequestHeader) != 1 || len(settings.RewriteResponseHeader) != 1 {
t.Fatalf("rewrite header counts = %d/%d, want 1/1", len(settings.RewriteRequestHeader), len(settings.RewriteResponseHeader))
}
}
func TestParseHeaderRewrites_EmptyNameNoRewriter(t *testing.T) {
// Empty Name with no Rewriter must not be appended (silent no-op guard).
out := parseHeaderRewrites([]config.HTTPHeaderRewriteConfig{
{Name: "", Match: ".*", Replacement: ""},
}, testLogger())
if len(out) != 0 {
t.Fatalf("out = %d rules, want 0", len(out))
}
}
func TestParseNode_WithTLSNode(t *testing.T) {
n, err := ParseNode("test-hop", &config.NodeConfig{
Name: "tls-node",
@@ -501,3 +540,136 @@ func TestParseNode_DeprecatedFilterFields(t *testing.T) {
t.Fatal("expected non-nil node")
}
}
func TestFilterToMatcherRule(t *testing.T) {
tests := []struct {
name string
filter *config.NodeFilterConfig
want string
}{
{"nil", nil, ""},
{"all empty", &config.NodeFilterConfig{}, ""},
{"host only", &config.NodeFilterConfig{Host: "example.com"}, "Host(`example.com`)"},
{"dot host", &config.NodeFilterConfig{Host: ".example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"star host", &config.NodeFilterConfig{Host: "*.example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"protocol only", &config.NodeFilterConfig{Protocol: "ssh"}, "Proto(`ssh`)"},
{"path only", &config.NodeFilterConfig{Path: "/api"}, "PathPrefix(`/api`)"},
{"combined", &config.NodeFilterConfig{
Host: ".example.com",
Protocol: "http",
Path: "/api",
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
{"star combined", &config.NodeFilterConfig{
Host: "*.example.com",
Protocol: "http",
Path: "/api",
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filterToMatcherRule(tt.filter); got != tt.want {
t.Errorf("filterToMatcherRule(%v) = %q, want %q", tt.filter, got, tt.want)
}
})
}
}
func TestIsReference(t *testing.T) {
tests := []struct {
name string
cfg *config.NodeConfig
want bool
}{
{"nil", nil, false},
{"empty name", &config.NodeConfig{}, false},
{"blank name", &config.NodeConfig{Name: " "}, false},
{"name only", &config.NodeConfig{Name: "shared"}, true},
{"name and addr", &config.NodeConfig{Name: "shared", Addr: "example.com:80"}, false},
{"name and network", &config.NodeConfig{Name: "shared", Network: "tcp"}, false},
{"name and empty-but-non-nil connector", &config.NodeConfig{Name: "shared", Connector: &config.ConnectorConfig{}}, false},
{"name and empty bypasses slice", &config.NodeConfig{Name: "shared", Bypasses: []string{}}, true},
{"name and matcher", &config.NodeConfig{Name: "shared", Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsReference(tt.cfg); got != tt.want {
t.Errorf("IsReference(%+v) = %v, want %v", tt.cfg, got, tt.want)
}
})
}
}
func TestResolveConfig_TrimsName(t *testing.T) {
const name = "spaced-global-node"
registry.NodeRegistry().Register(name, &config.NodeConfig{Name: name, Addr: "example.com:8080"})
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
got, err := ResolveConfig(&config.NodeConfig{Name: " " + name + " "})
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if got == nil || got.Addr != "example.com:8080" {
t.Fatalf("resolved = %+v, want addr %q", got, "example.com:8080")
}
}
func TestResolveConfig_DeepCopyIsolation(t *testing.T) {
const name = "rich-global-node"
global := &config.NodeConfig{
Name: name,
Addr: "example.com:8080",
Metadata: map[string]any{"k": "v"},
Connector: &config.ConnectorConfig{
Type: "http",
Auth: &config.AuthConfig{Username: "u"},
Metadata: map[string]any{"ck": "cv"},
TLS: &config.TLSConfig{ServerName: "sni.example", Options: &config.TLSOptions{ALPN: []string{"h2"}}},
},
Dialer: &config.DialerConfig{
Type: "tcp",
Metadata: map[string]any{"dk": "dv"},
},
Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"},
HTTP: &config.HTTPNodeConfig{
Host: "h",
RequestHeader: map[string]string{"A": "b"},
Auth: &config.AuthConfig{Username: "hu"},
},
TLS: &config.TLSNodeConfig{ServerName: "tls.example", Options: &config.TLSOptions{MinVersion: "1.2"}},
Probe: &config.ProbeConfig{Type: "tcp", Addr: "example.com:80", HTTPHeaders: map[string]string{"H": "v"}},
}
registry.NodeRegistry().Register(name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
got, err := ResolveConfig(&config.NodeConfig{Name: name})
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
// Mutate every nested value on the copy; the global must not change.
got.Metadata["k"] = "mut"
got.Connector.Auth.Username = "mut"
got.Connector.Metadata["ck"] = "mut"
got.Connector.TLS.Options.ALPN[0] = "mut"
got.Dialer.Metadata["dk"] = "mut"
got.Matcher.Rule = "mut"
got.HTTP.RequestHeader["A"] = "mut"
got.HTTP.Auth.Username = "mut"
got.TLS.Options.MinVersion = "mut"
got.Probe.HTTPHeaders["H"] = "mut"
if global.Metadata["k"] != "v" ||
global.Connector.Auth.Username != "u" ||
global.Connector.Metadata["ck"] != "cv" ||
global.Connector.TLS.Options.ALPN[0] != "h2" ||
global.Dialer.Metadata["dk"] != "dv" ||
global.Matcher.Rule != "Host(`x`)" ||
global.HTTP.RequestHeader["A"] != "b" ||
global.HTTP.Auth.Username != "hu" ||
global.TLS.Options.MinVersion != "1.2" ||
global.Probe.HTTPHeaders["H"] != "v" {
t.Fatalf("global definition was mutated via the resolved copy: %+v", global)
}
}
+35
View File
@@ -0,0 +1,35 @@
package node
import (
"testing"
"github.com/go-gost/x/config"
coreRouting "github.com/go-gost/core/routing"
"github.com/go-gost/x/routing"
)
func TestFilterToMatcherRule_Precedence(t *testing.T) {
for _, fc := range []struct{ name, host string }{
{"dot", ".example.com"},
{"star", "*.example.com"},
} {
t.Run(fc.name, func(t *testing.T) {
rule := filterToMatcherRule(&config.NodeFilterConfig{
Host: fc.host, Protocol: "http", Path: "/api",
})
m, err := routing.NewMatcher(rule)
if err != nil {
t.Fatalf("parse %q: %v", rule, err)
}
if m.Match(&coreRouting.Request{Host: "example.com", Protocol: "https", Path: "/other"}) {
t.Errorf("rule %q matched apex+https+/other (should reject)", rule)
}
if !m.Match(&coreRouting.Request{Host: "example.com", Protocol: "http", Path: "/api/v1"}) {
t.Errorf("rule %q rejected apex+http+/api/v1 (should accept)", rule)
}
if !m.Match(&coreRouting.Request{Host: "www.example.com", Protocol: "http", Path: "/api"}) {
t.Errorf("rule %q rejected www+http+/api (should accept)", rule)
}
})
}
}
+5
View File
@@ -4,10 +4,12 @@ import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
observer_plugin "github.com/go-gost/x/observer/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseObserver converts an ObserverConfig into an observer.Observer. It only
@@ -18,6 +20,9 @@ func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
return nil
}
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+47
View File
@@ -0,0 +1,47 @@
// Package p2p converts a [config.P2PConfig] into a p2p.Tunnel.
package p2p
import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xp2p "github.com/go-gost/x/p2p"
p2p_plugin "github.com/go-gost/x/p2p/plugin"
xplugin "github.com/go-gost/x/plugin"
)
// ParseP2P converts a P2PConfig into a p2p.Tunnel backed by an
// external gRPC plugin. Returns nil when cfg is nil or no plugin is
// configured. The HTTP plugin variant is not implemented; configuring it
// logs a deprecation notice and falls back to gRPC.
func ParseP2P(cfg *config.P2PConfig) (p xp2p.Tunnel) {
if cfg == nil || cfg.Plugin == nil {
return nil
}
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
ServerName: cfg.Plugin.TLS.ServerName,
InsecureSkipVerify: !cfg.Plugin.TLS.Secure,
}
}
switch strings.ToLower(cfg.Plugin.Type) {
case "http":
logger.Default().Warn("p2p: http plugin type is deprecated/unimplemented, falling back to grpc")
fallthrough
default:
return p2p_plugin.NewGRPCPlugin(
cfg.Name, cfg.Plugin.Addr,
plugin.TokenOption(cfg.Plugin.Token),
plugin.TLSConfigOption(tlsCfg),
)
}
}
+10
View File
@@ -96,4 +96,14 @@ const (
// MDKeyLabels holds static key/value labels attached to a service's
// records and logs.
MDKeyLabels = "labels"
// MDKeyP2P selects the p2p tunnel provider for a chain node's base
// transport. The value is the name of a registered p2ps: component.
MDKeyP2P = "p2p"
// MDKeyP2PNetwork overrides the tunnel network a p2p-wrapped node asks the
// provider for (see xp2p.WithTunnelNetwork). Empty derives it from the inner
// dialer. A tun link sets it to "ip": datagram-shaped like udp, but
// session-scoped on the host, so the link ends on a lost peer session.
MDKeyP2PNetwork = "p2p.network"
)
+5
View File
@@ -43,6 +43,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
Services: []*config.ServiceConfig{{Name: "s1"}},
Chains: []*config.ChainConfig{{Name: "c1"}},
Hops: []*config.HopConfig{{Name: "h1"}},
Nodes: []*config.NodeConfig{{Name: "n1", Addr: "n1.example:80"}},
Authers: []*config.AutherConfig{{Name: "a1"}},
Admissions: []*config.AdmissionConfig{{Name: "ad1"}},
Bypasses: []*config.BypassConfig{{Name: "b1"}},
@@ -63,6 +64,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
Services: []*config.ServiceConfig{{Name: "s2"}},
Chains: []*config.ChainConfig{{Name: "c2"}},
Hops: []*config.HopConfig{{Name: "h2"}},
Nodes: []*config.NodeConfig{{Name: "n2", Addr: "n2.example:80"}},
Authers: []*config.AutherConfig{{Name: "a2"}},
Admissions: []*config.AdmissionConfig{{Name: "ad2"}},
Bypasses: []*config.BypassConfig{{Name: "b2"}},
@@ -91,6 +93,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
if len(got.Hops) != 2 {
t.Fatal("hops not properly appended")
}
if len(got.Nodes) != 2 {
t.Fatal("nodes not properly appended")
}
if len(got.Authers) != 2 {
t.Fatal("authers not properly appended")
}
+3
View File
@@ -320,6 +320,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
Services: append(cfg1.Services, cfg2.Services...),
Chains: append(cfg1.Chains, cfg2.Chains...),
Hops: append(cfg1.Hops, cfg2.Hops...),
Nodes: append(cfg1.Nodes, cfg2.Nodes...),
Authers: append(cfg1.Authers, cfg2.Authers...),
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
@@ -329,6 +330,8 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
SDs: append(cfg1.SDs, cfg2.SDs...),
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
P2Ps: append(cfg1.P2Ps, cfg2.P2Ps...),
Caches: append(cfg1.Caches, cfg2.Caches...),
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
+4
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-gost/core/recorder"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
xrecorder "github.com/go-gost/x/recorder"
recorder_plugin "github.com/go-gost/x/recorder/plugin"
"gopkg.in/natefinch/lumberjack.v2"
@@ -33,6 +34,9 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-gost/core/resolver"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
xresolver "github.com/go-gost/x/resolver"
resolver_plugin "github.com/go-gost/x/resolver/plugin"
@@ -24,6 +25,9 @@ func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+5
View File
@@ -4,9 +4,11 @@ import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
rewriter_plugin "github.com/go-gost/x/rewriter/plugin"
)
@@ -19,6 +21,9 @@ func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+4
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/loader"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
xrouter "github.com/go-gost/x/router"
router_plugin "github.com/go-gost/x/router/plugin"
)
@@ -23,6 +24,9 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
}
if cfg.Plugin != nil {
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+5
View File
@@ -4,9 +4,11 @@ import (
"crypto/tls"
"strings"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/sd"
"github.com/go-gost/x/config"
"github.com/go-gost/x/internal/plugin"
xplugin "github.com/go-gost/x/plugin"
sd_plugin "github.com/go-gost/x/sd/plugin"
)
@@ -17,6 +19,9 @@ func ParseSD(cfg *config.SDConfig) sd.SD {
return nil
}
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
}
var tlsCfg *tls.Config
if cfg.Plugin.TLS != nil {
tlsCfg = &tls.Config{
+9 -3
View File
@@ -55,15 +55,21 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
strategy = xs.HashStrategy[*chain.Node]()
case "parallel":
strategy = xs.ParallelStrategy[*chain.Node]()
case "lowestlatency", "lowest", "ll":
strategy = xs.LowestLatencyStrategy[*chain.Node]()
default:
strategy = xs.RoundRobinStrategy[*chain.Node]()
}
return xs.NewSelector(
strategy,
filters := []selector.Filter[*chain.Node]{
xs.FailFilter[*chain.Node](cfg.MaxFails, cfg.FailTimeout),
xs.BackupFilter[*chain.Node](),
)
}
if cfg.MaxLatency > 0 {
filters = append(filters, xs.LatencyFilter[*chain.Node](cfg.MaxLatency))
}
return xs.NewSelector(strategy, filters...)
}
// DefaultNodeSelector returns a node selector using round-robin strategy with
+168 -23
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"io"
"runtime"
"strings"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/go-gost/core/hop"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/routing"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
@@ -28,8 +30,12 @@ import (
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
hop_parser "github.com/go-gost/x/config/parsing/hop"
logger_parser "github.com/go-gost/x/config/parsing/logger"
node_parser "github.com/go-gost/x/config/parsing/node"
selector_parser "github.com/go-gost/x/config/parsing/selector"
xhop "github.com/go-gost/x/hop"
tls_util "github.com/go-gost/x/internal/util/tls"
xrouting "github.com/go-gost/x/routing"
xs "github.com/go-gost/x/selector"
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
@@ -199,13 +205,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if !ignoreChain {
routerOpts = append(routerOpts,
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup)),
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup, log)),
)
}
lnRouter := xchain.NewRouter(routerOpts...)
// The routers may hold a chainGroup running probe goroutines, and the
// forwarder hop may run probe goroutines of its own; make sure they are
// closed if service construction fails after this point.
var hRouter *xchain.Router
var fwdCloser io.Closer
success := false
defer func() {
if !success {
lnRouter.Close()
if hRouter != nil {
hRouter.Close()
}
if fwdCloser != nil {
fwdCloser.Close()
}
}
}()
listenOpts := []listener.Option{
listener.AddrOption(cfg.Addr),
listener.RouterOption(xchain.NewRouter(routerOpts...)),
listener.RouterOption(lnRouter),
listener.AutherOption(auther),
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
listener.TLSConfigOption(tlsConfig),
@@ -375,14 +400,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if !ignoreChain {
routerOpts = append(routerOpts,
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup)),
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup, log)),
)
}
var h handler.Handler
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
hRouter = xchain.NewRouter(routerOpts...)
h = rf(
handler.RouterOption(xchain.NewRouter(routerOpts...)),
handler.RouterOption(hRouter),
handler.AutherOption(auther),
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
@@ -391,6 +417,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
handler.RecordersOption(recorders...),
handler.CacheOption(registry.CacheRegistry().Get(cfg.Cache)),
handler.RewriterOption(rew),
handler.LoggerOption(handlerLogger),
handler.ServiceOption(cfg.Name),
@@ -401,10 +428,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
}
if forwarder, ok := h.(handler.Forwarder); ok {
hop, err := parseForwarder(cfg.Forwarder, log)
hop, closer, err := parseForwarder(cfg.Forwarder, log)
if err != nil {
return nil, err
}
fwdCloser = closer
forwarder.Forward(hop)
}
@@ -417,6 +445,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
closers := []io.Closer{lnRouter, hRouter}
if fwdCloser != nil {
closers = append(closers, fwdCloser)
}
s := xservice.NewService(cfg.Name, ln, h,
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
xservice.PreUpOption(preUp),
@@ -429,15 +461,31 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
xservice.ObserverPeriodOption(observerPeriod),
xservice.LoggerOption(serviceLogger),
xservice.LabelsOption(labels),
xservice.ClosersOption(closers...),
)
success = true
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
return s, nil
}
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, error) {
// parseForwarder builds the forwarder hop for a service. The returned
// io.Closer is non-nil only when the hop is owned by the service (inline
// nodes or a hop group) and must be closed on service shutdown; hops from
// the HopRegistry are closed by the registry on unregister.
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, io.Closer, error) {
if cfg == nil {
return nil, nil
return nil, nil, nil
}
// HopGroup takes precedence over Hop / Name / Nodes.
if cfg.HopGroup != nil {
group, err := parseHopGroup(cfg.HopGroup, log)
if err != nil || group == nil {
return nil, nil, err
}
closer, _ := group.(io.Closer)
return group, closer, nil
}
hopName := cfg.Hop
@@ -445,7 +493,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
hopName = cfg.Name
}
if hopName != "" {
return registry.HopRegistry().Get(hopName), nil
return registry.HopRegistry().Get(hopName), nil, nil
}
hc := config.HopConfig{
@@ -487,35 +535,132 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
Matcher: node.Matcher,
HTTP: httpCfg,
TLS: node.TLS,
Probe: node.Probe,
Metadata: node.Metadata,
})
}
return hop_parser.ParseHop(&hc, log)
// Inline nodes build a per-service hop, owned (and closed) by the service.
h, err := hop_parser.ParseHop(&hc, log)
if err != nil || h == nil {
return nil, nil, err
}
closer, _ := h.(io.Closer)
return h, closer, nil
}
func chainGroup(name string, group *config.ChainGroupConfig) chain.Chainer {
var chains []chain.Chainer
var sel selector.Selector[chain.Chainer]
if c := registry.ChainRegistry().Get(name); c != nil {
chains = append(chains, c)
func parseHopGroup(cfg *config.ForwardHopGroupConfig, log logger.Logger) (hop.Hop, error) {
if cfg == nil {
return nil, nil
}
if group != nil {
for _, s := range group.Chains {
if c := registry.ChainRegistry().Get(s); c != nil {
chains = append(chains, c)
var entries []*xhop.HopEntry
for _, hc := range cfg.Hops {
if hc == nil {
continue
}
h := registry.HopRegistry().Get(hc.Hop)
if h == nil {
log.Warnf("hop %q not found in hopGroup, skipping", hc.Hop)
continue
}
var m routing.Matcher
if hc.Matcher != nil && hc.Matcher.Rule != "" {
var err error
m, err = xrouting.NewMatcher(hc.Matcher.Rule)
if err != nil {
log.Warnf("hop %q: bad matcher rule %q: %v, skipping", hc.Hop, hc.Matcher.Rule, err)
continue
}
}
sel = selector_parser.ParseChainSelector(group.Selector)
entries = append(entries, xhop.NewHopEntry(h, m, node_parser.ParseProbeConfig(hc.Probe)))
}
if len(chains) == 0 {
if len(entries) == 0 {
return nil, nil
}
var hopSelector selector.Selector[hop.Hop]
if cfg.Selector != nil {
var strategy selector.Strategy[hop.Hop]
switch cfg.Selector.Strategy {
case "round", "rr":
strategy = xs.RoundRobinStrategy[hop.Hop]()
case "random", "rand":
strategy = xs.RandomStrategy[hop.Hop]()
case "fifo", "ha":
strategy = xs.FIFOStrategy[hop.Hop]()
case "hash":
strategy = xs.HashStrategy[hop.Hop]()
default:
strategy = xs.RoundRobinStrategy[hop.Hop]()
}
hopSelector = xs.NewSelector(
strategy,
xs.FailFilter[hop.Hop](cfg.Selector.MaxFails, cfg.Selector.FailTimeout),
xs.BackupFilter[hop.Hop](),
)
}
if hopSelector == nil {
hopSelector = xs.NewSelector(
xs.RoundRobinStrategy[hop.Hop](),
xs.FailFilter[hop.Hop](xs.DefaultMaxFails, xs.DefaultFailTimeout),
xs.BackupFilter[hop.Hop](),
)
}
return xhop.NewHopGroup(
xhop.WithEntriesOption(entries...),
xhop.WithGroupSelectorOption(hopSelector),
xhop.WithGroupLoggerOption(log.WithFields(map[string]any{
"kind": "hopGroup",
"service": "forwarder",
})),
), nil
}
func chainGroup(name string, group *config.ChainGroupConfig, log logger.Logger) chain.Chainer {
var entries []*xchain.ChainEntry
if c := registry.ChainRegistry().Get(name); c != nil {
entries = append(entries, xchain.NewChainEntry(c, nil, nil))
}
if group != nil {
for _, ge := range group.Chains {
c := registry.ChainRegistry().Get(ge.Chain)
if c == nil {
log.Warnf("chain %q not found in chainGroup, skipping", ge.Chain)
continue
}
var m routing.Matcher
if ge.Matcher != nil && ge.Matcher.Rule != "" {
var err error
m, err = xrouting.NewMatcher(ge.Matcher.Rule)
if err != nil {
log.Warnf("chain %q: bad matcher rule %q: %v, skipping", ge.Chain, ge.Matcher.Rule, err)
continue
}
}
entries = append(entries, xchain.NewChainEntry(c, m, node_parser.ParseProbeConfig(ge.Probe)))
}
}
if len(entries) == 0 {
return nil
}
var sel selector.Selector[chain.Chainer]
if group != nil {
sel = selector_parser.ParseChainSelector(group.Selector)
}
if sel == nil {
sel = selector_parser.DefaultChainSelector()
}
return xchain.NewChainGroup(chains...).
WithSelector(sel)
return xchain.NewChainGroup().
WithGroupEntries(entries...).
WithSelector(sel).
WithGroupLogger(log)
}
+328
View File
@@ -0,0 +1,328 @@
package masque
import (
"context"
"errors"
"net"
"os"
"sync"
"time"
"github.com/go-gost/core/logger"
)
const udpAssociationBufferSize = 64 * 1024
var errMissingDestination = errors.New("masque: destination address required")
type udpAssociationDialFunc func(ctx context.Context, addr net.Addr) (net.PacketConn, error)
type udpAssociationResult struct {
data []byte
addr net.Addr
}
type udpAssociationTunnel struct {
conn net.PacketConn
}
type udpAssociationConn struct {
ctx context.Context
cancel context.CancelFunc
localAddr net.Addr
dial udpAssociationDialFunc
closeIdle func() error
log logger.Logger
timeout time.Duration
closed chan struct{}
closeOnce sync.Once
closeErr error
results chan udpAssociationResult
mu sync.Mutex
tunnels map[string]*udpAssociationTunnel
readDeadline time.Time
readDeadlineChanged chan struct{}
}
func newUDPAssociationConn(
ctx context.Context,
localAddr net.Addr,
timeout time.Duration,
dial udpAssociationDialFunc,
closeIdle func() error,
log logger.Logger,
) *udpAssociationConn {
ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
c := &udpAssociationConn{
ctx: ctx,
cancel: cancel,
localAddr: localAddr,
dial: dial,
closeIdle: closeIdle,
log: log,
timeout: timeout,
closed: make(chan struct{}),
results: make(chan udpAssociationResult, 32),
tunnels: make(map[string]*udpAssociationTunnel),
readDeadlineChanged: make(chan struct{}),
}
return c
}
func (c *udpAssociationConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
for {
select {
case <-c.closed:
return 0, nil, net.ErrClosed
default:
}
c.mu.Lock()
deadline := c.readDeadline
deadlineChanged := c.readDeadlineChanged
c.mu.Unlock()
var (
timer *time.Timer
timeout <-chan time.Time
)
if !deadline.IsZero() {
remaining := time.Until(deadline)
if remaining <= 0 {
return 0, nil, os.ErrDeadlineExceeded
}
timer = time.NewTimer(remaining)
timeout = timer.C
}
select {
case result := <-c.results:
if timer != nil {
timer.Stop()
}
return copy(b, result.data), result.addr, nil
case <-deadlineChanged:
if timer != nil {
timer.Stop()
}
continue
case <-timeout:
return 0, nil, os.ErrDeadlineExceeded
case <-c.closed:
if timer != nil {
timer.Stop()
}
return 0, nil, net.ErrClosed
}
}
}
func (c *udpAssociationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
if addr == nil {
return 0, errMissingDestination
}
tunnel, err := c.tunnel(addr)
if err != nil {
if c.isClosed() {
return 0, net.ErrClosed
}
c.logError(addr, err)
return len(b), nil
}
n, err := tunnel.conn.WriteTo(b, addr)
if err == nil {
return n, nil
}
if c.isClosed() {
return 0, net.ErrClosed
}
if c.removeTunnel(addr.String(), tunnel) {
c.logError(addr, err)
}
return len(b), nil
}
func (c *udpAssociationConn) tunnel(addr net.Addr) (*udpAssociationTunnel, error) {
key := addr.String()
c.mu.Lock()
select {
case <-c.closed:
c.mu.Unlock()
return nil, net.ErrClosed
default:
}
if tunnel := c.tunnels[key]; tunnel != nil {
c.mu.Unlock()
return tunnel, nil
}
c.mu.Unlock()
ctx := c.ctx
if c.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, c.timeout)
defer cancel()
}
conn, err := c.dial(ctx, addr)
if err != nil {
return nil, err
}
if conn == nil {
return nil, errors.New("masque: nil UDP tunnel")
}
tunnel := &udpAssociationTunnel{conn: conn}
c.mu.Lock()
select {
case <-c.closed:
c.mu.Unlock()
conn.Close()
return nil, net.ErrClosed
default:
}
if current := c.tunnels[key]; current != nil {
c.mu.Unlock()
conn.Close()
return current, nil
}
c.tunnels[key] = tunnel
c.mu.Unlock()
go c.readTunnel(key, tunnel)
return tunnel, nil
}
func (c *udpAssociationConn) readTunnel(key string, tunnel *udpAssociationTunnel) {
buf := make([]byte, udpAssociationBufferSize)
for {
n, addr, err := tunnel.conn.ReadFrom(buf)
if err != nil {
if c.isClosed() {
return
}
if c.removeTunnel(key, tunnel) {
c.logError(key, err)
}
return
}
data := append([]byte(nil), buf[:n]...)
select {
case c.results <- udpAssociationResult{data: data, addr: addr}:
case <-c.closed:
return
}
}
}
func (c *udpAssociationConn) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func (c *udpAssociationConn) logError(addr any, err error) {
if c.log != nil {
c.log.Warnf("masque: UDP tunnel %v: %v", addr, err)
}
}
func (c *udpAssociationConn) removeTunnel(key string, tunnel *udpAssociationTunnel) bool {
c.mu.Lock()
removed := c.tunnels[key] == tunnel
if c.tunnels[key] == tunnel {
delete(c.tunnels, key)
}
c.mu.Unlock()
tunnel.conn.Close()
return removed
}
func (c *udpAssociationConn) Read(b []byte) (int, error) {
n, _, err := c.ReadFrom(b)
return n, err
}
func (c *udpAssociationConn) Write(b []byte) (int, error) {
return 0, errMissingDestination
}
func (c *udpAssociationConn) Close() error {
c.closeOnce.Do(func() {
close(c.closed)
c.cancel()
c.mu.Lock()
close(c.readDeadlineChanged)
tunnels := make([]*udpAssociationTunnel, 0, len(c.tunnels))
for _, tunnel := range c.tunnels {
tunnels = append(tunnels, tunnel)
}
clear(c.tunnels)
c.mu.Unlock()
var errs []error
for _, tunnel := range tunnels {
if err := tunnel.conn.Close(); err != nil {
errs = append(errs, err)
}
}
if c.closeIdle != nil {
if err := c.closeIdle(); err != nil {
errs = append(errs, err)
}
}
c.closeErr = errors.Join(errs...)
})
return c.closeErr
}
func (c *udpAssociationConn) LocalAddr() net.Addr {
return c.localAddr
}
func (c *udpAssociationConn) RemoteAddr() net.Addr {
return &net.UDPAddr{}
}
func (c *udpAssociationConn) SetDeadline(t time.Time) error {
if err := c.SetReadDeadline(t); err != nil {
return err
}
return c.SetWriteDeadline(t)
}
func (c *udpAssociationConn) SetReadDeadline(t time.Time) error {
c.mu.Lock()
defer c.mu.Unlock()
select {
case <-c.closed:
return net.ErrClosed
default:
}
c.readDeadline = t
close(c.readDeadlineChanged)
c.readDeadlineChanged = make(chan struct{})
return nil
}
func (c *udpAssociationConn) SetWriteDeadline(t time.Time) error {
select {
case <-c.closed:
return net.ErrClosed
default:
}
return nil
}
var (
_ net.Conn = (*udpAssociationConn)(nil)
_ net.PacketConn = (*udpAssociationConn)(nil)
)
+321
View File
@@ -0,0 +1,321 @@
package masque
import (
"context"
"errors"
"net"
"sync"
"testing"
"time"
)
func TestUDPAssociationConnRoutesByDestination(t *testing.T) {
type dialCall struct {
addr net.Addr
conn *fakePacketConn
}
var (
mu sync.Mutex
calls []dialCall
)
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
conn := newFakePacketConn()
mu.Lock()
calls = append(calls, dialCall{addr: addr, conn: conn})
mu.Unlock()
return conn, nil
}
ctx, cancel := context.WithCancel(context.Background())
conn := newUDPAssociationConn(ctx, &net.UDPAddr{}, 0, dial, nil, nil)
cancel() // The route dial context ends before the UDP association is used.
t.Cleanup(func() { conn.Close() })
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
t.Fatalf("first WriteTo failed: %v", err)
}
if _, err := conn.WriteTo([]byte("second"), addr1); err != nil {
t.Fatalf("second WriteTo failed: %v", err)
}
if _, err := conn.WriteTo([]byte("third"), addr2); err != nil {
t.Fatalf("third WriteTo failed: %v", err)
}
mu.Lock()
gotCalls := append([]dialCall(nil), calls...)
mu.Unlock()
if len(gotCalls) != 2 {
t.Fatalf("expected one tunnel per destination, got %d", len(gotCalls))
}
if gotCalls[0].addr.String() != addr1.String() || gotCalls[1].addr.String() != addr2.String() {
t.Fatalf("unexpected tunnel destinations: %s, %s", gotCalls[0].addr, gotCalls[1].addr)
}
if got := gotCalls[0].conn.writtenData(); len(got) != 2 || string(got[0]) != "first" || string(got[1]) != "second" {
t.Fatalf("unexpected writes for first destination: %q", got)
}
if got := gotCalls[1].conn.writtenData(); len(got) != 1 || string(got[0]) != "third" {
t.Fatalf("unexpected writes for second destination: %q", got)
}
replyAddr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}
gotCalls[1].conn.queueRead([]byte("reply"), replyAddr)
buf := make([]byte, 32)
n, addr, err := conn.ReadFrom(buf)
if err != nil {
t.Fatalf("ReadFrom failed: %v", err)
}
if string(buf[:n]) != "reply" {
t.Fatalf("unexpected reply: %q", buf[:n])
}
if addr.String() != replyAddr.String() {
t.Fatalf("expected reply from %s, got %s", replyAddr, addr)
}
}
func TestUDPAssociationConnClose(t *testing.T) {
upstream := newFakePacketConn()
idleClosed := 0
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
0,
func(context.Context, net.Addr) (net.PacketConn, error) { return upstream, nil },
func() error {
idleClosed++
return nil
},
nil,
)
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}); err != nil {
t.Fatalf("WriteTo failed: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("second Close failed: %v", err)
}
if idleClosed != 1 {
t.Fatalf("expected idle stream to be closed once, got %d", idleClosed)
}
if !upstream.isClosed() {
t.Fatal("expected destination tunnel to be closed")
}
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{}); !errors.Is(err, net.ErrClosed) {
t.Fatalf("expected net.ErrClosed after Close, got %v", err)
}
}
func TestUDPAssociationConnTunnelFailureIsIsolated(t *testing.T) {
var (
mu sync.Mutex
calls []*fakePacketConn
)
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
0,
func(context.Context, net.Addr) (net.PacketConn, error) {
pc := newFakePacketConn()
mu.Lock()
calls = append(calls, pc)
mu.Unlock()
return pc, nil
},
nil,
nil,
)
t.Cleanup(func() { conn.Close() })
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443}
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 443}
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
t.Fatalf("first WriteTo failed: %v", err)
}
if _, err := conn.WriteTo([]byte("second"), addr2); err != nil {
t.Fatalf("second WriteTo failed: %v", err)
}
mu.Lock()
first, second := calls[0], calls[1]
mu.Unlock()
first.queueReadError(errors.New("stream reset"))
select {
case <-first.closed:
case <-time.After(time.Second):
t.Fatal("failed tunnel was not removed")
}
second.queueRead([]byte("reply"), addr2)
buf := make([]byte, 32)
n, addr, err := conn.ReadFrom(buf)
if err != nil {
t.Fatalf("healthy tunnel failed after peer reset: %v", err)
}
if string(buf[:n]) != "reply" || addr.String() != addr2.String() {
t.Fatalf("unexpected healthy tunnel reply %q from %v", buf[:n], addr)
}
if _, err := conn.WriteTo([]byte("retry"), addr1); err != nil {
t.Fatalf("retry WriteTo failed: %v", err)
}
mu.Lock()
callCount := len(calls)
mu.Unlock()
if callCount != 3 {
t.Fatalf("expected failed destination to redial, got %d dials", callCount)
}
}
func TestUDPAssociationConnDialTimeout(t *testing.T) {
dialErr := make(chan error, 1)
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
20*time.Millisecond,
func(ctx context.Context, _ net.Addr) (net.PacketConn, error) {
<-ctx.Done()
dialErr <- ctx.Err()
return nil, ctx.Err()
},
nil,
nil,
)
t.Cleanup(func() { conn.Close() })
payload := []byte("request")
n, err := conn.WriteTo(payload, &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
if err != nil || n != len(payload) {
t.Fatalf("timed out datagram was not dropped cleanly: n=%d err=%v", n, err)
}
if err := <-dialErr; !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected dial deadline, got %v", err)
}
}
func TestUDPAssociationConnDialDoesNotHoldAssociationLock(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
conn := newUDPAssociationConn(
context.Background(),
&net.UDPAddr{},
0,
func(context.Context, net.Addr) (net.PacketConn, error) {
close(started)
<-release
return newFakePacketConn(), nil
},
nil,
nil,
)
t.Cleanup(func() { conn.Close() })
writeDone := make(chan struct{})
go func() {
conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
close(writeDone)
}()
<-started
deadlineDone := make(chan error, 1)
go func() {
deadlineDone <- conn.SetReadDeadline(time.Now().Add(time.Second))
}()
select {
case err := <-deadlineDone:
if err != nil {
t.Fatalf("SetReadDeadline failed: %v", err)
}
case <-time.After(time.Second):
t.Fatal("dial held the association lock")
}
close(release)
<-writeDone
}
type fakePacket struct {
data []byte
addr net.Addr
err error
}
type fakePacketConn struct {
reads chan fakePacket
closed chan struct{}
closeOnce sync.Once
mu sync.Mutex
writes [][]byte
}
func newFakePacketConn() *fakePacketConn {
return &fakePacketConn{
reads: make(chan fakePacket, 1),
closed: make(chan struct{}),
}
}
func (c *fakePacketConn) queueRead(data []byte, addr net.Addr) {
c.reads <- fakePacket{data: append([]byte(nil), data...), addr: addr}
}
func (c *fakePacketConn) queueReadError(err error) {
c.reads <- fakePacket{err: err}
}
func (c *fakePacketConn) writtenData() [][]byte {
c.mu.Lock()
defer c.mu.Unlock()
result := make([][]byte, len(c.writes))
for i, data := range c.writes {
result[i] = append([]byte(nil), data...)
}
return result
}
func (c *fakePacketConn) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func (c *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
select {
case packet := <-c.reads:
if packet.err != nil {
return 0, nil, packet.err
}
return copy(b, packet.data), packet.addr, nil
case <-c.closed:
return 0, nil, net.ErrClosed
}
}
func (c *fakePacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
select {
case <-c.closed:
return 0, net.ErrClosed
default:
}
c.mu.Lock()
c.writes = append(c.writes, append([]byte(nil), b...))
c.mu.Unlock()
return len(b), nil
}
func (c *fakePacketConn) Close() error {
c.closeOnce.Do(func() { close(c.closed) })
return nil
}
func (c *fakePacketConn) LocalAddr() net.Addr { return &net.UDPAddr{} }
func (c *fakePacketConn) SetDeadline(time.Time) error { return nil }
func (c *fakePacketConn) SetReadDeadline(time.Time) error { return nil }
func (c *fakePacketConn) SetWriteDeadline(time.Time) error { return nil }
+82 -15
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/go-gost/core/connector"
@@ -19,6 +20,7 @@ import (
masque_dialer "github.com/go-gost/x/dialer/http3/masque"
masque_util "github.com/go-gost/x/internal/util/masque"
"github.com/go-gost/x/registry"
"github.com/quic-go/quic-go/http3"
)
func init() {
@@ -73,6 +75,16 @@ func (c *masqueConnector) Connect(ctx context.Context, conn net.Conn, network, a
return nil, fmt.Errorf("%w: %s", ErrUnsupportedNetwork, network)
}
func newTCPConnectRequest(address string) *http.Request {
return &http.Request{
Method: http.MethodConnect,
URL: &url.URL{},
Host: address,
Header: http.Header{},
ProtoMajor: 3,
}
}
func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address string, log logger.Logger) (net.Conn, error) {
log.Debugf("connect-tcp %s", address)
@@ -103,13 +115,7 @@ func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address
// Create standard HTTP/3 CONNECT request (RFC 9114)
// No :protocol pseudo-header for standard CONNECT
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{},
Host: address, // Target address goes in :authority
Header: http.Header{},
Proto: "HTTP/3.0",
}
req := newTCPConnectRequest(address)
// Add proxy authentication if configured
if c.options.Auth != nil {
@@ -172,9 +178,69 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
return nil, ErrInvalidConnection
}
// Get pre-opened stream from dialer (stream opening happens there for dead connection detection)
reqStream := masqueConn.GetRequestStream()
proxyHost := masqueConn.GetHost()
if address == "" {
return c.connectUDPAssociation(ctx, masqueConn, log), nil
}
return c.connectUDPTarget(
ctx,
conn.LocalAddr(),
masqueConn.GetHost(),
masqueConn.GetRequestStream(),
address,
nil,
log,
)
}
func (c *masqueConnector) connectUDPAssociation(ctx context.Context, conn *masque_dialer.MasqueConn, log logger.Logger) net.Conn {
var (
mu sync.Mutex
firstStream = conn.GetRequestStream()
)
nextStream := func(ctx context.Context) (*http3.RequestStream, error) {
mu.Lock()
defer mu.Unlock()
if firstStream != nil {
stream := firstStream
firstStream = nil
return stream, nil
}
return conn.OpenRequestStream(ctx)
}
closeIdle := func() error {
mu.Lock()
defer mu.Unlock()
if firstStream == nil {
return nil
}
err := firstStream.Close()
firstStream = nil
return err
}
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
stream, err := nextStream(ctx)
if err != nil {
return nil, err
}
return c.connectUDPTarget(ctx, conn.LocalAddr(), conn.GetHost(), stream, addr.String(), addr, log)
}
return newUDPAssociationConn(ctx, conn.LocalAddr(), c.md.connectTimeout, dial, closeIdle, log)
}
func (c *masqueConnector) connectUDPTarget(
ctx context.Context,
localAddr net.Addr,
proxyHost string,
reqStream *http3.RequestStream,
address string,
remoteAddr net.Addr,
log logger.Logger,
) (*masque_util.DatagramConn, error) {
// Apply connect timeout to the actual stream
if c.md.connectTimeout > 0 {
@@ -259,14 +325,15 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
// Get the underlying HTTP/3 stream for datagrams
stream := reqStream
// Resolve target address
raddr, err := net.ResolveUDPAddr("udp", address)
if err != nil {
return nil, err
if remoteAddr == nil {
remoteAddr, err = net.ResolveUDPAddr("udp", address)
if err != nil {
return nil, err
}
}
// Create datagram connection wrapping the request stream
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, conn.LocalAddr(), raddr)
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, localAddr, remoteAddr)
success = true // Prevent defer from closing stream - datagramConn now owns it
return datagramConn, nil
+24
View File
@@ -0,0 +1,24 @@
package masque
import (
"net/http"
"testing"
)
func TestNewTCPConnectRequestUsesStandardConnect(t *testing.T) {
req := newTCPConnectRequest("example.com:443")
if req.Method != http.MethodConnect {
t.Fatalf("expected CONNECT method, got %s", req.Method)
}
if req.Host != "example.com:443" {
t.Fatalf("expected target authority, got %s", req.Host)
}
if req.Proto != "" {
// quic-go treats any non-empty Proto as an extended CONNECT :protocol.
t.Fatalf("standard CONNECT must not set :protocol, got %q", req.Proto)
}
if req.ProtoMajor != 3 {
t.Fatalf("expected HTTP/3 request, got HTTP/%d", req.ProtoMajor)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
type bindListener struct {
network string
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
+3 -1
View File
@@ -4,8 +4,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
@@ -31,6 +31,8 @@ func (c *relayConnector) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
return
+10 -1
View File
@@ -137,7 +137,12 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
addr.Host = "127.0.0.1"
}
req := gosocks5.NewRequest(gosocks5.CmdConnect, &addr)
cmd := gosocks5.CmdConnect
if c, ok := ctxvalue.Socks5CmdFromContext(ctx); ok {
cmd = c
}
req := gosocks5.NewRequest(cmd, &addr)
log.Trace(req)
if err := req.Write(conn); err != nil {
log.Error(err)
@@ -157,6 +162,10 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
return nil, err
}
if cmd == socks.CmdResolve || cmd == socks.CmdResolvePTR {
return &resolveConn{Conn: conn, resolvedAddr: reply.Addr}, nil
}
return conn, nil
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (p *tcpListener) Close() error {
type tcpMuxListener struct {
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
+2
View File
@@ -39,6 +39,8 @@ func (c *socks5Connector) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
return
}
+23
View File
@@ -0,0 +1,23 @@
package v5
import (
"net"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/gosocks5"
xmd "github.com/go-gost/x/metadata"
)
// resolveConn wraps a raw net.Conn and carries the resolved address from a
// Tor RESOLVE/RESOLVE_PTR reply as metadata. It implements net.Conn (passthrough)
// and metadata.Metadatable so the handler can extract the resolved address.
type resolveConn struct {
net.Conn
resolvedAddr *gosocks5.Addr
}
func (c *resolveConn) Metadata() md.Metadata {
return xmd.NewMetadata(map[string]any{
"resolvedAddr": c.resolvedAddr,
})
}
+50
View File
@@ -0,0 +1,50 @@
package v5
import (
"net"
"testing"
"github.com/go-gost/gosocks5"
)
type dummyConn struct{ net.Conn }
func TestResolveConn_Metadata(t *testing.T) {
addr := &gosocks5.Addr{Type: gosocks5.AddrIPv4, Host: "93.184.216.34", Port: 0}
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: addr}
md := rc.Metadata()
if md == nil {
t.Fatal("Metadata() returned nil")
}
v := md.Get("resolvedAddr")
if v == nil {
t.Fatal("resolvedAddr key not present in metadata")
}
got, ok := v.(*gosocks5.Addr)
if !ok {
t.Fatalf("resolvedAddr is %T, want *gosocks5.Addr", v)
}
if got.Host != "93.184.216.34" {
t.Errorf("Host = %q, want %q", got.Host, "93.184.216.34")
}
if got.Type != gosocks5.AddrIPv4 {
t.Errorf("Type = %d, want %d", got.Type, gosocks5.AddrIPv4)
}
}
func TestResolveConn_Metadata_NilAddr(t *testing.T) {
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: nil}
md := rc.Metadata()
v := md.Get("resolvedAddr")
addr, ok := v.(*gosocks5.Addr)
if !ok {
t.Fatalf("resolvedAddr has type %T, want *gosocks5.Addr", v)
}
if addr != nil {
t.Errorf("resolvedAddr = %v, want nil", addr)
}
}
+7 -6
View File
@@ -9,7 +9,6 @@ import (
"strings"
"github.com/go-gost/core/common/bufpool"
"github.com/go-gost/core/connector"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/go-shadowsocks2/core"
@@ -97,10 +96,13 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
log.Error(err)
return nil, err
}
rawaddr := bufpool.Get(512)
defer bufpool.Put(rawaddr)
// The wrapped conn stores this target and only writes it on first I/O,
// so it must be owned by this connection, not a shared pooled buffer
// (see go-gost/gost#896).
const maxAddrLen = 1 + 1 + 255 + 2 // SOCKS5 addr: atyp + len + host + port
n, err := addr.Encode(rawaddr)
target := make([]byte, maxAddrLen)
n, err := addr.Encode(target)
if err != nil {
log.Error("encoding addr: ", err)
return nil, err
@@ -111,8 +113,7 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
defer conn.SetDeadline(time.Time{})
}
target := socks.Addr(rawaddr[:n])
conn, err = c.client.WrapConn(conn, target)
conn, err = c.client.WrapConn(conn, socks.Addr(target[:n]))
if err != nil {
return nil, err
}
+81
View File
@@ -0,0 +1,81 @@
package ss
import (
"bytes"
"context"
"io"
"net"
"net/url"
"testing"
"time"
"github.com/go-gost/core/connector"
"github.com/go-gost/go-shadowsocks2/socks"
"github.com/go-gost/x/internal/util/ss/none"
xlogger "github.com/go-gost/x/logger"
mdx "github.com/go-gost/x/metadata"
)
// captureConn records everything written to it, so a test can inspect the
// bytes a connector actually puts on the wire.
type captureConn struct {
buf bytes.Buffer
}
func (c *captureConn) Read(b []byte) (int, error) { return 0, io.EOF }
func (c *captureConn) Write(b []byte) (int, error) { return c.buf.Write(b) }
func (c *captureConn) Close() error { return nil }
func (c *captureConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *captureConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *captureConn) SetDeadline(time.Time) error { return nil }
func (c *captureConn) SetReadDeadline(time.Time) error { return nil }
func (c *captureConn) SetWriteDeadline(time.Time) error { return nil }
// TestConnectTargetNotOverwrittenByPool is a regression test for
// https://github.com/go-gost/gost/issues/896: the connector previously passed
// a slice aliasing the bufpool buffer to WrapConn, and the buffer was returned
// to the pool before the wrapped conn lazily wrote the target on first I/O. A
// second Connect could then overwrite the bytes and the first conn would send
// the second conn's target. The none cipher keeps the wire plaintext so the
// written target is directly assertable.
func TestConnectTargetNotOverwrittenByPool(t *testing.T) {
c := NewConnector(
connector.AuthOption(url.UserPassword("none", "x")),
connector.LoggerOption(xlogger.NewLogger()),
)
if err := c.Init(mdx.NewMetadata(nil)); err != nil {
t.Fatalf("Init: %v", err)
}
ctx := context.Background()
cap1 := &captureConn{}
conn1, err := c.Connect(ctx, cap1, "tcp", "1.2.3.4:80")
if err != nil {
t.Fatalf("first Connect: %v", err)
}
// Second Connect reuses the same 512-byte bufpool buffer that conn1's
// target used to alias (single pool tier, same goroutine).
if _, err := c.Connect(ctx, &captureConn{}, "tcp", "5.6.7.8:443"); err != nil {
t.Fatalf("second Connect: %v", err)
}
if _, err := conn1.Write([]byte("X")); err != nil {
t.Fatalf("first Write: %v", err)
}
// initWriter frames the target as [salt][2-byte length][addr].
data := cap1.buf.Bytes()
payload := data[none.Cipher.SaltSize():]
if len(payload) < 2 {
t.Fatalf("captured payload too short: %d bytes: %x", len(payload), data)
}
n := int(payload[0])<<8 | int(payload[1])
if len(payload) < 2+n {
t.Fatalf("captured payload too short: len=%d, need %d: %x", len(payload), 2+n, data)
}
addr := socks.SplitAddr(payload[2 : 2+n])
if got, want := addr.String(), "1.2.3.4:80"; got != want {
t.Fatalf("first conn wrote target %q, want %q (captured %x)", got, want, data)
}
}
+5
View File
@@ -78,6 +78,11 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
req.Features = append(req.Features, &relay.TunnelFeature{
ID: c.md.tunnelID,
})
if c.md.recordMode != "" {
req.Features = append(req.Features, &relay.MetadataFeature{
KVs: map[string]string{"record.mode": c.md.recordMode},
})
}
if _, err = req.WriteTo(conn); err != nil {
return
}
+16
View File
@@ -1,6 +1,7 @@
package tunnel
import (
"context"
"encoding/binary"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
"github.com/go-gost/core/common/bufpool"
mdata "github.com/go-gost/core/metadata"
"github.com/go-gost/relay"
ictx "github.com/go-gost/x/internal/ctx"
xrelay "github.com/go-gost/x/internal/util/relay"
)
@@ -94,6 +96,14 @@ func (c *bindConn) Metadata() mdata.Metadata {
return c.md
}
// Context implements xctx.Context, exposing the connection metadata (e.g. the
// tunneled hostname) to the handler's context. The service accept loop derives
// the handler context from a conn that implements this interface, which lets
// reverse-forward handlers (rtcp) route on the hostname carried by the tunnel.
func (c *bindConn) Context() context.Context {
return ictx.ContextWithMetadata(context.Background(), c.md)
}
type bindUDPConn struct {
net.Conn
localAddr net.Addr
@@ -162,6 +172,12 @@ func (c *bindUDPConn) Metadata() mdata.Metadata {
return c.md
}
// Context implements xctx.Context, exposing the connection metadata (e.g. the
// tunneled hostname) to the handler's context. See bindConn.Context.
func (c *bindUDPConn) Context() context.Context {
return ictx.ContextWithMetadata(context.Background(), c.md)
}
type bindAddr struct {
network string
addr string
+45
View File
@@ -0,0 +1,45 @@
package tunnel
import (
"net"
"testing"
mdata "github.com/go-gost/core/metadata"
ictx "github.com/go-gost/x/internal/ctx"
xmd "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
)
// TestBindConnContext verifies that a bindConn exposes its connection metadata
// (the tunneled hostname) via the xctx.Context interface, so the service accept
// loop forwards it into the handler context for filter.host routing (gost#898).
func TestBindConnContext(t *testing.T) {
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
cn := &bindConn{
Conn: &net.TCPConn{},
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
md: md,
}
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
if got != "example.local:80" {
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
}
}
// TestBindUDPConnContext is the same check for the UDP reverse-tunnel conn.
func TestBindUDPConnContext(t *testing.T) {
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
cn := &bindUDPConn{
Conn: &net.TCPConn{},
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
md: md,
}
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
if got != "example.local:80" {
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
}
}
+5
View File
@@ -89,6 +89,11 @@ func (c *tunnelConnector) Connect(ctx context.Context, conn net.Conn, network, a
req.Features = append(req.Features, &relay.TunnelFeature{
ID: c.md.tunnelID,
})
if c.md.recordMode != "" {
req.Features = append(req.Features, &relay.MetadataFeature{
KVs: map[string]string{"record.mode": c.md.recordMode},
})
}
if _, err := req.WriteTo(conn); err != nil {
return nil, err
+1 -1
View File
@@ -15,7 +15,7 @@ import (
type bindListener struct {
network string
addr net.Addr
session *mux.Session
session mux.Session
logger logger.Logger
}
+5
View File
@@ -19,6 +19,7 @@ type metadata struct {
connectTimeout time.Duration
tunnelID relay.TunnelID
muxCfg *mux.Config
recordMode string
}
func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
@@ -44,6 +45,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
c.md.tunnelID = c.md.tunnelID.SetWeight(uint8(weight))
}
c.md.recordMode = mdutil.GetString(md, "record.mode")
c.md.muxCfg = &mux.Config{
Version: mdutil.GetInt(md, "mux.version"),
KeepAliveInterval: mdutil.GetDuration(md, "mux.keepaliveInterval"),
@@ -52,6 +55,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
if c.md.muxCfg.Version == 0 {
c.md.muxCfg.Version = 2
+16
View File
@@ -121,6 +121,22 @@ func PeerCertFromContext(ctx context.Context) *PeerCert {
return v
}
type socks5CmdKey struct{}
// ContextWithSocks5Cmd returns a copy of ctx that carries a SOCKS5 command
// override. Connectors use this to send a non-CONNECT command (e.g. Tor
// RESOLVE/RESOLVE_PTR) to the upstream proxy.
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
return context.WithValue(ctx, socks5CmdKey{}, cmd)
}
// Socks5CmdFromContext returns the SOCKS5 command override stored in ctx.
// The bool is false if no override is present.
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
return v, ok
}
// labelsKey saves the static service labels.
type labelsKey struct{}
+27
View File
@@ -261,6 +261,33 @@ func (c *testContextConn) Context() context.Context {
return c.ctx
}
func TestContextWithSocks5Cmd(t *testing.T) {
ctx := ContextWithSocks5Cmd(context.Background(), 0xF0)
cmd, ok := Socks5CmdFromContext(ctx)
if !ok {
t.Fatal("Socks5CmdFromContext() ok = false, want true")
}
if cmd != 0xF0 {
t.Errorf("Socks5CmdFromContext() = 0x%02X, want 0xF0", cmd)
}
}
func TestSocks5CmdFromContext_Empty(t *testing.T) {
_, ok := Socks5CmdFromContext(context.Background())
if ok {
t.Error("Socks5CmdFromContext(empty) ok = true, want false")
}
}
func TestSocks5CmdFromContext_WrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), socks5CmdKey{}, "not-a-uint8")
_, ok := Socks5CmdFromContext(ctx)
if ok {
t.Error("Socks5CmdFromContext(wrong type) ok = true, want false")
}
}
func TestContextInterface(t *testing.T) {
ctx := context.Background()
ctx = ContextWithSid(ctx, Sid("iface-test"))
+9
View File
@@ -102,6 +102,15 @@ func (c *MasqueConn) GetRequestStream() *http3.RequestStream {
return c.reqStream
}
// OpenRequestStream opens another request stream on the shared HTTP/3 connection.
// CONNECT-UDP associations use one stream per destination.
func (c *MasqueConn) OpenRequestStream(ctx context.Context) (*http3.RequestStream, error) {
if c.clientConn == nil {
return nil, errors.New("masque: connection is closed")
}
return c.clientConn.OpenRequestStream(ctx)
}
// GetHost returns the proxy host.
func (c *MasqueConn) GetHost() string {
return c.host
+2 -2
View File
@@ -12,8 +12,8 @@ type quicSession struct {
session *quic.Conn
}
func (session *quicSession) GetConn() (*quicConn, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
+1 -1
View File
@@ -117,7 +117,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
conn, err = session.GetConn(ctx)
if err != nil {
d.sessionMutex.Lock()
if d.sessions[addr] == session {
+3
View File
@@ -29,6 +29,9 @@ func (d *icmpDialer) parseMetadata(md mdata.Metadata) (err error) {
}
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
if d.md.maxIdleTimeout <= 0 {
d.md.maxIdleTimeout = 90 * time.Second
}
return
}
+1 -5
View File
@@ -8,7 +8,7 @@ import (
type muxSession struct {
conn net.Conn
session *mux.Session
session mux.Session
}
func (session *muxSession) GetConn() (net.Conn, error) {
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
}
return session.session.IsClosed()
}
func (session *muxSession) NumStreams() int {
return session.session.NumStreams()
}
+20 -4
View File
@@ -2,7 +2,6 @@ package mtcp
import (
"context"
"errors"
"net"
"sync"
"time"
@@ -61,7 +60,17 @@ func (d *mtcpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
// A session still waiting for its handshake is not dead: its conn
// belongs to the request that dialed it, whose Handshake either
// builds the session on it or closes it. Only an established
// session's base conn is closed here.
if session.session != nil {
session.Close()
if session.conn != nil {
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
}
}
delete(d.sessions, addr)
ok = false
}
if !ok {
@@ -114,8 +123,15 @@ func (d *mtcpDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
session, ok := d.sessions[opts.Addr]
if session != nil && session.conn != conn {
conn.Close()
return nil, errors.New("mtls: unrecognized connection")
// Another Dial registered a conn of its own while this one waited
// for the lock. If it has become a live session, use that and drop
// this conn; otherwise build the session on this conn, and the other
// request's Handshake will find it here and use it.
if session.IsClosed() {
ok = false
} else {
conn.Close()
}
}
if !ok || session.session == nil {
+93
View File
@@ -0,0 +1,93 @@
package mtcp
import (
"context"
"io"
"net"
"testing"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/listener"
mtcp_listener "github.com/go-gost/x/listener/mtcp"
xlogger "github.com/go-gost/x/logger"
xmd "github.com/go-gost/x/metadata"
)
type netDialer struct{}
func (netDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
// echoServer serves mtcp on a local port and echoes every stream.
func echoServer(t *testing.T) string {
t.Helper()
ln := mtcp_listener.NewListener(
listener.AddrOption("127.0.0.1:0"),
listener.LoggerOption(xlogger.Nop()),
)
if err := ln.Init(xmd.NewMetadata(nil)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
io.Copy(conn, conn)
}()
}
}()
return ln.Addr().String()
}
// Concurrent requests that find no live session each dial a conn of their
// own, and a Dial that finds a session still waiting for its handshake
// replaces it. Both handshakes must still end on one usable session,
// whichever runs first; they used to fail with "unrecognized connection",
// or on a conn the later Dial had closed.
func TestHandshakeAfterAnotherDial(t *testing.T) {
for _, first := range []string{"earlier", "later"} {
t.Run(first+" conn first", func(t *testing.T) {
addr := echoServer(t)
d := NewDialer(
dialer.LoggerOption(xlogger.Nop()),
)
if err := d.Init(xmd.NewMetadata(nil)); err != nil {
t.Fatal(err)
}
ctx := context.Background()
earlier, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
if err != nil {
t.Fatal(err)
}
later, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
if err != nil {
t.Fatal(err)
}
conns := []net.Conn{earlier, later}
if first == "later" {
conns = []net.Conn{later, earlier}
}
for i, conn := range conns {
cc, err := d.(dialer.Handshaker).Handshake(ctx, conn, dialer.AddrHandshakeOption(addr))
if err != nil {
t.Fatalf("handshake %d: %v", i+1, err)
}
if _, err := cc.Write([]byte("ping")); err != nil {
t.Fatalf("handshake %d: write: %v", i+1, err)
}
if _, err := io.ReadFull(cc, make([]byte, 4)); err != nil {
t.Fatalf("handshake %d: read: %v", i+1, err)
}
cc.Close()
}
})
}
}
+3 -1
View File
@@ -4,8 +4,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
@@ -34,6 +34,8 @@ func (d *mtcpDialer) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
if d.md.muxCfg.Version == 0 {
d.md.muxCfg.Version = 2
+1 -5
View File
@@ -8,7 +8,7 @@ import (
type muxSession struct {
conn net.Conn
session *mux.Session
session mux.Session
}
func (session *muxSession) GetConn() (net.Conn, error) {
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
}
return session.session.IsClosed()
}
func (session *muxSession) NumStreams() int {
return session.session.NumStreams()
}
+20 -4
View File
@@ -3,7 +3,6 @@ package mtls
import (
"context"
"crypto/tls"
"errors"
"net"
"sync"
"time"
@@ -62,7 +61,17 @@ func (d *mtlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
// A session still waiting for its handshake is not dead: its conn
// belongs to the request that dialed it, whose Handshake either
// builds the session on it or closes it. Only an established
// session's base conn is closed here.
if session.session != nil {
session.Close()
if session.conn != nil {
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
}
}
delete(d.sessions, addr)
ok = false
}
if !ok {
@@ -115,8 +124,15 @@ func (d *mtlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
session, ok := d.sessions[opts.Addr]
if session != nil && session.conn != conn {
conn.Close()
return nil, errors.New("mtls: unrecognized connection")
// Another Dial registered a conn of its own while this one waited
// for the lock. If it has become a live session, use that and drop
// this conn; otherwise build the session on this conn, and the other
// request's Handshake will find it here and use it.
if session.IsClosed() {
ok = false
} else {
conn.Close()
}
}
if !ok || session.session == nil {
+123
View File
@@ -0,0 +1,123 @@
package mtls
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"io"
"math/big"
"net"
"testing"
"time"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/listener"
mtls_listener "github.com/go-gost/x/listener/mtls"
xlogger "github.com/go-gost/x/logger"
xmd "github.com/go-gost/x/metadata"
)
type netDialer struct{}
func (netDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
// echoServer serves mtls on a local port and echoes every stream.
func echoServer(t *testing.T) string {
t.Helper()
ln := mtls_listener.NewListener(
listener.AddrOption("127.0.0.1:0"),
listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}),
listener.LoggerOption(xlogger.Nop()),
)
if err := ln.Init(xmd.NewMetadata(nil)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
io.Copy(conn, conn)
}()
}
}()
return ln.Addr().String()
}
func selfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "127.0.0.1"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// Concurrent requests that find no live session each dial a conn of their
// own, and a Dial that finds a session still waiting for its handshake
// replaces it. Both handshakes must still end on one usable session,
// whichever runs first; they used to fail with "unrecognized connection",
// or on a conn the later Dial had closed.
func TestHandshakeAfterAnotherDial(t *testing.T) {
for _, first := range []string{"earlier", "later"} {
t.Run(first+" conn first", func(t *testing.T) {
addr := echoServer(t)
d := NewDialer(
dialer.TLSConfigOption(&tls.Config{InsecureSkipVerify: true}),
dialer.LoggerOption(xlogger.Nop()),
)
if err := d.Init(xmd.NewMetadata(nil)); err != nil {
t.Fatal(err)
}
ctx := context.Background()
earlier, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
if err != nil {
t.Fatal(err)
}
later, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
if err != nil {
t.Fatal(err)
}
conns := []net.Conn{earlier, later}
if first == "later" {
conns = []net.Conn{later, earlier}
}
for i, conn := range conns {
cc, err := d.(dialer.Handshaker).Handshake(ctx, conn, dialer.AddrHandshakeOption(addr))
if err != nil {
t.Fatalf("handshake %d: %v", i+1, err)
}
if _, err := cc.Write([]byte("ping")); err != nil {
t.Fatalf("handshake %d: write: %v", i+1, err)
}
if _, err := io.ReadFull(cc, make([]byte, 4)); err != nil {
t.Fatalf("handshake %d: read: %v", i+1, err)
}
cc.Close()
}
})
}
}
+3 -1
View File
@@ -4,8 +4,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
type metadata struct {
@@ -34,6 +34,8 @@ func (d *mtlsDialer) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
return
}
+1 -5
View File
@@ -8,7 +8,7 @@ import (
type muxSession struct {
conn net.Conn
session *mux.Session
session mux.Session
}
func (session *muxSession) GetConn() (net.Conn, error) {
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
}
return session.session.IsClosed()
}
func (session *muxSession) NumStreams() int {
return session.session.NumStreams()
}
+20 -6
View File
@@ -2,7 +2,6 @@ package mws
import (
"context"
"errors"
"net"
"net/url"
"sync"
@@ -76,7 +75,17 @@ func (d *mwsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
session, ok := d.sessions[addr]
if session != nil && session.IsClosed() {
delete(d.sessions, addr) // session is dead
// A session still waiting for its handshake is not dead: its conn
// belongs to the request that dialed it, whose Handshake either
// builds the session on it or closes it. Only an established
// session's base conn is closed here.
if session.session != nil {
session.Close()
if session.conn != nil {
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
}
}
delete(d.sessions, addr)
ok = false
}
if !ok {
@@ -129,10 +138,15 @@ func (d *mwsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dia
session, ok := d.sessions[opts.Addr]
if session != nil && session.conn != conn {
err := errors.New("mws: unrecognized connection")
log.Error(err)
conn.Close()
return nil, err
// Another Dial registered a conn of its own while this one waited
// for the lock. If it has become a live session, use that and drop
// this conn; otherwise build the session on this conn, and the other
// request's Handshake will find it here and use it.
if session.IsClosed() {
ok = false
} else {
conn.Close()
}
}
if !ok || session.session == nil {
+93
View File
@@ -0,0 +1,93 @@
package mws
import (
"context"
"io"
"net"
"testing"
"github.com/go-gost/core/dialer"
"github.com/go-gost/core/listener"
mws_listener "github.com/go-gost/x/listener/mws"
xlogger "github.com/go-gost/x/logger"
xmd "github.com/go-gost/x/metadata"
)
type netDialer struct{}
func (netDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
// echoServer serves mws on a local port and echoes every stream.
func echoServer(t *testing.T) string {
t.Helper()
ln := mws_listener.NewListener(
listener.AddrOption("127.0.0.1:0"),
listener.LoggerOption(xlogger.Nop()),
)
if err := ln.Init(xmd.NewMetadata(nil)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
io.Copy(conn, conn)
}()
}
}()
return ln.Addr().String()
}
// Concurrent requests that find no live session each dial a conn of their
// own, and a Dial that finds a session still waiting for its handshake
// replaces it. Both handshakes must still end on one usable session,
// whichever runs first; they used to fail with "unrecognized connection",
// or on a conn the later Dial had closed.
func TestHandshakeAfterAnotherDial(t *testing.T) {
for _, first := range []string{"earlier", "later"} {
t.Run(first+" conn first", func(t *testing.T) {
addr := echoServer(t)
d := NewDialer(
dialer.LoggerOption(xlogger.Nop()),
)
if err := d.Init(xmd.NewMetadata(nil)); err != nil {
t.Fatal(err)
}
ctx := context.Background()
earlier, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
if err != nil {
t.Fatal(err)
}
later, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
if err != nil {
t.Fatal(err)
}
conns := []net.Conn{earlier, later}
if first == "later" {
conns = []net.Conn{later, earlier}
}
for i, conn := range conns {
cc, err := d.(dialer.Handshaker).Handshake(ctx, conn, dialer.AddrHandshakeOption(addr))
if err != nil {
t.Fatalf("handshake %d: %v", i+1, err)
}
if _, err := cc.Write([]byte("ping")); err != nil {
t.Fatalf("handshake %d: write: %v", i+1, err)
}
if _, err := io.ReadFull(cc, make([]byte, 4)); err != nil {
t.Fatalf("handshake %d: read: %v", i+1, err)
}
cc.Close()
}
})
}
}
+3 -1
View File
@@ -5,8 +5,8 @@ import (
"time"
mdata "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/internal/util/mux"
mdutil "github.com/go-gost/x/metadata/util"
)
const (
@@ -49,6 +49,8 @@ func (d *mwsDialer) parseMetadata(md mdata.Metadata) (err error) {
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
Type: mdutil.GetString(md, "mux.type"),
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
}
d.md.handshakeTimeout = mdutil.GetDuration(md, "ws.handshakeTimeout", "handshakeTimeout")
+2 -2
View File
@@ -12,8 +12,8 @@ type quicSession struct {
session *quic.Conn
}
func (session *quicSession) GetConn() (*quicConn, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
stream, err := session.session.OpenStreamSync(ctx)
if err != nil {
+1 -1
View File
@@ -98,7 +98,7 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
}
d.sessionMutex.Unlock()
conn, err = session.GetConn()
conn, err = session.GetConn(ctx)
if err != nil {
d.sessionMutex.Lock()
if d.sessions[addr] == session {
+3
View File
@@ -40,6 +40,9 @@ func (d *quicDialer) parseMetadata(md mdata.Metadata) (err error) {
}
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
if d.md.maxIdleTimeout <= 0 {
d.md.maxIdleTimeout = 90 * time.Second
}
d.md.maxStreams = mdutil.GetInt(md, maxStreams)
d.md.enableDatagram = mdutil.GetBool(md, "quic.enableDatagram", "enableDatagram")
+2
View File
@@ -52,6 +52,8 @@ func (d *tlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
d.log.Error(err)
}
xnet.ApplyNoDelay(conn)
if d.md.keepalive {
xnet.ApplyKeepalive(conn, net.KeepAliveConfig{
Enable: true,
+8 -3
View File
@@ -4,16 +4,21 @@ import (
"net"
)
// conn presents a dialed connection as a connected datagram socket: one
// Write/Read per datagram, the peer address always the connected one, and the
// address argument of WriteTo ignored. It covers both a raw *net.UDPConn and a
// wrapped base dialer (e.g. a p2p tunnel endpoint), neither of which the caller
// needs to tell apart.
type conn struct {
*net.UDPConn
net.Conn
}
func (c *conn) WriteTo(b []byte, addr net.Addr) (int, error) {
return c.UDPConn.Write(b)
return c.Write(b)
}
func (c *conn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
n, err = c.UDPConn.Read(b)
n, err = c.Read(b)
addr = c.RemoteAddr()
return
}
+4 -1
View File
@@ -49,8 +49,11 @@ func (d *udpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
return nil, err
}
// The base may be a raw *net.UDPConn (a direct dial) or any conn carrying
// datagrams (a wrapped base dialer, e.g. a p2p tunnel endpoint); the
// wrapper treats both the same.
c = &conn{
UDPConn: c.(*net.UDPConn),
Conn: c,
}
c = proxyproto.WrapClientConn(
+2
View File
@@ -65,6 +65,8 @@ func (d *wsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOpt
d.options.Logger.Error(err)
}
xnet.ApplyNoDelay(conn)
if d.md.tcpKeepalive {
xnet.ApplyKeepalive(conn, net.KeepAliveConfig{
Enable: true,
+129
View File
@@ -0,0 +1,129 @@
# Plan: obfs4 Pluggable Transport Support (go-gost/gost#182)
## Context
Issue [#182](https://github.com/go-gost/gost/issues/182) requests obfs4 support. GOST v3 currently has `ohttp` (HTTP WebSocket upgrade) and `otls` (fake TLS handshake) obfuscation, but not obfs4 — the "look-like-nothing" pluggable transport used by Tor bridges. The maintainer confirmed in the issue comments that obfs4 was not yet implemented. The `feature` label indicates this is desired functionality.
## Approach: Use external library
Use **`github.com/refraction-networking/obfs4`** (BSD-licensed) — the same organization that provides `refraction-networking/utls` already depended on by GOST. This library implements the full obfs4 protocol (ntor handshake, Elligator 2 key obfuscation, NaCl secretbox framing, SipHash length masking, replay filtering).
### Why this library vs implementing from scratch
| Factor | Library | From scratch |
|--------|---------|-------------|
| Cryptographic safety | Battle-tested (used by Tor/Refraction Networking) | Risk of subtle bugs in Elligator 2, ntor, constant-time ops |
| Code volume | ~200 lines of GOST wrappers | ~1500+ lines of complex crypto code |
| Maintenance | Upstream maintains protocol compliance | GOST maintainers own all crypto code |
| Dependency footprint | Adds ~3 transitive deps | None new |
| Integration fit | `ServerFactory.WrapConn()` is a perfect match for Accept(); `ClientFactory.Dial()` needs a thin bridge in Handshake() | Full control |
The library approach matches how GOST already uses `refraction-networking/utls` for TLS fingerprinting.
## Files to Create
### 1. `x/listener/obfs/obfs4/listener.go`
- Registers `"obfs4"` in `ListenerRegistry`
- `obfsListener` struct following the same pattern as `listener/obfs/tls/listener.go` and `listener/obfs/http/listener.go`
- `NewListener()` factory, `Init(metadata)`, `Accept()` methods
- In `Accept()`: wrap accepted conn with limiter, then call `serverFactory.WrapConn(conn)` to perform server-side obfs4 handshake
### 2. `x/listener/obfs/obfs4/conn.go`
- Thin: the library's `WrapConn` already returns a `net.Conn` that handles framing transparently
- May not even need a custom conn type — `WrapConn` returns a fully functional `net.Conn`
### 3. `x/listener/obfs/obfs4/metadata.go`
- Parse server-side config: `nodeId` (hex), `privateKey` (base64), `iatMode` (int, default 0)
- Construct `pt.Args` from metadata for `ServerFactory()`
### 4. `x/dialer/obfs/obfs4/dialer.go`
- Registers `"obfs4"` in `DialerRegistry`
- `obfs4Dialer` struct following the same pattern as `dialer/obfs/tls/dialer.go`
- `NewDialer()`, `Init(metadata)`, `Dial()`, `Handshake()` methods
- `Dial()`: delegates to options.Dialer (standard raw TCP connection)
- `Handshake()`: uses `ClientFactory.Dial()` with a passthrough `DialFunc` that returns the already-established conn — the library then performs the obfs4 client handshake on it
### 5. `x/dialer/obfs/obfs4/metadata.go`
- Parse client-side config: `cert` (base64, required), `iatMode` (int)
- Store state dir path for client factory
### 6. Update `gost/cmd/gost/register.go`
- Add blank imports:
```go
_ "github.com/go-gost/x/listener/obfs/obfs4"
_ "github.com/go-gost/x/dialer/obfs/obfs4"
```
## Key Design Decisions
### Server-side: Use `ServerFactory.WrapConn()`
The library's `base.ServerFactory` interface has `WrapConn(conn net.Conn) (net.Conn, error)` which is an exact match for GOST's `Accept()` pattern — it takes a raw TCP connection, performs the obfs4 server handshake, and returns a wrapped connection. No custom conn type needed.
### Client-side: Bridge Dial/Handshake with passthrough DialFunc
The library's `ClientFactory.Dial(network, address, DialFunc, args)` both connects AND handshakes. GOST separates these. Solution:
```go
func (d *obfs4Dialer) Handshake(ctx context.Context, conn net.Conn, ...) (net.Conn, error) {
cf, _ := d.transport.ClientFactory(d.md.stateDir)
args, _ := cf.ParseArgs(d.md.ptArgs)
return cf.Dial("tcp", "", func(network, address string) (net.Conn, error) {
return conn, nil // passthrough — conn already established
}, args)
}
```
### Config interface
Users configure obfs4 via YAML/JSON metadata, mirroring Tor's bridge line format:
**Server** (`listener`):
```yaml
services:
- name: obfs4-bridge
addr: :8080
listener:
type: obfs4
metadata:
nodeId: "0123456789abcdef0123456789abcdef01234567"
privateKey: "base64-encoded-curve25519-private-key"
iatMode: 0
handler:
type: auto
```
**Client** (`hop node`):
```yaml
chains:
- name: obfs4-chain
hops:
- nodes:
- addr: bridge.example.com:8080
dialer:
type: obfs4
metadata:
cert: "base64-encoded-server-cert"
iatMode: 0
connector:
type: http
```
### follow existing patterns exactly
- Same `init()` registration pattern
- Same `metadata` struct + `parseMetadata()` pattern
- Same `mdutil.GetString/GetInt` helpers for metadata extraction
- Same listener wrapper chain (proxyproto → metrics → stats → admission → traffic limiter → conn limiter)
- Same dialer factory + Handshake pattern
## Risks
1. **Library API stability**: The refraction-networking/obfs4 library is used by the Tor anti-censorship community. API changes are unlikely but possible. Mitigation: pin to a specific version in go.mod.
2. **State directory requirement**: The library requires a state directory for replay filter persistence. For stateless deployments (containers), we'll default to a temp directory that's cleaned up on exit.
3. **Interop with real Tor obfs4 bridges**: This should work since we're using the same protocol implementation library that Tor uses.
## Verification
1. Build: `cd x && go build ./...` and `cd gost && go build ./cmd/gost/...`
2. Vet: `cd x && go vet ./...`
3. Smoke test: Create a minimal config with obfs4 listener + handler, verify the binary starts without error
4. Integration test: Start a server with obfs4 listener and client with obfs4 dialer, verify connectivity. This can't be tested with real Tor bridges without credentials, but local loopback testing validates the integration layer.
+236
View File
@@ -0,0 +1,236 @@
# Issue #349: 通用 Cache 组件设计
## Context
Issue [#349](https://github.com/go-gost/gost/issues/349) 请求 HTTP 代理缓存层。用户要求先聚焦 cache 组件本身,设计要通用——不仅用于 HTTP,也要能统一替换现有的 DNS、Router、Traffic Limiter、TLS 等模块中的 ad-hoc 缓存。
## 现状:6 处独立缓存实现
| 位置 | 底层库 | Key | Value | TTL 模式 | 淘汰 | 最大容量 |
|------|--------|-----|-------|----------|------|----------|
| `x/internal/util/cache` | 自实现 | `string` | `interface{}` | 硬过期 | 无 | 无 |
| `x/internal/util/resolver` (DNS) | 自实现 | `CacheKey(string)` | `*dns.Msg` | 硬过期 + serve-stale | 最旧(O(n)) | 可选 |
| `x/limiter/traffic/cache` | #1 之上 | `string` | `traffic.Limiter` | 硬过期 + 刷新时滑动 | 无 | 无 |
| `x/limiter/traffic` | patrickmn/go-cache | `string` | `traffic.Limiter` | **滑动**(Get时重置) | 后台清理 | 无 |
| `x/internal/util/tls` | patrickmn/go-cache | `string` | `*x509.Certificate` | 硬过期(1周) | 后台清理 | 无 |
| `x/handler/router` | #1 之上 | `string` | `net.Addr` / `*Route` | 硬过期(1s) | 无 | 无 |
**问题**: 三种底层实现(自实现 ×2 + patrickmn/go-cache),功能重叠但接口不兼容,无通用配置入口。
---
## Core Interface
### `core/cache/cache.go` (新文件)
```go
package cache
var ErrNotFound = errors.New("cache: key not found")
// Entry 缓存条目
type Entry struct {
Data []byte
Expiration time.Time // zero = 永不过期
}
func (e *Entry) Expired() bool
func (e *Entry) TTL() time.Duration // 剩余 TTL,负数=已过期,-1=永不过期
// SetOptions 写入选项
type SetOptions struct {
TTL time.Duration // 逐条 TTL
}
type SetOption func(*SetOptions)
func WithTTL(d time.Duration) SetOption
// Cache 通用 KV 缓存接口
// 实现必须并发安全
type Cache interface {
Get(ctx context.Context, key string) (*Entry, error)
Set(ctx context.Context, key string, data []byte, opts ...SetOption) error
Delete(ctx context.Context, key string) error
Has(ctx context.Context, key string) bool
Clear(ctx context.Context) error
Len(ctx context.Context) int
RefreshTTL(ctx context.Context, key string, ttl time.Duration) error
}
```
**设计决策**:
- Key 固定为 `string`,Value 固定为 `[]byte` — 与 registry 模式兼容,所有后端统一
- `Get` 返回过期条目(`ErrNotFound` 仅对不存在的 key)— 支持 DNS serve-stale
- `RefreshTTL` 显式延长过期时间 — 支持 DNS 异步刷新防重复
- 调用方自行序列化/反序列化 — Cache 不绑定任何业务类型,真正通用
- 与 `recorder.Recorder` 接口复杂度相当(都是单方法 + 选项)
### Each handler wraps with typed methods
```go
// DNS handler 中:
func dnsCacheGet(c cache.Cache, key string) (*dns.Msg, time.Duration) {
entry, err := c.Get(ctx, key)
if err != nil { return nil, 0 }
msg := &dns.Msg{}
msg.Unpack(entry.Data)
return msg, entry.TTL()
}
// HTTP handler 中:
func httpCacheGet(c cache.Cache, key string) (*http.Response, error) { ... }
func httpCacheSet(c cache.Cache, key string, resp *http.Response, ttl time.Duration) error { ... }
```
---
## Config Structure
### `x/config/config.go` — 新增
```go
type CacheConfig struct {
Name string `json:"name"`
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
File *FileCache `yaml:",omitempty" json:"file,omitempty"`
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
}
type MemoryCache struct {
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
MaxSize int `yaml:",omitempty" json:"maxSize,omitempty"`
MaxBytes int64 `yaml:",omitempty" json:"maxBytes,omitempty"`
CleanupInterval time.Duration `yaml:",omitempty" json:"cleanupInterval,omitempty"`
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
}
type FileCache struct {
Dir string `json:"dir"`
MaxBytes int64 `yaml:",omitempty" json:"maxBytes,omitempty"`
Levels int `yaml:",omitempty" json:"levels,omitempty"`
}
type RedisCache struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
KeyPrefix string `yaml:",omitempty" json:"keyPrefix,omitempty"`
}
// 在 Config struct 加:
// Caches []*CacheConfig `yaml:",omitempty" json:"caches,omitempty"`
// 在 ServiceConfig struct 加:
// Cache string `yaml:",omitempty" json:"cache,omitempty"`
```
### YAML 示例
```yaml
caches:
- name: cache-0
memory:
ttl: 60m
maxSize: 10000
maxBytes: 268435456 # 256MB
cleanupInterval: 5m
eviction: lru
services:
- name: service-0
addr: ":8080"
handler:
type: http
cache: cache-0 # 引用
forwarder:
nodes: [...]
```
---
## Memory Backend
### `x/cache/cache.go` — 内建实现
不依赖外部库,替代 `x/internal/util/cache/cache.go` 和 patrickmn/go-cache。
```go
type memoryCache struct {
mu sync.RWMutex
entries map[string]*entry
defaultTTL time.Duration
maxSize int
maxBytes int64
currentBytes int64
cleanupInterval time.Duration
evictionPolicy EvictionPolicy
stopCleanup chan struct{}
logger logger.Logger
}
type entry struct {
data []byte
expiration time.Time // zero = 永不过期
lastAccess time.Time // LRU 用
}
```
**功能矩阵**:
| 特性 | 说明 |
|------|------|
| 并发安全 | `sync.RWMutex` |
| 默认 TTL | 配置项,`Set` 的 `WithTTL` 可逐条覆盖 |
| 最大条目数 | 满时按 `evictionPolicy` 淘汰(oldest / lru) |
| 最大字节数 | 满时同样淘汰 |
| 后台清理 | 可选,按 `cleanupInterval` 定期删除过期条目 |
| serve-stale | `Get` 返回过期条目,`Entry.Expired()` 让调用方判断 |
| `RefreshTTL` | 延长过期时间,用于 DNS 异步刷新模式 |
| 注册 | `init()` 注册为 `registry.CacheRegistry().Register("memory", ...)` |
**淘汰策略**: 满时 → 先清过期 → 若仍满则按策略淘汰(oldest: 按插入时间; lru: 按 `lastAccess`)
---
## 实现步骤
| Step | 文件 | 动作 | 行数 |
|------|------|------|------|
| 1 | `core/cache/cache.go` | 新建 — `Entry`, `Cache` 接口, 选项 | ~70 |
| 2 | `x/config/config.go` | 修改 — `CacheConfig` + 子结构体 + Config 字段 | ~50 |
| 3 | `x/registry/registry.go` | 修改 — `cacheReg` + `CacheRegistry()` | ~15 |
| 4 | `x/registry/cache.go` | 新建 — 热重载包装器 | ~30 |
| 5 | `x/cache/cache.go` | 新建 — memory cache 实现 | ~200 |
| 6 | `x/config/parsing/cache/parse.go` | 新建 — `ParseCache()` 后端分派 | ~50 |
| 7 | `x/config/loader/loader.go` | 修改 — cache 注册块 | ~10 |
| 8 | `x/config/parsing/service/parse.go` | 修改 — 解析 cache 引用注入 handler option | ~25 |
| 9 | `core/handler/option.go` | 修改 — `CacheOption` | ~10 |
| 10 | `gost/cmd/gost/register.go` | 修改 — blank import `x/cache` | +1 |
**总计: ~460 行,集中在 `core/cache/` 和 `x/cache/`**
### 迁移路径 (后续 Phase)
| Phase | 内容 |
|-------|------|
| **Phase 1 (本次)** | `core/cache/` 接口 + memory 后端 + config/registry 全链路 |
| **Phase 2** | DNS handler 切换到新 cache;Router handler 切换;替换 `x/internal/util/cache` |
| **Phase 3** | Traffic limiter 切换;TLS cert pool 切换;移除 patrickmn/go-cache 依赖 |
| **Phase 4** | HTTP 代理缓存集成(handler 中查缓存/存缓存) |
| **Phase 5** | File 后端;Redis 后端;Plugin 后端 |
---
## 验证
```bash
# 编译
cd x && go build ./...
cd gost && go build ./cmd/gost/...
# 单元测试
cd x && go test ./cache/... -v
# 验证 registry 集成
cd x && go vet ./...
```
+131
View File
@@ -0,0 +1,131 @@
# Plan: Tor SOCKS5 Resolve Commands (go-gost/gost#524)
## Context
Issue [#524](https://github.com/go-gost/gost/issues/524) requests support for Tor-specific SOCKS5 extension commands:
- **`0xF0` RESOLVE** — resolve a hostname to an IP address via Tor's DNS
- **`0xF1` RESOLVE_PTR** — reverse DNS lookup (IP → hostname) via Tor's DNS
This allows tools like `torsocks` and `tor-resolve` to work through GOST when forwarding to a Tor SOCKS5 upstream proxy. Previously, GOST's SOCKS5 handler didn't recognize these commands (returning `CmdUnsupported`), so Tor-specific tools failed to work through GOST.
### Existing extension pattern
GOST already defines custom SOCKS5 extension commands in `x/internal/util/socks/socks.go`:
- `CmdMuxBind = 0xF2` — multiplex bind
- `CmdUDPTun = 0xF3` — UDP tunnel over TCP
We add `CmdResolve = 0xF0` and `CmdResolvePTR = 0xF1` to match the same pattern.
### Data flow
```
Client (torsocks) -> GOST SOCKS5 handler (CMD=0xF0) -> Chain -> SOCKS5 connector (CMD=0xF0 to Tor) -> Tor -> reply
|
Client <- GOST SOCKS5 handler (writes Reply with resolved IP) <- chain conn (carries resolved addr metadata)
```
Unlike CONNECT (continuous bidirectional pipe), RESOLVE/PTR is a single request-reply exchange. The connector sends the resolve command upstream, reads the reply containing the resolved address, and returns a connection carrying that address as metadata. The handler extracts it, writes the reply to the client, and closes.
## Files to modify
### 1. `x/internal/util/socks/socks.go` — Add Tor resolve command constants
Add after `CmdUDPTun`:
```go
CmdResolve uint8 = 0xF0 // Tor RESOLVE (hostname -> IP)
CmdResolvePTR uint8 = 0xF1 // Tor RESOLVE_PTR (IP -> hostname)
```
### 2. `x/ctx/value.go` — Add context key for SOCKS5 command override
Add a context key and helpers so the handler can signal a custom SOCKS5 command to the connector. Follows the existing pattern used by other context values in this file (Hash, ClientID, SrcAddr, etc.).
```go
type socks5CmdKey struct{}
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
return context.WithValue(ctx, socks5CmdKey{}, cmd)
}
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
return v, ok
}
```
### 3. `x/connector/socks/v5/connector.go` — Send custom SOCKS5 command when signaled
In `Connect()` at the point where `gosocks5.NewRequest(gosocks5.CmdConnect, &addr)` is created:
- Check `Socks5CmdFromContext(ctx)` for a custom command
- Use the context-specified command if present, otherwise default to `CmdConnect`
- After reading the reply, if the command was a resolve type, return a `resolveConn` wrapping the raw connection with the resolved address stored as metadata
### 4. `x/connector/socks/v5/resolve.go` (new file) — Define `resolveConn`
```go
type resolveConn struct {
net.Conn
resolvedAddr *gosocks5.Addr
}
func (c *resolveConn) Metadata() mdata.Metadata {
return mdata.NewMetadata(map[string]any{
"resolvedAddr": c.resolvedAddr,
})
}
```
Implements `net.Conn` (passing through to underlying connection) plus `metadata.Metadatable` to expose the resolved address.
### 5. `x/handler/socks/v5/handler.go` — Add resolve command cases
In `Handle()` switch on `req.Cmd`, add:
```go
case socks.CmdResolve:
return h.handleResolve(ctx, conn, address, ro, log)
case socks.CmdResolvePTR:
return h.handleResolvePTR(ctx, conn, address, ro, log)
```
### 6. `x/handler/socks/v5/resolve.go` (new file) — Implement resolve handlers
**`handleResolve`**:
1. If `enableTor` metadata is false, return `CmdUnsupported` reply
2. Set context with `socks.CmdResolve` via `ContextWithSocks5Cmd`
3. Call `h.options.Router.Dial(ctx, "tcp", address)` — chain routes to SOCKS5 connector, which sends CMD=0xF0
4. Type-assert returned conn to `md.Metadatable`, extract resolved addr
5. Write `gosocks5.Reply{Rep: Succeeded, Addr: resolvedAddr}` to client conn
6. Close chain connection
**`handleResolvePTR`**: Same structure but uses `socks.CmdResolvePTR`.
Both follow the existing error-handling patterns (log errors, write failure replies to client).
### 7. `x/handler/socks/v5/metadata.go` — Add `enableTor` metadata option
Add a boolean metadata field (keys: `"tor"`, `"enableTor"`, `"socks5.tor"`) that defaults to `false`. When `false`, Tor resolve commands are rejected with `CmdUnsupported`. When `true`, the handler accepts and forwards them.
## Verification
### Build check
```bash
cd /config/workspace/go-gost/x && go build ./... && go vet ./...
```
### Integration test
1. Start GOST with `-L "socks5://127.0.0.1:19055?tor=true" -F "socks5://127.0.0.1:9050"` (pointing at a Tor daemon)
2. Use a SOCKS5 client to send a RESOLVE (0xF0) request to port 19055
3. Verify the reply contains a valid resolved IP address
### Manual testing
```bash
gost -L "socks5://127.0.0.1:19055?tor=true" -F "socks5://127.0.0.1:9050"
tor-resolve example.com 127.0.0.1:19055
```
### Test areas
- `tor=false` (default) rejects 0xF0/0xF1 commands with `CmdUnsupported`
- `tor=true` accepts them and forwards through the chain
- Connector sends correct command byte (0xF0/0xF1) when context signals it
- Connector returns `resolveConn` with correct resolved address in metadata
@@ -0,0 +1,119 @@
# go-gost/gost#642 — UDP 目的端口范围跳跃: 可行性 & 复杂度分析
## 需求
客户端支持 UDP 目的端口范围跳跃 (destination port-range hopping),类似 Hysteria 的端口跳跃机制:
```
# 期望的客户端语法
gost3 -L=udp://:8000/mydomain.com:50000-50050/?hopInterval=x秒
gost3 -L=:1080 -F=kcp://127.0.0.1:8000
# 服务端用 iptables 将端口范围折叠到单一端口
iptables -t nat -A PREROUTING -i ens3 -p udp --dport 50000:50050 -j DNAT --to-destination :51000
gost3 -L=kcp://:51000
```
核心用例: 端口跳跃 evasion → iptables 服务端范围折叠 → KCP/QUIC 隧道 → 透明代理。
## 现有基础设施
### 1. `AddrPortRange` — 目的端口范围已可解析
[x/internal/net/addr.go:100](x/internal/net/addr.go#L100) 中 `AddrPortRange.Addrs()` 会将 `50000-50050` 展开为 51 个独立地址 `[":50000", ":50001", ..., ":50050"]`。
[x/config/cmd/cmd.go:440-499](x/config/cmd/cmd.go#L440) 中 `buildServiceConfig()` 将这些地址转化为 `ForwardNodeConfig` 条目。当只有 1 个 listener 但 N 个 forward node 时,所有 N 个 node 都会附加到同一个 service 的 `ForwarderConfig` 上 (一个 hop 持有 N 个 target)。
**关键缺漏**: selector 策略 (`RoundRobin`, `Random`, `Hash`, `FIFO`) 是 per-association / per-connection 的,**不支持基于时间的旋转**。所以要实现"每 X 秒跳跃端口",需要时间感知的选择逻辑。
### 2. UDP forwarder 的 relay 路径
UDP forwarding 由 `x/handler/forward/local/` 处理 (注册名为 `"udp"`, `"tcp"`, `"forward"`):
- **connected UDP** (`handleRawForwarding`): 一次 `dialTarget()` → 单次 `Router.Dial()` → 双向 pipe。目标地址由 hop.Selector 选出,在 association 生命周期内固定。
- **stateless UDP** (`handleRawDatagram`): 每个 datagram 做一次 `dialTarget()` → `ReadFrom` → `WriteTo` → `ReadFrom` → `WriteTo`。从技术上讲,dest port **可以 per-datagram 改变**,但当前 selector 不提供 per-datagram 旋转。
[x/handler/forward/local/forward.go:32-175](x/handler/forward/local/forward.go#L32)
### 3. KCP 会话身份绑定到 4-tuple
xtaci/kcp-go 库 (`v5.6.5`) 的会话模型:
| 组件 | 机制 | 端口跳跃的影响 |
|------|------|---------------|
| **服务端 `Listener`** | 会话存储在 `map[string]*UDPSession`,key 为 `remoteIP:Port` 字符串 | 若客户端源端口稳定 → 同一 session。symmetric NAT 下失效。 |
| **客户端 `output()`** | 固定 `msg.Addr = s.remote`(dial 时解析的地址) | 必须通过 PacketConn wrapper 重写目标端口才能跳跃。 |
| **客户端 `readLoop`** | 锁定到第一个看到的源地址;丢弃所有来自不同地址的包 | 服务端始终从 `:51000` 回复(真实端口,非跳跃范围端口),所以稳定。只有服务端回复时源端口变化才会出问题。 |
### 4. QUIC 支持连接迁移
GOST 的 QUIC 传输使用 quic-go,天然支持 QUIC Connection IDs。这些 ID 允许会话在客户端的 IP/端口变更后依然存活 (RFC 9000 连接迁移)。所以 **QUIC 对 symmetric NAT 也具有较高鲁棒性。**
但 QUIC listener 和 KCP listener 一样,只绑定到**一个端口**。跳跃仍然是面向**服务端的监听端口**的,因此需要 iptables 折叠范围,或者在 gost 内支持多端口 QUIC listener。
## 可行性: 三个方案层级
### 方案 A: 传输层跳跃 (推荐) — 中等工作量
**机制**: 用 PacketConn wrapper 包装 KCP/QUIC dialer 的 `net.PacketConn`,在 `WriteTo` 中根据当前跳跃端口重写目标端口。底层库 (kcp-go/quic-go) 不知道端口在跳跃。
**数据通路**:
```
客户端 KCP dialer 创建 PacketConn → hopWrapper.WriteTo(addr) 重写端口 → kcp-go output() 向 hopped 端口发包
服务端 iptables dport 范围 → 折叠到 :51000 → KCP listener 收到 → 按客户端源 IP:Port 关联会话
服务端从 :51000 回复 → 客户端 readLoop 收到稳定源地址 (一直是 :51000)
```
**需要修改的地方**:
1. **配置解析** (`x/config/cmd/cmd.go` / `x/config/config.go`): 为 `ForwardNodeConfig` 添加 `PortRange` + `HopInterval` 字段,或扩充 chain/hop 的 metadata。
2. **KCP dialer** (`x/dialer/kcp/dialer.go`): 若配置了跳跃参数,将 `kcp.NewConn` 传入的基础 `net.PacketConn` 用 `portHoppingConn` 包装。
3. **QUIC dialer** (`x/dialer/quic/dialer.go`): 同上,但这边的 `quic.DialEarly` 使用的是包装过的 PacketConn。
4. **PortHoppingConn** (新文件/复用 `x/internal/net/` 内现有工具): 一个实现了 `net.PacketConn` 的 wrapper:
- 持有 `currentIndex atomic.Int32` 用于在范围端口间轮转
- 每 `hopInterval` 秒递增 `currentIndex`
- `WriteTo(b, addr)` → 用 `currentPort()` 重写 `addr.Port`,委托到底层 `PacketConn.WriteTo`
- `ReadFrom` / `Close` / `LocalAddr` → 直透传到底层
**KCP 的注意事项**: symmetric NAT(为不同的目标端口分配不同的源端口)会导致服务端因为客户端的 `addr.String()` 变化而创建新 KCP 会话 → 连接中断。这是 KCP 的基本限制,无法在 gost 代码层面解决。**QUIC 没有这个问题**,因为连接 ID 的存在使得源端口变化也没问题。
**工作量**: 预估约 200-300 行新代码 / 约 5 个文件。
### 方案 B: Stateless UDP forwarder 跳跃 — 小工作量
**机制**: 只为 plain-UDP forwarding(非隧道)添加时间跳跃。已经在现有的多 node forwarder (方案 A 的步骤 1) 基础上完成了一半。
**数据通路**: `handleRawDatagram` 中的每次 `dialTarget()` → 当前端口 → `WriteTo`
**限制**: 仅适用于 plain-UDP relay,**不适用于 KCP/QUIC 隧道**,而隧道才是 evasion 真正需要的地方。
**工作量**: 约 50-100 行新代码 / 2-3 个文件。
### 方案 C: 完全协议重设计 — 超大工作量
机制: 不带 iptables 的原生会话迁移,fork / 修补 kcp-go 让会话按 Conversation ID 查找,多端口 listener,迁移信令。这正是 ginuerzh 在 [这条评论](https://github.com/go-gost/gost/issues/642#issuecomment-2568987717) 中提到 "需要新协议和流程" 时的设想。
**工作量**: 数周,跨多个仓库 (fork kcp-go,新认证流程,多 socket 监听)。
## 建议
**方案 A (传输层 PacketConn wrapper)** 是正确的中位数: 用约 200-300 行代码和简单的 PacketConn wrapper,为 KCP 和 QUIC 隧道实现真实的端口跳跃,服务端无需改动 (只需 iptables)。
回应 issue 时,需要解释以下几点:
1. 已实现用途: `AddrPortRange` 已经能将目标端口范围展开为多个 forward node。
2. 未实现部分: 时间感知跳跃,推荐用方案 A 实现。
3. KCP 限制: 服务端 symmetric NAT 会导致跳跃连接中断。建议用 QUIC 传输以获得完全的鲁棒性。
4. 服务端: iptables DNAT 是跨越端口范围的最轻量方案;不需要在 gost 服务端做任何改动。
## 验证
如果决定实现: 不具备 ci/macos 下自动化测试的大规模开销价值。手工验证:
```bash
# 服务端
iptables -t nat -A PREROUTING -p udp --dport 50000:50050 -j DNAT --to-destination :51000
gost -L=kcp://:51000
# 客户端 (运行需要方案 A 的二进制)
gost -L=:1080 -F=kcp://server:50000-50050?hopInterval=10s
# 通过代理发起 UDP 连接,验证 10 秒后目标端口变化 (tcpdump)
```
+942
View File
@@ -0,0 +1,942 @@
# Go-Gost #652: User Namespace 支持 — 技术调研
## Issue
[go-gost/gost#652](https://github.com/go-gost/gost/issues/652) — Feature request: Linux user namespace support.
作者: @Niek | 状态: OPEN
请求支持 Linux user namespaces,使任意程序可以被透明代理。参考项目:[wirez](https://github.com/v-byte-cpu/wirez)(Go 实现的 per-program 透明代理)。
## 核心发现:User NS vs Net NS
GOST 已有完善的**网络命名空间 (netns)** 支持,但 user namespace 与 network namespace 有本质差异:
| 属性 | Network NS | User NS |
|------|-----------|---------|
| 切换时机 | 可事后进入 (`setns()`) | **必须在进程创建时** (`clone(CLONE_NEWUSER)`) |
| 作用域 | per-thread | **per-process** |
| 权限要求 | 需要 `CAP_SYS_ADMIN` | **无需特权** (Linux 3.8+) |
| Go 实现 | `github.com/vishvananda/netns` | `syscall.SysProcAttr` + `os/exec` |
| 可事后 setns? | ✅ (如果有 fd) | ❌ per-process,不能 per-thread 切换 |
**关键结论:现有的 `switchNetns()` 模式([x/internal/net/dialer/dialer_netns.go])无法直接套用到 user namespace。** 需要完全不同的架构:进程创建时通过 `clone()` 建立 namespace,而不是事后 `setns()` 切换。
---
## 现有基础:GOST 已有的可复用组件
### 1. TUN Listener 框架
两个 TUN listener 实现:
- `x/listener/tun/` — 使用 `golang.zx2c4.com/wireguard/tun`,注册名 `"tun"`
- `x/listener/tungo/` — 同样的 wireguard tun driver,注册名 `"tungo"`
两者结构相同:`Init()` → `listenLoop()` → `Accept()` 返回 TUN `net.Conn`,handler 在 conn 上读写 IP 包。
### 2. gVisor Netstack 集成
`x/handler/tungo/` 已深度集成 gVisor netstack (`gvisor.dev/gvisor/pkg/tcpip/stack`)。handler 接收 TUN conn 上的 IP 包,通过 gVisor 做 TCP/UDP 协议栈处理,将流量路由到 GOST proxy chain。
**关键架构优势**:TUN listener 和 handler 在 `io.ReadWriteCloser` 接口处解耦。意味着新增一个 "在 user namespace 内创建 TUN 并通过 fd 传递传回" 的 listener,可以直接对接现有的 `tungo` handler。
### 3. Namespace 基础设施
- `x/internal/net/dialer/dialer_netns.go` — `switchNetns()` 函数,`runtime.LockOSThread()` + `netns.Get()` + `netns.Set()` 模式
- `x/internal/net/net.go` — `ListenConfig` wrapper,支持 namespace-aware 的 `Listen()`/`ListenPacket()`
- `x/config/parsing/parse.go` — `MDKeyNetns`, `MDKeyNetnsOut` metadata key 常量
- `x/config/parsing/service/parse.go` — Service 级别的 netns 解析和注入
### 4. Proxy Chain 基础设施
完全复用:路由 → chain → hop → dialer → connector 管道无需任何修改。
---
## 方案对比
### 方案 A:Wirez 风格透明代理 — 新增 `usertun` Listener ⭐ 推荐
新增 listener 类型,在子进程中 spawn 目标程序(`CLONE_NEWUSER | CLONE_NEWNET`),子进程创建 TUN 并通过 Unix socket + `SCM_RIGHTS` 将 fd 传回父进程。父进程用 gVisor netstack 在 TUN 上处理 TCP/UDP,路由到 GOST proxy chain。
```
┌─ Parent (GOST, unprivileged host user) ───────────┐
│ │
│ TUN fd ← SCM_RIGHTS ← Unix socket ← Child │
│ gVisor netstack ← TUN │
│ TCP/UDP → GOST chain → SOCKS5/HTTP proxy │
│ │
└─────────────────────────────────────────────────────┘
│ clone(CLONE_NEWUSER | CLONE_NEWNET)
▼
┌─ Child (new userns+netns, UID 0 inside) ───────────┐
│ │
│ Creates /dev/net/tun │
│ Configures routing (default gw → TUN) │
│ exec's target program │
│ │
└─────────────────────────────────────────────────────┘
```
**re-exec 核心代码示意:**
```go
// Parent: spawn child in new user+net namespace
socks, _ := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
cmd := exec.Command("/proc/self/exe", "--gost-usertun-child", ...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
UidMappings: []syscall.SysProcIDMap{
{ContainerID: 0, HostID: os.Getuid(), Size: 1},
},
GidMappings: []syscall.SysProcIDMap{
{ContainerID: 0, HostID: os.Getgid(), Size: 1},
},
GidMappingsEnableSetgroups: false,
}
cmd.ExtraFiles = []*os.File{os.NewFile(uintptr(socks[1]), "tun-sock")}
cmd.Start()
// Receive TUN fd from child
oob := make([]byte, unix.CmsgSpace(4))
_, _, _, _, err := unix.Recvmsg(socks[0], nil, oob, 0)
// ... extract fd from SCM_RIGHTS control message
```
**可复用的已有代码:**
| 组件 | 文件 | 复用方式 |
|------|------|----------|
| TUN 设备创建 | `x/listener/tun/tun.go` (`createTunDevice()`) | 在子进程中调用 |
| gVisor netstack | `x/handler/tungo/` | 父进程中直接使用 |
| Proxy chain | `x/chain/`, `x/router/` | 无修改 |
| LockOSThread 模式 | `x/internal/net/dialer/dialer_netns.go` | 参考模式 |
| Netlink 路由配置 | `x/listener/tun/tun_linux.go` | 在子进程中调用 |
**需新增的代码(估算 ~400-600 行):**
| 工作项 | 估算行数 |
|--------|----------|
| re-exec 入口点 (child mode handler in `cmd/gost/`) | ~50 |
| Namespace + TUN 创建 + 路由 (child side) | ~120 |
| Parent/child Unix socket + SCM_RIGHTS fd 传递 | ~60 |
| usertun listener (parent side, gVisor attachment) | ~150 |
| Metadata 解析 + config 定义 | ~80 |
| 错误处理 + 优雅关闭 + cleanup | ~80 |
| 非 Linux 平台 stub | ~40 |
**依赖变化:零新增依赖。** 所有需要的原语已在依赖树中:
- `golang.org/x/sys/unix` — `CLONE_NEWUSER`, `CLONE_NEWNET`, `SCM_RIGHTS`, `Recvmsg`
- `golang.zx2c4.com/wireguard/tun` — TUN 设备创建
- `github.com/vishvananda/netlink` — 地址/路由配置
- `gvisor.dev/gvisor/pkg/tcpip/stack` — userspace 协议栈(已在 tungo handler 中使用)
**复杂度:中等。** 核心挑战是进程管理和 fd 传递,不涉及 GOST 核心转发逻辑改动。
**平台限制:Linux only。**
---
### 方案 B:简单 User NS 包装器 — 新增 `userexec` Listener
不集成 TUN/gVisor。让 GOST 启动外部命令在 user+net namespace 中运行,GOST 在 namespace 内暴露一个 SOCKS5 listener,子进程通过该 listener 代理。
```
gost -L "userexec://?cmd=curl&listen=127.0.0.1:0"
&proxy=socks5://127.0.0.1:1080"
```
GOST 在 namespace 内创建一个 SOCKS5 listener + handler,子进程通过环境变量 (`ALL_PROXY`) 或直接配置代理。
**改动量:~200-300 行**
**优点:** 实现简单,不影响现有架构
**缺点:**
- 需要目标程序支持 SOCKS5/HTTP 代理(或 LD_PRELOAD),比不上 Wirez 的透明性
- 对于不支持代理的程序(Go 静态链接、某些 CLI 工具)无效
- 本质上只是 `unshare -Urn` + `proxy exec` 的包装
---
### 方案 C:GOST 自身沙箱化
将 GOST 拆分为特权 controller(处理 listener bind、TUN 创建)+ 非特权 worker(在 user namespace 中处理流量转发)。
**不推荐:** 改动范围太大(涉及核心生命周期重构),投入产出比不合理。作为安全加固方向可以在未来考虑,但不是 #652 的合理解决方案。
---
## 关键技术风险
| 风险 | 严重程度 | 缓解措施 |
|------|----------|----------|
| **Go #29789**: `Unshareflags` + `UidMappings` 组合在 `exec.Cmd` 中可能 `EPERM` | 中 | 使用 `Cloneflags` 而非 `Unshareflags`;此 bug 在 `exec.Cmd` 中已有 workaround |
| **`/dev/net/tun` 访问权限** | 低 | 子进程在 user ns 中是 UID 0,有 `CAP_NET_ADMIN`;`/dev/net/tun` 在内核中不检查 host UID |
| **gVisor netstack 性能** | 低 | `tungo` handler 已验证 gVisor 性能可接受;对日常使用足够 |
| **`/etc/subuid` 依赖** | 无 | 单 UID 映射 (`host_uid → ns_uid 0`) 不需要 `/etc/subuid` |
| **`setgroups` 限制** | 低 | `GidMappingsEnableSetgroups: false` 是 Go 1.7+ 的标准写法 |
| **User NS 嵌套** | 低 | 如果 GOST 本身在容器中运行(已有 user ns),嵌套创建需要内核支持(最大 32 层);Linux 4.9+ 默认支持 |
| **re-exec 入口点** | 低 | 隐藏 CLI flag(`--gost-usertun-child`),不影响用户界面 |
---
## 不需要引入的库
以下库经过评估,**不建议引入**:
| 库 | 原因 |
|----|------|
| `github.com/opencontainers/runc/libcontainer` | 太重:~50+ 间接依赖(cgroups, seccomp, capabilities, apparmor, CRIU),只为 user+net ns 创建引入不划算 |
| `github.com/rootless-containers/rootlesskit` | 二进制导向架构(外部 `newuidmap`/`newgidmap`),不适合做 library |
| `github.com/v-byte-cpu/wirez` | 独立的命令行工具,不是 library |
---
## 实现路径建议
如果决定实现方案 A,分 3 个阶段:
### Phase 1: POC (~1-2 天)
- [ ] parent/child re-exec 框架(`cmd/gost/main.go` 中增加 `--gost-usertun-child` 隐藏 flag)
- [ ] 子进程中 user+net namespace 创建 + TUN 创建 + 路由配置
- [ ] Unix socket + SCM_RIGHTS fd 传递
- [ ] 父进程中 TUN fd → gVisor netstack → proxy chain 通路验证
- [ ] 非 root 用户端到端测试(如 `curl ifconfig.me` → SOCKS5 代理)
### Phase 2: 生产化 (~2-3 天)
- [ ] `usertun` listener 完整实现(metadata 解析、config 定义)
- [ ] 错误处理:子进程 crash、TUN 断开、namespace 清理
- [ ] 优雅关闭:SIGTERM → 子进程 cleanup → namespace 销毁
- [ ] DNS 在 namespace 内的处理(gVisor DNS resolver)
- [ ] 非 Linux 平台 stub(返回 "not supported")
### Phase 3: 测试/文档 (~1 天)
- [ ] E2E 测试(Docker-based,参考 `gost/tests/e2e/`)
- [ ] `docs/issues/` 中配置文档
- [ ] 上游 issue 回复
---
## 需要修改的文件清单
### 新文件
- `x/listener/usertun/listener.go` — usertun listener(父进程侧,接收 TUN fd)
- `x/listener/usertun/listener_linux.go` — Linux 实现
- `x/listener/usertun/listener_other.go` — 非 Linux stub
- `x/listener/usertun/metadata.go` — metadata 解析
- `x/listener/usertun/conn.go` — TUN conn 包装
- `x/internal/net/userns/userns_linux.go` — user+net namespace 创建 + re-exec 逻辑
- `x/internal/net/userns/userns_other.go` — 非 Linux stub
- `gost/cmd/gost/usertun_child.go` — re-exec 子进程入口点
### 需要修改的已有文件
- `x/config/parsing/parse.go` — 新增 `MDKeyUserTun` metadata key
- `x/config/parsing/service/parse.go` — usertun 的 service 解析
- `x/config/config.go` — UserTun 相关 config struct(如需要)
- `gost/cmd/gost/main.go` — re-exec 入口分发
- `gost/cmd/gost/register.go` — 注册 usertun listener
### 不需要修改的文件
- `x/handler/tungo/` — 不做任何修改,直接复用
- `x/chain/`, `x/router/` — 不做任何修改
- `x/internal/net/dialer/` — 不做任何修改
- `core/` — 接口不变
---
## 结论
**可行性:高。** GOST 已有的 TUN + gVisor + proxy chain 架构天然适合 Wirez 风格的透明代理集成。User namespace 逻辑是完全隔离的"胶水代码",不改动任何现有核心转发路径。
**集成复杂度:中等偏低。** 核心工作约 400-600 行新代码,零新增依赖,主要挑战是进程管理和 fd 传递。分为 3 个阶段,总计约 4-6 天工作量。
**建议:** 技术可行,值得列入 roadmap,但不急于实现。这个功能开启了"非 root 透明代理任意程序"的使用场景,对移动端/CI 环境/容器化部署有意义。可以等社区有更多需求信号时再推进实现。
---
## 附录:Windows 平台可行性研究
Linux user namespace 的核心价值是:**无需管理员权限,创建隔离的网络环境,使任意程序的流量可以被透明代理。** 在 Windows 上是否有等价机制?本节对此进行独立评估。
### Windows 与 Linux 的根本差异
| 概念 | Linux | Windows |
|------|-------|---------|
| Namespace 隔离 | 内核原生支持,非特权用户可创建 | 无等价概念 |
| 网络重定向 | `netns` + TUN + 路由表 | WFP / WinDivert / TUN(均需管理员) |
| 进程级代理 | `CLONE_NEWUSER` → 子进程隔离 | 无原生机制 |
| TUN 设备创建 | 非特权用户(在 user ns 内) | **始终需要管理员** |
**核心结论:Windows 不存在 Linux user namespace 的等价物。** 所有能实现"透明代理单个程序"的 Windows 机制都至少需要一次性管理员权限。
---
### Windows 透明代理技术全景
#### 方案 1:WinDivert(包级拦截 + 重定向)⭐ Windows 首选
WinDivert 是一个用户态包捕获/修改/重注入框架,内核驱动已签名预编译。
```
目标程序 → WinDivert.sys (内核) → Go 用户态程序 (godivert)
│
修改目标 IP → 127.0.0.1:proxy
│
GOST SOCKS5/HTTP 代理
│
GOST chain → 真实目标
```
| 属性 | 评估 |
|------|------|
| 管理员权限 | **需要**(安装驱动) |
| 单进程过滤 | ✅(`process.path = "C:\\...\\app.exe"`) |
| TCP 重定向 | ✅ |
| UDP 重定向 | ⚠️ 可能但复杂 |
| Go 库 | `github.com/yanlinLiu0424/godivert`, `github.com/imgk/divert-go` |
| 成熟度 | 高(ProxyBridge、Mullvad、SSTap 等均基于此) |
| 新增依赖 | `godivert` + `WinDivert64.sys`(预编译,~200KB) |
**GOST 集成方式:** 新增 `windivert` handler,监听 WinDivert 捕获的包,将 TCP SYN 目标改写为本机代理端口。参考实现:[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)(MIT,Go)。
**复杂度:中。** GOST 需要实现:WinDivert 会话管理 + 包解析/重写 + TCP 状态跟踪 + 代理转发。约 500-800 行新代码。
---
#### 方案 2:Wintun TUN + Route 重定向
GOST 已有完整的 Windows TUN 支持(`x/listener/tun/tun_windows.go`)。
| 属性 | 评估 |
|------|------|
| 管理员权限 | **需要**(创建网络适配器 + `netsh` 路由操作) |
| 单进程过滤 | ❌(路由表是系统级的) |
| TUN 创建 | ✅(已集成 `golang.zx2c4.com/wireguard/tun`) |
| gVisor netstack | ✅(`x/handler/tungo/` 已集成) |
| Go 库 | 全部已有 |
**限制:** Windows 没有 per-process 路由表。TUN 模式会将**整个系统**的流量(或在路由表中的特定 CIDR)导入代理,无法限制只代理单个进程。
**GOST 集成方式:** 完全不需新代码——`gost -L tun://` 在 Windows 上已经可以工作(以管理员权限运行)。
---
#### 方案 3:Wintun + WFP Callout(Mullvad 模式)— WinDivert 的主要竞争者
这是理论上最优、实践上最难的方案。核心思路:
**WFP callout 内核驱动在 `ALE_CONNECT_REDIRECT` / `ALE_BIND_REDIRECT` 层拦截特定进程的 socket 操作,将流量重定向到 Wintun 虚拟适配器。Wintun 上的 IP 包**保留原始目标地址**(因为重定向发生在 socket 层而非包层),GOST 已有的 TUN listener + gVisor netstack + proxy chain 直接处理,零新 handler 代码。**
```
目标进程调用 connect("example.com:443")
│
▼
┌─ WFP Callout 内核驱动 ──────────────────────────────┐
│ ALE_CONNECT_REDIRECT_V4/V6 │
│ ├─ 按 PID/进程名过滤 │
│ ├─ 重定向 socket → Wintun 接口 │
│ └─ 原始目标 IP 保持不变(socket 级操作) │
└──────────────────────┬──────────────────────────────┘
│ TCP SYN dst=example.com:443
▼
┌─ Wintun TUN 适配器 ─────────────────────────────────┐
│ IP 包: src=<app> dst=example.com:443 ← 目标完整保留 │
│ GOST TUN listener (已有) │
│ gVisor netstack (已有) │
│ GOST proxy chain (已有) │
└─────────────────────────────────────────────────────┘
```
**为什么这比 WinDivert 优雅:**
| 维度 | WFP Callout → Wintun | WinDivert |
|------|---------------------|-----------|
| **拦截层** | Socket 层(`connect()`/`bind()` 级别) | 包层(IP/TCP 头修改) |
| **原始目标** | 自然保留在 IP 头中 | 丢失,需映射表恢复 |
| **TCP 状态** | 内核处理,无需用户态跟踪 | 需用户态维护 TCP 状态机 |
| **校验和** | 无需修改 | 需重算 IP/TCP checksum |
| **UDP 支持** | ✅ 干净(BIND_REDIRECT + CONNECT_REDIRECT) | ⚠️ 复杂 |
| **GOST 代码复用** | ✅ 100%(TUN+gVisor+chain 不变) | ⚠️ ~50%(需新 handler) |
| **连接生命周期** | WFP 原生跟踪(connect/close 事件) | 用户态推断 |
| **递归重定向防护** | WFP 内置机制(`SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS`) | 需手动维护排除列表 |
**但是——实现代价极其巨大:**
下面是 Mullvad [win-split-tunnel](https://github.com/mullvad/win-split-tunnel)(开源的 WFP callout 驱动,GPLv3/MPL-2.0)的工程复杂度分析:
| 维度 | 数据 |
|------|------|
| 语言 | C++ 94.2% + C 4.2%(非 C#/Rust/Go) |
| 类型 | 非 PnP KMDF 内核驱动 |
| 源文件 | 21 个根级文件 + 8 个 firewall/ 子系统文件 |
| 子系统 | 防火墙(WFP callout)、进程管理(完整进程树)、事件(用户态通信)、IOCTL 接口 |
| 驱动状态机 | STARTED → INITIALIZED → READY → ENGAGED(4 阶段 IOCTL 初始化) |
| WFP 层 | `ALE_CONNECT_REDIRECT_V4/V6`, `ALE_BIND_REDIRECT_V4/V6`, `ALE_AUTH_CONNECT_V4/V6`, `ALE_AUTH_RECV_ACCEPT_V4/V6` |
| 流量场景 | 9 种组合(IPv4/v6 × internet/tunnel 接口可用性) |
| 进程管理 | 系统范围进程创建/销毁通知 + 父子进程继承 |
| 事务系统 | 自定义并行事务系统(独立于 WFP 事务) |
| 构建环境 | Visual Studio 2022 + WDK v10.0.22621 + Windows SDK v10.0.22621(仅可在 Win10 2004+ 构建) |
| 代码签名 | EV 证书($300-500/年)+ Microsoft 认证签名 |
| 用户态依赖 | 需要 agent 进程持续提供配置、监控网络接口变化 |
| Git 提交 | 205 commits |
**对 GOST 来说,实现一个 WFP callout 驱动的实际工作量估算:**
| 工作项 | 估算 |
|--------|------|
| WFP callout 驱动开发(C++) | 3-5 周(全职) |
| 进程管理子系统(PID/进程名过滤 + 父子继承) | 1-2 周 |
| IOCTL 接口 + 用户态 Go 控制库(CGO) | 1 周 |
| EV 签名 + Microsoft 认证签名 | 持续成本 |
| 驱动安装/卸载/升级逻辑 | 1 周 |
| 跨 Windows 版本兼容性测试(10/11/Server) | 1-2 周 |
| 调试和稳定性(内核崩溃 = BSOD) | 2-4 周 |
| 用户态 agent(网络变化监控 + 配置推送) | 1 周 |
| **总计** | **10-16 周 + 持续的签名费用** |
**结论:Wintun+WFP 在技术架构上明显优于 WinDivert,但实现代价完全不匹配 GOST 的投入规模。** 这条路线只有在以下场景才合理:(a) 商业化产品有专职 Windows 内核开发团队;(b) 可以 fork Mullvad win-split-tunnel 直接复用(但 Mullvad 的驱动是"排除进程出 VPN"方向,需要反转逻辑 + 仍然需要签名)。
**GOST 若选择此路线,最现实的方案是直接复用 Mullvad 驱动 + 反转过滤逻辑(从"排除"改为"包含")**——但即使如此,仍需精通 Windows 内核开发的工程师,以及持续的代码签名费用。
---
#### 方案 4:API Hooking / DLL 注入
启动目标进程时注入 hook DLL,拦截 Winsock `connect()`/`WSAConnect()` 调用并重定向到本机代理。
| 属性 | 评估 |
|------|------|
| 管理员权限 | ⚠️ 不一定需要(注入自己启动的子进程通常可行) |
| 单进程过滤 | ✅(注入到目标进程内) |
| TCP 重定向 | ✅ |
| UDP 重定向 | ✅(hook `sendto()`/`recvfrom()`) |
| Go 实现 | 需要 CGO + 注入 DLL(C 编写) |
| 风险 | 杀软可能标记;32/64 位不匹配;对反注入保护的应用无效 |
参考:[ProxiGo](https://github.com/hypnguyen1209/proxigo)(Go 实现的 Detours 风格注入器)。
**复杂度:中高。** 需要维护注入 DLL(C 代码),且可靠性不如内核级方案。
---
#### 方案 5:AppContainer + 环境变量
Windows AppContainer 提供进程级安全隔离(UWP/IoT 使用),结合环境变量实现"受限代理"。
| 属性 | 评估 |
|------|------|
| 管理员权限 | **不需要** |
| 网络能力控制 | ✅(通过 Capability SID 限制 Internet/Private 访问) |
| 代理重定向 | ❌(只能 Allow/Deny,不能重定向) |
| 环境变量注入 | ✅(`HTTP_PROXY` 等) |
**限制:** AppContainer 不能重定向流量到代理。只能配合 `HTTP_PROXY` 环境变量使用(仅覆盖支持它的应用)。这不是透明代理方案。
---
#### 方案 6:Windows 容器 / HCS
Windows 容器(通过 `hcsshim` Go SDK)提供网络命名空间级别的隔离,等效于 Linux network namespace + user namespace。
| 属性 | 评估 |
|------|------|
| 管理员权限 | **需要**(启用 Containers 功能 + HCS 操作) |
| 网络隔离 | ✅(HCN/HNS 提供 network namespace 等效能力) |
| 代理策略 | ✅(`hcnproxyctrl` 支持 L4 WFP 代理策略) |
| Go SDK | ✅(`github.com/Microsoft/hcsshim`) |
| 开销 | 高(~200MB+ 基础镜像,秒级启动) |
**不推荐。** 开销太大,不适合"代理单个命令"的场景。
---
#### 方案 7:PAC / WinHTTP / WinINET 代理设置
| 属性 | 评估 |
|------|------|
| 管理员权限 | WinINET: 不需要(HKCU registry)。WinHTTP: 需要(HKLM)。 |
| 单进程过滤 | ❌(用户级或机器级全局设置) |
| 覆盖范围 | WinINET: IE + 使用 WinINET 的应用。WinHTTP: 服务/后台应用。均不覆盖直接使用 Winsock 的程序。 |
**不适合。** 既不能做到单进程,也不能覆盖所有应用。
---
### Windows 方案对比总表
| 方案 | 需要 Admin | 单进程代理 | 透明性 | TCP | UDP | 新代码 | 新依赖 |
|------|-----------|-----------|--------|-----|-----|--------|--------|
| **WinDivert** ⭐ | 一次性(驱动安装) | ✅ | ✅ | ✅ | ⚠️ | ~500-800行 | `godivert` |
| Wintun TUN | 每次运行 | ❌ | ✅ | ✅ | ✅ | 0 | 无 |
| Wintun + WFP | 每次运行 | ✅ | ✅ | ✅ | ✅ | ~1000行 | 内核驱动(C) |
| DLL 注入/Hooking | ⚠️ 不一定 | ✅ | ✅ | ✅ | ✅ | ~600行 | 注入DLL(C) |
| AppContainer | ❌ | ✅ | ❌ | N/A | N/A | ~200行 | 无 |
| Windows 容器 | 每次运行 | ✅ | ✅ | ✅ | ✅ | ~400行 | `hcsshim` (~10MB) |
| PAC/WinHTTP | ❌ | ❌ | ❌ | N/A | N/A | 0 | 无 |
---
### Windows 推荐方案:WinDivert
在 Windows 上实现 #652 的目标(透明代理单个程序),**WinDivert 是唯一在复杂度、可靠性、成熟度之间取得合理平衡的方案**。
**架构设计:**
```
gost -L "windivert://?process=chrome.exe&proxy=socks5://127.0.0.1:1080"
```
```
┌──────────────┐
│ GOST Process │
│ │
WinDivert64.sys ──→ │ godivert │──→ TCP 状态跟踪
(kernel driver) │ handler │──→ 包重写(目标→127.0.0.1:proxy)
↑ │ │──→ 代理 SOCKS5/HTTP
目标进程流量 │ local proxy │──→ GOST proxy chain
(chrome.exe) └──────────────┘
```
**关键实现要点:**
1. **进程过滤:** WinDivert filter string `outbound and tcp and process.path = "C:\\...\\target.exe"`
2. **包重写:** 修改 TCP SYN 的 destination IP/port 为 `127.0.0.1:<proxy_port>`,重算 checksum
3. **原目标记录:** 在代理端通过自定义协议或 TPROXY 等价机制获取原始目标地址
4. **连接跟踪:** 维护 src_ip:src_port → original_dst 映射表
5. **DNS 劫持:** 拦截 UDP 53 的 DNS 请求,防止 DNS 泄漏
**与 Linux 方案的对比:**
| 维度 | Linux (usertun) | Windows (windivert) |
|------|----------------|---------------------|
| 权限模型 | 零特权(user ns 内 root) | 需要一次性管理员安装驱动 |
| 架构 | re-exec 子进程 + TUN fd 传递 | 内核驱动拦截 + 用户态包重写 |
| 代码量 | ~400-600 行 | ~500-800 行 |
| 新增依赖 | 零 | `godivert` (~3KB Go 代码) + `WinDivert64.sys` (~200KB) |
| 成熟度参考 | wirez, tun2socks, gVisor | ProxyBridge, Mullvad, SSTap |
| 平台 | Linux 3.8+ | Windows 7+ |
---
### 业界参考:SocksCap64 与 Netch 的进程流量代理原理
为了更全面地评估 Windows 上的技术选型,本节分析两个成熟的 Windows 进程代理工具的内部机制。
#### SocksCap64:Inline API Hook + 远程 DLL 注入
**主页:** https://www.sockscap64.com/
**核心原理:** SocksCap64 是经典的 Permeo SocksCap 的 64 位继任者,采用**用户态 Inline API Hook + 远程 DLL 注入**技术。
**架构流程:**
```
SocksCap64.exe (启动器)
│
│ CreateProcess + 远程 DLL 注入
▼
目标进程 (已注入 Hook DLL)
│
│ Hook ws2_32.dll 函数:
│ connect(), send(), recv()
│ WSASend(), WSARecv()
│ sendto(), recvfrom()
│ bind(), GetAddrInfoW()
│
│ 每次 connect() 被截获时:
│ 1. 记录原始目标地址
│ 2. 将目标改写为 127.0.0.1:<proxy_port>
│ 3. 完成 SOCKS5 握手(认证 + CONNECT)
│ 4. 所有 send()/recv() → 通过 SOCKS5 隧道中转
▼
SOCKS5 代理服务器
```
**技术细节:**
| 维度 | 说明 |
|------|------|
| Hook 方式 | **Inline Hook**(函数开头写入 `JMP <HookHandler>`,5 字节) |
| Hook 库 | 未公开(可能基于 Detours 或自研) |
| 注入方式 | 远程线程注入(`CreateRemoteThread` + `LoadLibrary`) |
| 目标进程 | 由 SocksCap64 启动的任意 Win32 程序 |
| 管理员权限 | 理论上不需要(注入自己启动的子进程),安装时可能需要 |
| 源码 | 闭源 |
| Go 绑定 | 无 |
| 稳定性 | 中低 — 侵入式注入可能被反作弊/杀软拦截 |
| 覆盖范围 | 所有使用 Winsock 的 Win32 应用;**不覆盖 UWP/Metro 应用** |
**Hook 过程(5 步):**
1. `GetProcAddress` 获取 `ws2_32.dll` 中 `connect`/`send`/`recv` 等函数地址
2. 保存原始函数的前 N 字节指令(用于 trampoline 回调原始函数)
3. `VirtualProtect` 将函数地址页设为 `PAGE_EXECUTE_READWRITE`
4. 在函数开头写入 `JMP <HookHandler>`(E9 相对跳转,5 字节)
5. Hook Handler 中:判断是否代理 → 修改目标地址 → SOCKS5 握手 → 转发数据
**SocksCap64 vs Proxifier 差异:**
| | SocksCap64 | Proxifier |
|---|---|---|
| 技术 | Inline API Hook + DLL 注入 | Winsock LSP(Winsock SPI) |
| 机制 | 修改函数 prologue | 注册 Layered Service Provider |
| 稳定性 | 较低(侵入式注入) | 较高(使用官方 SPI 接口) |
| 反作弊对抗 | 容易被检测/拦截 | 兼容性更好 |
| 现状 | 已停止更新 | 持续维护 |
| 64 位支持 | 原生支持 | 原生支持 |
**对 GOST 的参考价值:**
- ✅ 验证了 "拦截 Winsock → 改写目标 → SOCKS5 转发" 模式的有效性
- ✅ 不需要内核驱动,不强制需要管理员权限
- ❌ DLL 注入本身可靠性低,对 GOST 这种网络工具不适用
- ❌ 注入 DLL 需要 C 编写,Go 无法直接实现
---
#### Netch ProcessMode:内核级包过滤 (netfilter2) + 用户态重定向
**主页:** https://github.com/BoyceLig/Netch | **许可:** GPLv3
**核心原理:** Netch 的 `ProcessMode` 使用 **ntkernel.com netfilter2 SDK**(商业内核驱动)+ **用户态 Redirector DLL**(C++),实现进程级 TCP/UDP 流量拦截和 SOCKS5 重定向。
**架构流程:**
```
┌─ Netch.exe (C# / .NET) ──────────────────────────┐
│ │
│ P/Invoke → Redirector.bin (C++ DLL) │
│ ├─ aio_init() 初始化 │
│ ├─ aio_register() 注册进程过滤规则 │
│ └─ aio_dial() 配置代理目标 │
│ │
│ Redirector DLL: │
│ ├─ nf_init() 注册 netfilter2 驱动 │
│ ├─ nf_registerDriver() │
│ ├─ nf_addRule() 安装过滤规则 │
│ └─ NF_EventHandler 回调: │
│ ├─ tcpConnectRequest ← 内核拦截 TCP 连接 │
│ ├─ tcpSend / tcpReceive │
│ ├─ udpCreated / udpSend / udpReceive │
│ └─ tcpClosed / udpClosed │
│ │
│ TCPHandler (用户态): │
│ ├─ 本地 TCP listener (127.0.0.1:<port>) │
│ ├─ CreateHandler(client, original_dst) 映射表 │
│ └─ Handle() → SocksHelper::TCP → SOCKS5 │
│ │
└─────────────────────────────────────────────────────┘
│ nf_registerDriver (ioctl)
▼
┌─ netfilter2.sys (内核驱动, ntkernel.com) ──────────┐
│ 拦截 TCP connect request (NDIS/WFP 层) │
│ 按 PID/进程名过滤 │
│ 将包信息回调到用户态 │
└─────────────────────────────────────────────────────┘
```
**关键源码文件(基于 GitHub 仓库分析):**
| 文件 | 作用 |
|------|------|
| `Redirector/Redirector.cpp` | DLL 入口,初始化 netfilter2 |
| `Redirector/EventHandler.cpp` | `tcpConnectRequest` 等回调实现 |
| `Redirector/TCPHandler.cpp` | 本地 TCP 监听 + 连接映射表 |
| `Redirector/SocksHelper.cpp` | SOCKS5 握手 + 认证 + CONNECT/UDP ASSOCIATE |
| `Redirector/DNSHandler.cpp` | DNS 劫持/重定向 |
| `Netch/` (C#) | 主程序,P/Invoke 调用 Redirector.bin |
| `RouteHelper/` | 辅助路由工具 |
**TCP `tcpConnectRequest` 重定向流程(基于 EventHandler.cpp 源码):**
```cpp
void tcpConnectRequest(ENDPOINT_ID id, PNF_TCP_CONN_INFO info) {
// 1. 自过滤:不拦截 redirector 自身的流量
if (CurrentID == info->processId) return;
// 2. 进程名过滤:检查 bypassList 和 handleList
if (checkBypassName(info->processName)) return; // 白名单跳过
if (!checkHandleName(info->processName)) return; // 不在代理列表中
// 3. 记录原始目标地址
SOCKADDR_IN6 client, remote;
memcpy(&client, &info->localAddress, sizeof(client));
memcpy(&remote, &info->remoteAddress, sizeof(remote));
// 4. 改写连接目标 → 本机 SOCKS 端口
// IPv4: info->remoteAddress = 127.0.0.1:tcpListen
// IPv6: info->remoteAddress = ::1:tcpListen
// 5. 建立映射表:client_addr → original_remote
TCPHandler::CreateHandler(client, remote);
}
```
**技术总结:**
| 维度 | 说明 |
|------|------|
| 拦截层 | **内核级**(netfilter2 NDIS/WFP 驱动) |
| 用户态组件 | Redirector.bin (C++ DLL),C# P/Invoke 调用 |
| 进程过滤 | 驱动层按 PID + 进程名匹配 |
| TCP 重定向 | 内核拦截 connect → 改写目标 → 本地 listener → SOCKS5 |
| DNS 处理 | DNSHandler 劫持 DNS 查询 |
| 管理员权限 | **必须**(内核驱动安装 + 运行) |
| 源码 | C++/C# 部分 GPLv3 开源;netfilter2 驱动是商业闭源 |
| Go 绑定 | **无**(netfilter2 SDK 提供 C API,可理论上通过 CGO 调用) |
| 稳定性 | 高(内核级拦截比用户态 DLL 注入可靠) |
**Netch 的 4 种模式对比:**
| 模式 | 技术 | 粒度 | Admin |
|------|------|------|-------|
| **ProcessMode** | netfilter2 内核驱动 | 单进程 | 必须 |
| **ShareMode** | WinPcap/Npcap | 系统级(共享) | 必须 |
| **TunMode** | WinTUN 驱动 | 系统级 | 必须 |
| **WebMode** | HTTP 代理 | 应用层(需应用支持) | 不需要 |
---
### Wintun+WFP vs WinDivert — 深度对比
#### 架构差异的本质
这两种方案的**根本区别**在于流量拦截的层级:
```
Wintun+WFP (socket级): WinDivert (包级):
connect("example.com") connect("example.com")
│ │
[WFP callout] [WinDivert driver]
改 socket 路由到 TUN 捕获 TCP SYN 包
│ │
TCP SYN: dst=example.com 改包: dst=127.0.0.1:proxy
原始目标在 IP 头中! │
│ TCP SYN: dst=127.0.0.1
[GOST TUN listener] 原始目标丢失!
│ │
[gVisor netstack] [自定义 handler]
TCP 协议栈自然处理 SOCKS5 握手 + dst 恢复
│ │
[GOST proxy chain] [GOST proxy chain]
│ │
→ example.com:443 → example.com:443
```
#### 逐项对比
| 评估维度 | Wintun+WFP Callout | WinDivert | 优势方 |
|----------|-------------------|-----------|--------|
| **原始目标保留** | ✅ IP 头自然保留 | ❌ 需映射表/编码恢复 | WFP |
| **TCP 处理** | ✅ gVisor 协议栈 | ⚠️ 手动 checksum + 状态跟踪 | WFP |
| **UDP 处理** | ✅ WFP bind/connect redirect | ⚠️ 无连接语义,复杂 | WFP |
| **连接生命周期** | ✅ WFP 原生 connect/close 事件 | ⚠️ 用户态推断 | WFP |
| **DNS 处理** | ✅ gVisor DNS resolver | ⚠️ 需独立 DNSHandler | WFP |
| **递归重定向防护** | ✅ WFP `SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS` | ⚠️ 手动排除本地代理端口 | WFP |
| **GOST 代码复用** | ✅ 100%(TUN+gVisor+chain 零修改) | ⚠️ ~50%(需新 handler + 协议) | WFP |
| **驱动依赖** | ❌ 需自写内核驱动 (C++) | ✅ WinDivert64.sys 已存在 | **WinDivert** |
| **驱动签名** | ❌ EV 证书 + MS 认证 ($300-500/年) | ✅ 已签名 | **WinDivert** |
| **开发语言** | ❌ C++ (KMDF 内核驱动) | ✅ Go (`godivert`) | **WinDivert** |
| **开发工作量** | ❌ 10-16 周 + 内核调试 | ✅ 1-2 周 | **WinDivert** |
| **维护负担** | ❌ 跨 Win10/11/Server 兼容性 + BSOD 风险 | ✅ 上游维护驱动 | **WinDivert** |
| **Go 生态** | ❌ 无 Go 库,需 CGO 封装 | ✅ `godivert` 成熟 | **WinDivert** |
| **构建环境** | ❌ VS2022 + WDK + WinSDK(仅 Win10 2004+ 可构建) | ✅ `go build` | **WinDivert** |
| **开源参考** | Mullvad win-split-tunnel (C++, 205 commits) | ProxyBridge (Go, MIT) | **WinDivert** |
#### 结论
**Wintun+WFP 在技术架构上全面优于 WinDivert**(socket 级重定向 + 原始目标保留 + 100% 复用 GOST 已有 TUN 代码),但**实现代价完全不匹配 GOST 的投入规模**。
| | Wintun+WFP | WinDivert |
|---|---|---|
| 技术优雅度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 实现可行性(对 GOST) | ⭐ | ⭐⭐⭐⭐⭐ |
| 投入产出比 | ⭐ | ⭐⭐⭐⭐ |
**WinDivert 仍然是 GOST 在 Windows 上唯一实际可行的单进程透明代理方案。** Wintun+WFP 在以下条件同时满足时才有意义:
1. 有专职 Windows 内核开发工程师
2. 愿意持续支付 EV 代码签名费用
3. 愿意承担跨 Windows 版本的内核兼容性维护
对于 GOST 这样以 Go 为主的社区驱动项目——WinDivert 是正确选择。
---
### Windows 三种技术路线对比
综合 SocksCap64、Netch、Wintun+WFP、WinDivert 的分析,Windows 上实现单进程透明代理的路径:
| | **SocksCap64** | **Netch** | **Wintun+WFP** | **WinDivert** ⭐ |
|---|---|---|---|---|
| **技术** | Inline Hook + DLL 注入 | netfilter2 商业驱动 | WFP Callout 驱动 + Wintun | WinDivert 开源驱动 |
| **拦截层** | 用户态(函数 hook) | 内核态(NDIS/WFP) | 内核态(WFP socket 重定向) | 内核态(WFP 包捕获) |
| **Go 集成** | ❌(需 C 注入 DLL) | ❌(商业驱动 + C API) | ❌(需写 C++ 内核驱动) | ✅(`godivert` Go 库) |
| **开源** | 闭源 | 半开源(驱动商业) | Mullvad 参考(GPLv3) | 开源(GPLv3+MIT) |
| **Admin** | ⚠️ 不必要 | 必须 | 必须 | 一次性驱动安装 |
| **可靠性** | 中低(注入易被拦截) | 高 | 高 | 高 |
| **原始目标保留** | ✅(hook 参数) | ✅(内核提供) | ✅(IP 头自然保留) | ❌(需映射表) |
| **GOST 代码复用** | 低 | 低 | ✅ 100%(TUN+gVisor 零改动) | 中(需新 handler) |
| **开发量** | 高(注入 DLL) | 中高(商业授权) | **极高(10-16周+签名费)** | 中(~500-800行Go) |
| **维护** | 高 | 中高 | **极高(内核跨版本)** | 低(上游维护) |
| **GOST** | 不推荐 | 不推荐(商业驱动) | 技术最优,实践不现实 | ⭐ 唯一可行 |
**结论:WinDivert 仍然是 GOST 在 Windows 上的唯一实际可行方案。** Wintun+WFP 在技术架构上更优(socket 级重定向保留原始目标、100% 复用 GOST TUN+gVisor 代码),但实现代价(自写 C++ 内核驱动 + EV 签名费 + 跨版本兼容维护)与 GOST 的投入规模完全不匹配。
- SocksCap64 路线:Go 不能写注入 DLL,排除
- Netch 路线:netfilter2 是商业闭源驱动,排除
- **Wintun+WFP 路线:技术架构最优,但需 10-16 周 C++ 内核开发 + 持续签名费,对社区项目不现实**
- **WinDivert 路线:技术上不如 WFP 优雅,但是唯一开源+Go 可集成的方案**
---
### WinDivert UDP 可靠性深度分析
WinDivert 对 TCP 的支持相对成熟(有连接的语义、可以可靠地重写 SYN 目标地址、通过 ACK/FIN 跟踪生命周期),但 **UDP 的支持有一系列架构层面的限制**,直接影响 GOST 集成的可行性。
#### 核心矛盾:SOCKET 层 vs NETWORK 层
这是 WinDivert 最根本的问题,源于 WFP 内核框架的设计:
| 层 | 进程 ID 可用 | 包数据正确 | 用途 |
|-----|------------|-----------|------|
| `WINDIVERT_LAYER_SOCKET` | ✅ `processId` 可用 | ❌ 包数据**损坏/丢失** | 仅能获取进程关联,不能用于数据捕获 |
| `WINDIVERT_LAYER_FLOW` | ✅ `processId` 可用 | ❌ 包数据**损坏/丢失** | 同上 |
| `WINDIVERT_LAYER_NETWORK` | ❌ `processId = 0` | ✅ 完整正确的 IP 包 | 可用于数据捕获,但**不能按进程过滤** |
**根因**(来自 WinDivert 作者):WFP 的 NETWORK/IPPACKET 层在内核中**不携带 ProcessId**——这不是 WinDivert 的 bug,而是 WFP 框架的设计限制。SOCKET/FLOW 层有进程信息,但 WinDivert 在这两层捕获的包数据不包含可解析的 IP/TCP/UDP 头。
来源:[basil00/WinDivert#382](https://github.com/basil00/WinDivert/issues/382), [basil00/WinDivert#71](https://github.com/basil00/WinDivert/issues/71)
#### 唯一可行的 workaround:双 Handle + 5-tuple 映射
所有基于 WinDivert 的透明代理工具(Tallow、zapret/winws、ProxyBridge)的通用做法:
```
┌─ Handle 1: SOCKET 层 ────────────────────────┐
│ Filter: "processId != 0" │
│ 目的: 仅用于建立 5-tuple → PID 映射表 │
│ (数据损坏不关心,只要事件到达即可) │
│ │
│ g_pidMap[(proto, srcIP, srcPort, │
│ dstIP, dstPort)] = processId │
└────────────────────────────────────────────────┘
│ 共享映射表 (mutex + cv)
▼
┌─ Handle 2: NETWORK 层 ───────────────────────┐
│ Filter: "outbound && udp" │
│ 目的: 捕获正确的 UDP 包 │
│ 每个包: 提取 5-tuple → 查映射表 → 决定放行/拦截│
└────────────────────────────────────────────────┘
```
**但这个架构有三个不可消除的固有问题:**
**问题 1:竞态条件 (Race Condition)**
NETWORK 层包可能**先于** SOCKET 层事件到达。没有顺序保证。
缓解方案:
- 对未命中映射的包做**排队等待**(典型超时 2-5 秒)
- 超时后如果仍无映射,**丢弃该包**(首包丢失)
- 这意味着 UDP 应用的**第一个数据包可能被静默丢弃**
**问题 2:UDP 是无连接的 — 映射表管理远比 TCP 复杂**
| | TCP | UDP |
|---|---|---|
| 生命周期事件 | SYN → 已连接 → FIN/RST | **无** |
| 映射何时建立 | connect 前即可获得 | 第一个 sendto() 后 |
| 映射何时释放 | close/fin 事件明确可知 | **无法确定**(socket 可能一直复用) |
| 端口复用 | 简单(TIME_WAIT 后不可复用) | 同一个 socket sendto 到**不同目标**可能复用端口 |
| 映射表增长 | 可控(一个连接一条记录) | 不可控(一个 socket 可能有 N 条记录) |
对于 UDP,如果程序创建了一个 socket 并 sendto 到 100 个不同目标,映射表需要 100 条记录。且无法知道程序何时不再向某个目标发送数据。
缓解方案(ProxyBridge 的做法):
- 通过 `GetExtendedUdpTable` 轮询当前 UDP 绑定关系,作为补充
- 设置较短的 TTL(~30s),超时未使用则清除映射
**问题 3:SOCKET 层可能不触发 UDP 事件**
WinDivert SOCKET 层的 `WINDIVERT_EVENT_SOCKET_BIND` 和 `WINDIVERT_EVENT_SOCKET_CONNECT` 事件主要针对 TCP 设计。UDP 的 `bind()`/`connect()`(若存在)触发的事件覆盖率不可靠。对于无连接的 `sendto()`,SOCKET 层可能**完全不产生事件**。
这意味着某些 UDP 应用的 5-tuple → PID 映射**永远无法建立**。
#### WinDivert UDP 已知问题清单
| 问题 | 严重度 | 缓解措施 | 来源 |
|------|--------|----------|------|
| **SOCKET 层包数据损坏** | 🔴 致命 | 双 handle 架构 | [#382](https://github.com/basil00/WinDivert/issues/382) |
| **NETWORK 层无 processId** | 🔴 致命 | 双 handle + 5-tuple 映射 | [#71](https://github.com/basil00/WinDivert/issues/71) |
| **NETWORK 与 SOCKET 层竞态** | 🟠 高 | 包排队 + 超时(首包可能丢) | 通用架构问题 |
| **DHCP/广播 UDP 丢失** | 🟠 高 | Filter 排除广播 IP | [mitmproxy#6902](https://github.com/mitmproxy/mitmproxy/issues/6902) |
| **多播包重复** | 🟡 中 | 跳过 Loopback=1 的包 | [#364](https://github.com/basil00/WinDivert/issues/364) |
| **校验和 offload 冲突(VM)** | 🟡 中 | 禁用网卡 checksum offload | Xen ML |
| **DontFragment 标志干扰** | 🟡 中 | 无已知 fix | [#278](https://github.com/basil00/WinDivert/issues/278) |
| **高负载下队列断开** | 🟡 中 | 保护性队列大小限制 | 文档 |
| **包重排序** | 🟡 中 | 无(WinDivert 本身会引入) | 文档 |
| **PID 过期/无效** | 🟡 中 | TTL 缓存 | SO |
#### 业界工具的实际 UDP 支持情况
| 工具 | UDP 支持 | 实际策略 | 可靠性 |
|------|----------|----------|--------|
| **ProxyBridge** | 声称支持 | 双 handle + GetExtendedUdpTable 轮询 | ⚠️ DNS 单独处理,警告"大多数 SOCKS5 代理不支持 UDP" |
| **Netch ProcessMode** | 声称支持 | netfilter2 商业驱动(内核级 udpCreated/udpSend 回调) | ✅ 高(商业驱动,socket 级拦截) |
| **SocksCap64** | 声称支持 | Hook sendto/recvfrom | ⚠️ 中(用户态 hook,覆盖不全) |
| **Clash Verge/Mihomo** | 部分支持 | TUN 模式(系统级,不走 WinDivert) | ✅ 高(TUN 模式绕过 WinDivert) |
| **Tallow (Transparent Tor)** | ❌ TCP only | 明确只支持 TCP | N/A |
#### 对 GOST 集成的具体影响
如果 GOST 采用 WinDivert 实现 Windows 透明代理,UDP 的可靠性边界如下:
**可以可靠支持的场景:**
- ✅ TCP 所有流量(有连接 + 生命周期清晰 + 映射表可靠)
- ✅ DNS UDP(端口 53,单独处理,不改写目标,仅劫持响应)
- ⚠️ "典型" UDP 应用(使用 connected UDP socket 的应用,如 QUIC/HTTP3)
**不可靠/不支持的场景:**
- ⚠️ 无连接 UDP (sendto 到多目标,映射表爆炸)
- ⚠️ 高频 UDP(游戏、VoIP,包重排序和延迟不可预测)
- ⚠️ 广播/多播 UDP(DHCP、mDNS、SSDP)
- ❌ 依赖精确 `processId` 的 UDP 过滤(5-tuple 映射可能永远无法建立)
**实用建议:** GOST 的 WinDivert handler 应该默认只处理 TCP,UDP 支持作为**实验性功能**(opt-in flag),并明确文档化限制。
#### 与 Linux usertun 方案的对比
| | Linux usertun (user ns) | Windows WinDivert |
|---|---|---|
| TCP | ✅ 内核协议栈 (gVisor) | ✅ 可接受 |
| UDP | ✅ 内核协议栈 (gVisor) | ⚠️ 有限制 |
| 原始目标 | ✅ IP 头保留 | ❌ 需映射(TCP 还算可靠,UDP 不可靠) |
| 进程隔离 | ✅ 完全(独立 netns) | ⚠️ 基于 5-tuple 推断 |
| 实现复杂度 | TUN+gVisor (已有) | 自定义 handler (~500 行) |
这也又一次说明:**Linux usertun 方案在架构上远优于 Windows 上的任何方案**(包括 WinDivert)。
---
### Windows 额外发现:GOST 现有能力
GOST 在 Windows 上已有以下可用的透明代理能力(均需管理员):
1. **`gost -L tun://`** — TUN 模式,通过 Wintun 创建虚拟网卡,系统级透明代理。**无需新代码,已可用。**
2. **`gost -L tap://`** — TAP 模式,通过 OpenVPN TAP 驱动。同上。
3. **`gost -L tun://` + `gost -L tungo://`** — TUN + gVisor netstack,更完整的协议栈支持。
限制:这些是**系统级**透明代理,不是 Linux user namespace 的**单进程**透明代理。
---
### Windows 结论
**可行性:有条件可行。** 在 Windows 上不存在 Linux user namespace 的非特权等价物。实现"透明代理单个程序"需要内核级拦截(WinDivert 或 WFP callout),均需要一次性管理员权限安装驱动。
**推荐路径:**
1. **如果需要管理员权限可接受:** 实现 `windivert` handler(新代码 ~500-800 行),基于 `godivert` + 已有的 proxy chain。**TCP 支持可靠,UDP 应作为实验性 opt-in 功能。**
2. **如果要求零管理员权限:** 不可行。退而求其次的方案是环境变量注入(`HTTP_PROXY`)+ AppContainer 网络限制,仅覆盖尊重代理环境变量的应用。
3. **如果只需系统级透明代理:** GOST 现有 `tun://` listener 在 Windows 上已可用(需管理员运行)。TUN 模式走 gVisor netstack,**TCP/UDP 均可靠**——缺点是系统级而非单进程。
**建议:** Windows 的 WinDivert 方案可以作为 Linux usertun 方案的后续补充(Phase 2),但不作为 #652 的核心交付。Issue #652 应以 Linux user namespace 方案为主。
+206
View File
@@ -0,0 +1,206 @@
# Fix go-gost #754 — SIGHUP reload `bind: address already in use`
## Context
On SIGHUP hot-reload, every service that keeps its address logs
`init: listen tcp :PORT: bind: address already in use` and the reload fails
(issue go-gost/gost#754). Root cause: in `x/config/loader/loader.go`,
`register()` parses+binds **all** new services in a loop (each
`service_parser.ParseService(c)` binds its port at `ln.Init()`) **before**
`registerGroup` closes the old services. So new listeners bind while old
listeners are still open → `EADDRINUSE`. This is a regression from commit
`82e7e50`, which reversed the order (previously close-old-first, then parse)
to preserve old services on a *parse* error.
Only **services** bind ports; chains/hops/limiters/etc. don't, so the bug is
services-only.
**Goal (Fix #1):** separate listener **construction** from **binding** so the
reload can: (1) construct+validate all new services **without** binding
(any error here leaves old services running — preserves `82e7e50`'s property),
(2) close all old services (free ports), (3) bind+create+register the new ones.
## Design
Split `ParseService` into `PrepareService` (construct, no bind) + a
`(*PreparedService).Build()` method (bind + `NewService`). Keep `ParseService`
as a thin wrapper so its two other callers (`x/api/config_service.go`
`createService`/`updateService`) are unaffected — both already avoid
self-collision.
### Critical correctness detail — netns
The tcp listener binds with a **plain** `net.ListenConfig{}` (no self-switch,
`listener/tcp/listener.go:54,59`). For `metadata.netns` services, the bind
currently relies on the caller having switched namespaces first via the
`netns.Set` block at `parse.go:206-231`. Since that block wraps **both**
construction (line 235) **and** the bind (line 244) today, moving `ln.Init()`
into `Build()` would otherwise bind in the wrong (origin) namespace.
Resolution: **move the netns switch into `Build()`**, wrapping `ln.Init()`.
Construction (`rf(listenOpts...)`) only stores options — it is namespace-
independent, so `PrepareService` drops the netns block. Listeners that
self-switch (`xnet.ListenConfig`, `internal/net/net.go:51`) are unaffected
(a redundant same-namespace re-switch is harmless). Capture `netnsIn` on the
`PreparedService`.
## File-by-file changes
### 1. `x/config/parsing/service/parse.go`
- Add exported type:
```go
// PreparedService is a service fully constructed (listener+handler built,
// options assembled, handler.Init'd) but whose listener is NOT yet bound.
// Build binds the listener and returns a runnable service.Service.
type PreparedService struct {
name string
ln listener.Listener
listenerMD metadata.Metadata
h handler.Handler
netns string
log logger.Logger
options []xservice.Option
}
```
- Add `func PrepareService(cfg *config.ServiceConfig) (*PreparedService, error)`:
the current `ParseService` body with these changes:
- **Remove** the netns block (current lines 206-231) — no longer needed at
construction.
- Construct listener (factory lookup + `rf(listenOpts...)`) as today.
- Capture `listenerMD := metadata.NewMetadata(cfg.Listener.Metadata)` instead
of calling `ln.Init`.
- **Reorder** handler construction (current lines 249-348: handler TLS/auth/
recorders/routerOpts/handler factory/forwarder/`h.Init()`) to run **before**
the bind. Safe: none of it depends on a bound listener. This also fixes a
latent leak where a handler error after `ln.Init` orphans a bound socket.
- Assemble the `[]xservice.Option` slice (current lines 350-362) here while
locals are in scope.
- Return `&PreparedService{...}` (store `netnsIn`).
- Add `func (p *PreparedService) Build() (service.Service, error)`:
- If `p.netns != ""`: `runtime.LockOSThread()` + `netns.Get`/`GetFromPath`|
`GetFromName`/`Set` with `defer netns.Set(originNs)` (mirror old 206-231).
- `p.ln.Init(p.listenerMD)`; on error log `"init: "`, then
`if c, ok := p.h.(io.Closer); ok { c.Close() }` (avoid orphaned handler
goroutines), return `nil, err`.
- `s := xservice.NewService(p.name, p.ln, p.h, p.options...)`; return `s`.
- Refactor `ParseService` to:
```go
func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
ps, err := PrepareService(cfg)
if err != nil { return nil, err }
if ps == nil { return nil, nil }
return ps.Build()
}
```
- Add `"io"` import (used in `Build` error path). `netns`, `runtime`,
`strings`, `metadata`, `xservice` already imported.
### 2. `x/config/loader/loader.go`
Replace the services block in `register()` (lines 304-318) with a three-phase
sequence (services are now special-cased away from the generic
`registerGroup` full-swap):
```go
// --- services (special: only group whose Init binds a port) ---
// Phase 1: construct+validate all new services WITHOUT binding; on any
// error return early (old services untouched).
// Phase 2: close all old services (free ports).
// Phase 3: bind+create+register each prepared service.
{
type preparedEntry struct {
name string
ps *service_parser.PreparedService
}
var prepared []preparedEntry
for _, c := range cfg.Services {
ps, err := service_parser.PrepareService(c)
if err != nil {
return err
}
if ps != nil {
prepared = append(prepared, preparedEntry{c.Name, ps})
}
}
registerGroup[service.Service](nil, registry.ServiceRegistry()) // Phase 2: close-all
for _, e := range prepared { // Phase 3
svc, err := e.ps.Build()
if err != nil {
logger.Default().Errorf("service %s: %v", e.name, err) // log+continue
continue
}
if err := registry.ServiceRegistry().Register(e.name, svc); err != nil {
svc.Close()
logger.Default().Errorf("service %s: %v", e.name, err)
}
}
}
```
Phase 3 partial-failure policy is **log+continue** (aborting would leave all
not-yet-built services down with old already closed). `registerGroup(nil, r)`
is the existing "clear the group" idiom (covered by
`TestRegisterGroup_EmptyEntries_ClearsExisting`). No new imports needed.
### 3. `x/config/loader/loader_test.go`
Add a port-binding stub + reload test that reproduces EADDRINUSE pre-fix:
- `bindingStubListener` (distinct from inert `stubListener`): `Init` does
`net.Listen("tcp", addr)`, stores the `net.Listener`; `Addr`/`Close` delegate.
- `freeTCPPort(t)` helper: `net.Listen("tcp","127.0.0.1:0")`, read port, close.
- Register the binding factory under a test-only name (e.g. `"tcp-binding-test"`)
and the existing inert `"auto"` handler stub, with save/restore `t.Cleanup`.
- `TestRegister_ServiceReloadNoCollision`: build a config with one service on a
fixed free port; call `register()` twice; assert the **second** call returns
no error and the service stays registered. Before the fix the second call
returns `EADDRINUSE`. Assert via `strings.Contains(err.Error(), "address
already in use")` on failure.
- Add `"fmt"`, `"strings"` imports.
## Reused / referenced (no edits)
- `x/registry/registry.go:83` `Unregister` → calls `Close()` (frees ports in
Phase 2). `registerGroup` (loader.go:101) used as close-all idiom.
- `x/service/service.go:144` `NewService` (inert re: sockets; runs pre-up,
sets state) and `:344` `Close()` (closes listener/handler/observer).
- `internal/net/net.go:51` `xnet.ListenConfig` self-switches netns (context
for why moving the netns block to `Build` is safe).
## Verification
```bash
cd /config/workspace/go-gost/x
go build ./... # refactor compiles
go vet ./...
go test ./config/loader/... # existing + new test
CGO_ENABLED=1 go test -race ./config/loader/... # race-clean (per CLAUDE.md)
cd /config/workspace/go-gost && go build ./... # gost binary still builds
```
The new test must pass after the change and would fail (`EADDRINUSE` on the
second `register()`) if the construct/bind split were reverted.
Optional manual end-to-end: run gost with one TCP service on a fixed port,
`kill -HUP <pid>`, confirm no `address already in use` in the log and the
service keeps serving.
## Risks / notes
- **netns**: resolved by moving the netns switch into `Build` (see Design).
During implementation, grep to confirm no listener binds in its *constructor*
(factories should only store options); if one does, it would need separate
handling. The 4 checked (tcp/udp/tls/ws) bind in `Init`.
- **Bind failure = one service down**: after close-all, a single bind failure
takes only that service down (log+continue); unavoidable given the fix's
premise. A joined-error return is a possible follow-up.
- **Pre-existing, orthogonal**: `x/api/config_reload.go` spawns fresh `Serve`
goroutines for every registered service on each reload — not caused or
changed by this fix; flagged so it isn't mistaken for a regression.
## Post-implementation (out of plan scope)
After the code lands + builds + tests pass: optionally post the drafted
explanatory comment to issue #754 and/or open a PR (English comment; show to
user before posting per global GitHub-comments rule).
+203
View File
@@ -0,0 +1,203 @@
# Plan: QUIC/HTTP3 SNI sniffer (issue go-gost/gost#761, Scope A)
## Context
Issue go-gost/gost#761 requests a QUIC/HTTP3 protocol sniffer, referencing the v2fly and Xray
implementations. GOST already sniffs **TCP** traffic (TLS/HTTP/SSH) via
`x/internal/util/sniffing/` to extract the SNI/Host for **bypass decisions and traffic recording**.
QUIC (HTTP/3) runs over **UDP** and is encrypted from the first packet, so it cannot flow through
the existing TCP `Sniff(*bufio.Reader)` path — there is currently **no UDP sniffing anywhere** in
the codebase (`grep udp` in `sniffing/`+`forwarder/` → nothing).
This plan adds **Scope A only**: sniff the QUIC Initial packet to extract the SNI (server_name) for
SNI-based bypass and recording, then forward datagrams transparently. **No MITM/termination** of
QUIC (that is Scope B — a far larger effort, intentionally excluded). The goal matches exactly what
Xray/v2fly do and what GOST's existing TLS sniffer achieves for TCP.
Outcome: a transparent UDP redirect (`redu`) service can apply bypass rules and record QUIC flows by
hostname, closing the HTTP/3 gap.
## Approach
The QUIC Initial parser is extracted into a **separate repo `quic-dissector/`** (parallel to
[`tls-dissector/`](../../../tls-dissector/)), following the same pattern: zero/minimal dependencies,
clean I/O boundary (bytes in → parsed info out), independently versioned, reusable by projects
outside GOST. The `x/` module consumes it as a standard Go dependency, just like it does
`tls-dissector`.
Three layers, lowest-risk-first.
### Phase 1 — `quic-dissector/` repo (new, parallel to `tls-dissector/`)
New repo `github.com/go-gost/quic-dissector` at workspace root `quic-dissector/`. Structure:
```
quic-dissector/
├── go.mod module github.com/go-gost/quic-dissector
│ deps: tls-dissector + golang.org/x/crypto
├── dissector.go SniffQUIC — public API
├── dissector_test.go golden-packet tests + fuzz
├── internal/quic/
│ ├── varint.go QUIC varint (~5 lines, avoids quic-go dep)
│ ├── hkdf.go HKDF-Expand-Label per RFC 8446 §7.1 (~30 lines)
│ └── initial.go Header protection removal + AEAD decrypt + CRYPTO reassembly
├── CLAUDE.md
├── README.md
├── LICENSE
└── .github/workflows/ci.yml
```
**Why separate repo** (same rationale as `tls-dissector`):
- Clean boundary: `[]byte` in, `*dissector.ClientHelloInfo` out, no I/O, no state
- Reusable: any Go project doing QUIC inspection needs this (Xray, v2fly, network monitors)
- Independently versionable: QUIC v2 (RFC 9369) has different salt + inverted type bits
- Nearly zero-dependency: only `x/crypto` (ubiquitous) + `tls-dissector` (same org, zero-dep)
- Precedent: `tls-dissector/` already established this pattern in the go-gost ecosystem
**Public API** (`dissector.go`):
```go
package quicdissector
import "github.com/go-gost/tls-dissector"
// ErrNotQUIC is returned when the datagram is not a parsable QUIC Initial packet.
var ErrNotQUIC = errors.New("not a QUIC Initial packet")
// SniffQUIC decrypts a QUIC Initial datagram and parses the embedded TLS ClientHello.
// Returns the ClientHello info (ServerName=SNI, SupportedProtos=ALPN),
// or ErrNotQUIC when the datagram cannot be parsed.
// Only the first datagram is parsed; cross-datagram ClientHello reassembly is out of scope.
func SniffQUIC(datagram []byte) (*dissector.ClientHelloInfo, error)
```
Returning `*dissector.ClientHelloInfo` directly (rather than separate `sni, alpn` strings)
gives callers access to all ClientHello fields (SupportedVersions, CipherSuites, etc.) at
zero extra cost. The `x/` handler extracts `.ServerName` and `.SupportedProtos[0]` for
bypass/recording — same fields the TCP TLS sniffer uses today.
Algorithm (port of Xray `common/protocol/quic/sniff.go`, confirmed against quic-go internals):
1. **Long-Header validation** — bit 7 set (`0x80`), fixed bit set (`0x40`); read 4-byte version;
accept `0x00000001` (QUIC v1) and `0xff00001d` (draft-29). Reject short headers (1-RTT) — not
sniffable.
2. **Packet type** — `(typeByte & 0x30) >> 4 == 0x0` = Initial; skip non-Initial long-header packets.
3. **DCID** (8–20 bytes) — the key material. Add an explicit `dcidLen` range guard Xray omits.
4. **Token** (varint-length, Initial-only) — skip.
5. **Packet length + header boundary** via QUIC varint (self-contained, ~5 lines — avoids pulling in
`github.com/quic-go/quic-go/quicvarint`).
6. **Derive Initial keys** (HMAC-SHA256 HKDF, `golang.org/x/crypto/hkdf` + stdlib `crypto/aes`,
`crypto/cipher`):
- `initialSecret = HKDF-Extract(salt=QUIC_SALT, IKM=DCID)` — v1 salt
`38762cf7f55934b34d179ae6a4c80cadccbb7f0a` (RFC 9001 §5.2).
- `clientSecret = HKDF-Expand-Label(initialSecret, "client in", 32)`.
- `hpKey = HKDF-Expand-Label(clientSecret, "quic hp", 16)`,
`key = …("quic key", 16)`, `iv = …("quic iv", 12)`.
- `HKDF-Expand-Label` per RFC 8446 §7.1 (`"tls13 "+label`, 2B length + 1B label-len + 1B ctx-len).
Self-contained implementation in `internal/quic/hkdf.go`.
7. **Remove header protection** — AES-ECB single-block of the 16-byte sample at offset 4 into the
protected region; XOR low nibble of type byte (→ packet-number length) + up to 4 PN bytes.
8. **AEAD decrypt** — AES-128-GCM (`key`/`iv`), nonce = `iv` XOR truncated packet number, AAD = the
full unmasked header. QUIC Initial protection is always AES-128-GCM regardless of negotiated cipher.
9. **CRYPTO frame reassembly** — within the decrypted payload, iterate frames (PADDING/PING/ACK/
CRYPTO/CONNECTION_CLOSE); assemble `CRYPTO` (type `0x06`) data by offset into a buffer. Handle
coalesced packets within the single datagram (Xray's outer loop).
10. **ClientHello SNI** — prepend a synthetic TLS record header (`0x16,0x03,0x03,<len>`) to the
reassembled handshake bytes and call `dissector.ParseClientHello` (reuse — same parser the TCP
TLS sniffer uses at `sniffer_tls.go:34`), returning the full `*ClientHelloInfo`.
Reuses: `dissector.ParseClientHello`. New deps: `golang.org/x/crypto` (hkdf). **No quic-go
dependency** — varint and HKDF-Expand-Label are self-contained.
**Limitation (documented):** parses only the ClientHello within the **first datagram** (handles
coalescing inside it). Cross-datagram ClientHello reassembly needs connection-state tracking and is
out of scope — note in a code comment. This covers the overwhelming majority of real traffic.
### Phase 2 — redirect/udp (`redu`) handler integration
`x/handler/redirect/udp/` is the clean mirror of `redirect/tcp` (both use `conn.LocalAddr()` as the
TPROXY destination and `xnet.Pipe`). The per-client `net.Conn` from `internal/net/udp/listener.go`
already buffers the first datagram by the time `Handle()` runs (the stateful listener Accepts on the
first datagram), so peeking will not block.
- **`metadata.go`** — add `sniffing bool` and `sniffingTimeout time.Duration`. Keys
`mdutil.GetBool(md, "sniffing")`, `mdutil.GetDuration(md, "sniffing.timeout")` (mirror
`redirect/tcp/metadata.go`). No `mitm.*` fields — Scope A does no MITM.
- **`handler.go` `Handle()`** — after `dstAddr := conn.LocalAddr()`, if `h.md.sniffing`:
1. `conn.SetReadDeadline(now + sniffingTimeout)` (default from `sniffing.DefaultReadTimeout`).
2. Read the first datagram into a buffer (one `Read` returns exactly the buffered datagram).
3. `info, err := quicdissector.SniffQUIC(buf)`. Clear the read deadline.
4. On success: set `ro.Host = net.JoinHostPort(info.ServerName, port)`, `ro.TLS = &xrecorder.TLSRecorderObject{ServerName: info.ServerName, Proto: info.SupportedProtos[0]}` (ALPN, may be empty). Apply SNI-based bypass via `h.options.Bypass.Contains(ctx, "udp", ro.Host, ...)` → `xbypass.ErrBypass` (mirrors `sniffer_tls.go:59`).
5. Dial upstream to `dstAddr` (transparent — destination stays the original IP, SNI is for bypass/recording only), then forward the buffered first datagram + remaining via `xnet.Pipe`, prepending the buffered bytes with `xnet.NewReadWriteConn(io.MultiReader(bytes.NewReader(buf), conn), conn, conn)` (exact pattern from `redirect/tcp/handler.go:207`).
6. On `quicdissector.ErrNotQUIC` / decode failure: fall through to the existing transparent-forward path, prepending the already-read datagram the same way so no data is lost.
`forward/local` UDP sniffing is a **secondary, optional** extension (its sniffing is currently gated
`network == "tcp"`). Defer unless requested — `redirect/udp` is the primary, cleanest target.
### Phase 3 — protocol constant + integration
- **`sniff.go`** — add `ProtoQUIC = "quic"` constant for recording consistency (the TCP `Sniff()`
function itself is unchanged — it is TCP-only by design; QUIC has its own datagram entry point).
- **`handler/redirect/udp`** — build is the verification gate (no tests in this module per
`x/CLAUDE.md`); a `play/` YAML for manual end-to-end verification (below).
- **`quic-dissector/` tests** — golden-packet table tests (`SniffQUIC` against captured QUIC Initial
datagrams as hex literals with known SNI), plus negative cases (short header, non-Initial, truncated,
bogus version). Fuzz test (`FuzzSniffQUIC`) for panic/DoS resistance. Mirror the fixture style in
`tls-dissector/dissector_test.go`. Capture golden packets at implementation time via a one-off
quic-go client or `openssl` to a test endpoint; commit as hex.
## Files to add / modify
### New repo: `quic-dissector/`
| File | Change |
|------|--------|
| `quic-dissector/go.mod` | **NEW** — module `github.com/go-gost/quic-dissector`, deps: `tls-dissector` + `golang.org/x/crypto` |
| `quic-dissector/dissector.go` | **NEW** — `SniffQUIC` public API |
| `quic-dissector/dissector_test.go` | **NEW** — golden-packet unit tests + fuzz |
| `quic-dissector/internal/quic/varint.go` | **NEW** — QUIC varint (~5 lines) |
| `quic-dissector/internal/quic/hkdf.go` | **NEW** — HKDF-Expand-Label per RFC 8446 §7.1 (~30 lines) |
| `quic-dissector/internal/quic/initial.go` | **NEW** — header protection removal + AEAD decrypt + CRYPTO reassembly |
| `quic-dissector/CLAUDE.md` | **NEW** — build/test instructions |
| `quic-dissector/.github/workflows/ci.yml` | **NEW** — CI (vet + test + fuzz loop, mirror tls-dissector) |
### Existing repo: `x/`
| File | Change |
|------|--------|
| `x/go.mod` | add `github.com/go-gost/quic-dissector v0.1.0` |
| `x/internal/util/sniffing/sniff.go` | add `ProtoQUIC` constant |
| `x/handler/redirect/udp/metadata.go` | add `sniffing`, `sniffingTimeout` fields + parse keys |
| `x/handler/redirect/udp/handler.go` | sniff-first-datagram branch (bypass + recording + prepend) |
| `play/quic-sniff.yaml` (or similar) | manual e2e config (TPROXY UDP → QUIC upstream w/ bypass rule) |
Reused, existing code (do not duplicate): `dissector.ParseClientHello` (`tls-dissector/dissector.go`),
`sniffing.DefaultReadTimeout`, `xnet.NewReadWriteConn`/`Pipe`,
`xrecorder.TLSRecorderObject`, `xbypass.ErrBypass`, `mdutil.GetBool/GetDuration`.
## Verification
1. **`quic-dissector/` build + test** (own CI, mirror `tls-dissector`):
`cd quic-dissector && go build ./... && go vet ./... && go test -v -cover ./...`
Golden packets must round-trip the expected SNI; negative cases must return `ErrNotQUIC` without
false positives. Fuzz test runs 30s in CI.
2. **`x/` build + vet** (per `x/CLAUDE.md`, the module's verification path):
`cd x && go build ./... && go vet ./...`
Confirms the `quic-dissector` import compiles and the handler integration is correct.
3. **Manual e2e** (transparent UDP redirect): run the `play/` config with `sniffing: true` and a
bypass rule on a known QUIC hostname; `curl --http3 https://<host>` through the redirect and
confirm (a) the bypass rule fires for the SNI, (b) non-bypassed QUIC still reaches the upstream,
(c) recorder logs show `ro.TLS.ServerName` populated.
## Notes / scope boundaries
- **Scope A only** — sniff SNI + transparent forward. No QUIC MITM/termination (Scope B).
- **QUIC v1 + draft-29** initially (matches proven Xray code). QUIC **v2** (`0x6b3343cf`, different
salt + inverted type bits, RFC 9369) is a small follow-up enhancement — noted in a code comment.
- **Single-datagram** ClientHello parsing; cross-datagram reassembly out of scope (documented).
- **Separate repo** (`quic-dissector/`) — follows the `tls-dissector/` pattern. The parser is
self-contained, independently versioned, and reusable by projects outside GOST. The `x/` module
consumes it as a standard Go dependency.
- **No quic-go dependency** — varint and HKDF-Expand-Label are self-contained implementations
(~35 lines total). Only external dep is `golang.org/x/crypto` (hkdf).
- Comments in **English only** (project convention). Doc comments on all exported symbols.
@@ -0,0 +1,129 @@
# go-gost/gost#765 — IPv6 TPROXY UDP fails through SOCKS5 with `relay=udp`
**Status**: Open, `NeedsInvestigation`
**Reported**: 2025-08-19
**Reporter**: @przemyslaw0
## Summary
When GOST uses TPROXY (`redu` listener) to intercept IPv6 UDP traffic and forwards it through a SOCKS5 proxy chain with `relay=udp`, responses never come back — `outputBytes: 0` for all sessions, DNS queries time out.
## Reproduction
```
gost -L "redu://:1111?netns=ns-a" -F "socks5://127.0.0.1:1080?relay=udp"
```
TPROXY-intercepted IPv6 DNS packets (`[2606:4700:4700::1001]:53`) forwarded through 3proxy SOCKS5 server. DNS queries time out with no response data.
## Root Cause
The primary bug is in `relayUDP()` at `x/connector/socks/v5/connector.go:216`. When `relay=udp` is used (standard SOCKS5 UDP ASSOCIATE per RFC 1928), GOST does not handle the server's relay bind address correctly.
### Bug: Wildcard relay address not substituted
When 3proxy returns `0.0.0.0:0` as the relay endpoint (visible in 3proxy logs: `UDPMAP 0.0.0.0:0`), GOST dials directly to this literal address:
```go
// connector.go:216
cc, err := opts.Dialer.Dial(ctx, "udp", reply.Addr.String())
```
This creates a UDP socket "connected" to `0.0.0.0:0`, which is not a usable destination. Per RFC 1928 practice, when the SOCKS5 server returns a wildcard address (`0.0.0.0` or `[::]`), the client should substitute it with the TCP connection's remote address (the SOCKS5 server's actual address).
**Compare with GOST's own SOCKS5 server** in `x/handler/socks/v5/udp.go:63`, which correctly substitutes the wildcard:
```go
saddr.Host, _, _ = net.SplitHostPort(conn.LocalAddr().String())
```
The client-side `relayUDP()` does NOT perform this substitution — it uses `reply.Addr` verbatim.
### Additional concern: earlier IPv6 address corruption
In an earlier test (2024-07-11, [3proxy/3proxy#560](https://github.com/3proxy/3proxy/issues/560#issuecomment-2224053601)), the user noted "The IPv6 address received by 3proxy from GOST is received incomplete." The address `2606:4700:4700::1001` arrived as `4700::1001:0:0:53` — a 2-byte left shift of the raw bytes, suggesting possible SOCKS5 UDP header encoding/parsing issues.
In the later test (2025-08-19, the go-gost issue), the address in the 3proxy log appears correct (`2606:4700:4700::1001:53`), though it's ambiguous whether the `:53` is the port or part of the address. This may have been fixed in a GOST version update, or the corruption was a 3proxy log formatting issue.
## Evidence from Logs
**GOST log** (session lifecycle):
- Receives TPROXY UDP from `[fd00::2]:59871` → `[2606:4700:4700::1001]:53`
- `src: 127.0.0.1:42587` — the outbound relay socket's local address (IPv4, expected)
- `inputBytes: 40, outputBytes: 0` — sent 40-byte DNS query, received 0 bytes back
- `duration: 30s` — timed out
**3proxy log**:
- `127.0.0.1:53888 2606:4700:4700::1001:53 40 0 0 UDPMAP 0.0.0.0:0`
- Received 40 bytes from client, returned 0 bytes to client
- Relay bound to `0.0.0.0:0` (wildcard)
## Data Flow Trace
```
dig @2606:4700:4700::1001
│
▼ (TPROXY intercepts IPv6 UDP)
redu listener (::1:1111)
│ conn.LocalAddr() = [2606:4700:4700::1001]:53
│ conn.RemoteAddr() = [fd00::2]:59871
▼
handler.redirect/udp → Router.Dial(ctx, "udp", "[2606:4700:4700::1001]:53")
│
▼ chain: socks5://127.0.0.1:1080?relay=udp
TCP connect to 127.0.0.1:1080
│ SOCKS5 handshake
▼
socks5Connector.relayUDP()
│ sends CmdUdp (0x03) with nil address
│ receives reply: bind = 0.0.0.0:0 ← WILDCARD
▼
opts.Dialer.Dial(ctx, "udp", "0.0.0.0:0") ← BUG: dials to wildcard
│ creates IPv4 UDP socket connected to 0.0.0.0:0
▼
udpRelayConn.WriteTo(data, [2606:4700:4700::1001]:53)
│ wraps in SOCKS5 UDP header (correct IPv6 ATYP=4)
│ sends via c.udpConn.Write() → to 0.0.0.0:0 ← goes nowhere useful
▼
outputBytes: 0 — no response received
```
## Key Files
| File | Role |
|------|------|
| `x/connector/socks/v5/connector.go` | `relayUDP()` — the buggy method (line 195-234) |
| `x/connector/socks/v5/conn.go` | `udpRelayConn` — SOCKS5 UDP encapsulation (ReadFrom/WriteTo) |
| `x/handler/socks/v5/udp.go` | GOST's own SOCKS5 server `handleUDP()` — has correct wildcard substitution |
| `gosocks5/socks5.go` | SOCKS5 protocol: `Addr`, `UDPHeader`, `UDPDatagram` encoding |
| `x/handler/redirect/udp/handler.go` | Redu handler — TPROXY UDP forwarding |
| `x/listener/redirect/udp/listener_linux.go` | Redu listener — Linux TPROXY socket handling |
## Proposed Fix
In `relayUDP()` at `x/connector/socks/v5/connector.go`, after receiving the SOCKS5 UDP ASSOCIATE reply (line 213), substitute wildcard addresses:
```go
// After reply is read and verified
bindAddr := reply.Addr
// Per RFC 1928: if server returns 0.0.0.0 or [::], substitute
// with the TCP connection's remote address (the SOCKS5 server).
if bindAddr.Host == "0.0.0.0" || bindAddr.Host == "::" {
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
bindAddr.Host = host
}
log.Debugf("bind on: %v", bindAddr)
cc, err := opts.Dialer.Dial(ctx, "udp", bindAddr.String())
```
## Workaround
Use GOST's proprietary UDP tunnel mode (default, without `relay=udp`), which tunnels SOCKS5-encapsulated UDP datagrams over the existing TCP connection. This avoids the separate UDP relay socket entirely:
```
gost -L "redu://:1111?netns=ns-a" -F "socks5://127.0.0.1:1080"
```
**Caveat**: This uses GOST's proprietary `CmdUDPTun` (0xF3), which 3proxy does NOT support. For 3proxy specifically, the `relay=udp` fix is required.

Some files were not shown because too many files have changed in this diff Show More