Compare commits

..
Author SHA1 Message Date
ginuerzh 11220b15bb chore(gost): bump github.com/go-gost/x to v0.19.5 2026-10-03 23:38:53 +08:00
ginuerzh d7656ef3de tests/e2e: concurrent UDP endpoints must share a reverse tunnel intact
Covers gost#911 at the wire level: four UDP source sockets send through one
RUDP reverse tunnel bound on a relay server, and every datagram must come
back byte-identical to its own endpoint.

A frame torn by an interleaved writer shows up twice — as a corrupted
payload, and as the torn frame leaving half a header in the stream, which the
far end reads as "unexpected EOF" and answers by rebuilding the whole tunnel.
So the suite asserts both: no endpoint sees a corrupted datagram, and no
endpoint sees a receive gap long enough to be a teardown and rebind.

Verified against a binary built before the fix (4 endpoints, 35% loss, 1.4s
gaps, suite fails) and after it (0% loss, no corruption, 0.2s gaps, passes).
2026-10-03 16:27:05 +08:00
iBug e9fab95872 Add back SystemCallFilter=@chown (#907)
Otherwise lumberjack will fail to rotate log files

Ref: https://github.com/natefinch/lumberjack/pull/43/changes#diff-7a80b3353ee34c4b79751e1e47e5010af54e82f393aa112770a4d1a731e70ba2R480
2026-09-22 22:06:55 +08:00
ginuerzh 97bb57230c docs: document DEB/RPM installation
Covers the amd64v3 vs amd64 choice — both packages declare Architecture:
amd64, so the file name is the only way to tell them apart — and the fact
that the packaged systemd service stays inactive until /etc/gost/gost.yml
is created.
2026-09-21 21:23:33 +08:00
ginuerzh bb45b12809 fix(release): use DynamicUser for gost.service
systemd-analyze verify warns "Special user nobody configured, this is not
safe!": nobody is a shared account, so any other service running as it can
read and write /run/gost. DynamicUser= gives the unit its own transient
identity, named gost, without adding a static user to the package.

Verified under a real systemd: the unit starts, binds :80 through the
ambient capabilities, runs as the dynamic user, and is skipped rather than
failed when /etc/gost/gost.yml is absent.
2026-09-21 21:15:33 +08:00
ginuerzh c4e789e2df fix(release): pin capability bounding set in gost.service
CapabilityBoundingSet= was dropped when CAP_NET_ADMIN was added for TUN,
which leaves the bounding set unrestricted. Pin it to the capabilities the
unit actually needs. Restore ~@privileged as well: TUN uses ioctl(TUNSETIFF)
and netlink, neither of which is in that group.
2026-09-21 21:03:43 +08:00
iBug 2a6ba4df85 Add DEB and RPM packages for easier installation (#906)
* Add systemd service file

* Add DEB and RPM packages for amd64, amd64v3 and arm64 architectures

* Remove GOMEMLIMIT from systemd service

* Fix packaged systemd service defaults
2026-09-21 21:01:26 +08:00
ginuerzh ece7770ad7 chore(gost): bump github.com/go-gost/x to v0.17.2 2026-09-13 21:12:53 +08:00
ginuerzh eb66804ec8 chore(gost): bump github.com/go-gost/x to v0.17.1 2026-09-12 12:12:22 +08:00
ginuerzh a20999046b chore(gost): bump github.com/go-gost/x to v0.17.0 2026-09-12 10:16:16 +08:00
Igor Kuzmenkov fe0982b2e4 fix: update go-m1cpu for macOS arm64 (#903) 2026-09-12 10:06:30 +08:00
ginuerzh 36cc266257 chore: anchor gost gitignore rule to repo root
The bare 'gost' pattern also matched the cmd/gost/ directory, forcing
-f to stage tracked files under it.
2026-09-07 17:46:21 +08:00
ginuerzh 074af1b175 program: shut down plugin subprocesses on stop 2026-09-07 17:42:44 +08:00
ginuerzh ee61f28688 test(e2e): add QUIC cipherKey invalid-datagram regression
Add an e2e suite covering go-gost/x#125: a malformed UDP datagram sent
to a cipherKey-enabled QUIC listener must not close the shared transport.
Uses the canonical http-over-quic chaining pattern and verifies the
forward still works after the invalid datagram.

Also bumps go.mod/go.sum (plugin v0.6.1, otel, x/net, etc.).
2026-09-07 14:53:36 +08:00
ginuerzh 27ac16439e bump to v3.3.1, x v0.16.1 2026-08-31 22:54:26 +08:00
ginuerzh d778c031eb test(e2e): transparent whitelist bypass + sniffing (gost#899)
Reproduce the regression where a domain-only whitelist bypass on a red
(transparent) proxy rejected every connection: the pre-sniffing bypass
check ran against the bare destination IP, which is never in a domain
whitelist, before SNI sniffing could match. A privileged container
redirects outbound TCP to the red service; asserts an allowlisted SNI
is forwarded (hello-gost) and a non-allowlisted SNI is rejected.
2026-08-31 22:35:03 +08:00
ginuerzh cb76f63754 e2e: add PROXY protocol test (gost#677), bump core/x deps
ProxyProtoSuite starts gost with metadata.proxyProtocol set and asserts
the HAProxy PROXY header is prepended on outbound connections (v1/v2),
backed by a raw-TCP capture script and configs. Add echo754_repro.py
from the hot-reload EADDRINUSE investigation (gost#754).

Bump github.com/go-gost/core to v0.6.1 and x to v0.16.0.
2026-08-29 09:26:33 +08:00
ginuerzh 76467efc59 test(e2e): regression for Gost-Target destination-policy bypass
Adds TestGostTargetPolicyBypass: a service-level bypass blacklists the
echo server; verifies the control request is 403, a checksum-valid
Gost-Target header naming an allowed authority cannot reach the blocked
backend, and a malformed header is fail-closed. Verified to catch the bug
by reverting the fix (exploit returned the backend response).
2026-08-27 20:16:34 +08:00
ginuerzh 35f7cd912c test(e2e): cover origin-form HTTP proxy request (#679)
Send a raw "GET / HTTP/1.1" + Host header through the http proxy,
mirroring nginx proxy_pass. Dumps container logs on failure.
2026-08-27 00:02:33 +08:00
ginuerzhandClaude 5e40415ac9 e2e: demux testcontainers exec stream, fix mtls proxy flags, add rtcp filter test
- Add ExecOutput helper to demultiplex the raw Docker exec stream so test
  output assertions see clean stdout+stderr instead of framed bytes.
- Wait for container readiness via exposed port or "listening on" log line
  when no ports are exposed.
- Use curl --proxy-* TLS options for the mTLS HTTPS-proxy test.
- Relax the http_cache first-request assertion (shared backend counter).
- Add gost#898 rtcp forwarder filter.host e2e test (multi-service one tunnel).
- Bump x to v0.15.7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 22:27:52 +08:00
ginuerzh ecf14b8459 bump x to v0.15.6 2026-08-25 22:18:31 +08:00
ginuerzh 1f14e72e3c bump x to v0.15.5 2026-08-21 21:52:06 +08:00
ginuerzh aa1565c8b2 bump x to v0.15.4 2026-08-18 23:44:32 +08:00
ginuerzh f7b27a2c8b bump x to v0.15.3 2026-08-17 23:09:43 +08:00
ginuerzh c7d793c619 e2e: add cmd probe recovery test (gost#837)
Verifies dead->alive->dead node transition: a node excluded by a failing cmd
probe resumes carrying traffic once the probe flips healthy, without restart.
2026-08-01 23:23:28 +08:00
ginuerzh f25148f2e0 bump x to v0.15.2 (SSH authorized_keys trailing-newline fix, gost#699) 2026-08-01 23:22:22 +08:00
ginuerzh 960f081e53 e2e: add UDP forward QUIC sniffing test
Add TestUDPForwardQUICSniffing: sends a real captured QUIC v1 Initial packet
through the UDP forward handler (with sniffing enabled) and verifies the
datagram is echoed back. Covers the QUIC ClientHello/ServerHello sniff path
in x/handler/redirect/udp and x/handler/forward/local.
2026-07-30 23:24:35 +08:00
ginuerzh 55bb214a94 chore: bump x to v0.15.1, add e2e tests for chainGroup and sniffing
- Bump github.com/go-gost/x v0.15.0 → v0.15.1
- ChainGroupSuite: matcher routing by host, probe dead-chain pre-marking
- SniffingSuite: SOCKS5+HTTPS w/o SNI, debug-only log on empty SNI
- Add RunHTTPSEchoContainer and https_echo.py test helper
2026-07-29 23:06:15 +08:00
ginuerzh 555dacd244 chore: bump x to v0.15.0 (hopGroup, failCodes fix)
Add e2e regression test for http.failCodes selector bug —
FIFO selector + failCodes=429 on first node proves
node-429 is excluded after first 429 response and all
subsequent requests go to node-good.
2026-07-27 22:13:34 +08:00
ginuerzh f63b212434 chore: bump x to v0.14.2, tls-dissector to v0.3.1, quic-go to v0.60.0, webtransport-go to v0.11.1 2026-07-26 23:03:38 +08:00
ginuerzh 64017bf195 chore: bump x to v0.14.1 2026-07-26 20:17:23 +08:00
ginuerzh 5a1a799848 tests/e2e: add HTTP response cache e2e tests 2026-07-26 18:14:03 +08:00
ginuerzh 5204d6285e chore: bump deps (core v0.6.0, x v0.14.0) 2026-07-26 18:12:57 +08:00
ginuerzh 81108ee0a4 chore: bump deps (core v0.5.5, x v0.13.18) 2026-07-25 18:52:45 +08:00
ginuerzh 122dde8385 chore: go get -u all
- x v0.13.15 → v0.13.16
- tls-dissector v0.2.0 → v0.3.0
- golang.org/x/* bump (crypto, net, sys, text, mod, sync, term, tools)
- oTel v1.41.0 → v1.43.0
- grpc v1.79.3 → v1.82.1
- gonum v0.16.0 → v0.17.0
2026-07-24 23:15:01 +08:00
ginuerzh 9ccb007589 chore: bump deps
- x v0.13.14 → v0.13.15 (MetadataFeature, BodyJSON matcher)
- relay v0.6.2 → v0.7.0
- tls-dissector v0.2.0 → v0.3.0
- add tidwall gjson/match/pretty/sjson (indirect, via x)
2026-07-24 22:34:52 +08:00
ginuerzh 5a01aa4fd2 chore: bump x to v0.13.14 (forwarder probe passthrough fix) 2026-07-16 23:34:22 +08:00
ginuerzh 4cdc5588ff chore: bump core to v0.5.4 (node liveness probe types) 2026-07-16 21:34:33 +08:00
ginuerzh 89f9ea124a chore: bump x to v0.13.13 (Tor SOCKS5 resolve, cmd probe) 2026-07-16 21:24:41 +08:00
ginuerzh e9cf3b4d3f e2e: add cmd probe failover test with true/false command nodes 2026-07-16 20:53:30 +08:00
ginuerzh bef00dffef e2e: add node liveness probe tests
TCP probe + FailFilter: dead node pre-marked, all requests succeed.
LowestLatency strategy: two probed nodes, selector picks fastest.
2026-07-16 20:53:30 +08:00
ginuerzh 1c6fadcb1e test(e2e): add routing matcher module e2e test suite
Verify node-level routing matchers via a two-proxy relay: a forward
proxy whose only chain node carries matcher Host(`tcp-echo`) is only
eligible for a matching Host, so the request is relayed upstream to the
echo server; a non-matching Host is excluded (no eligible node) and never
reaches the echo server. Mirrors the resolver/ingress e2e pattern.
2026-07-16 20:53:30 +08:00
ginuerzh 20f1e4a0b9 test(e2e): add resolver module e2e test suite
Verifies that a configured resolver drives the proxy's outbound DNS
resolution. A gost HTTP proxy uses a resolver whose only nameserver is a
test responder answering echo.test with the real echo server IP and
NXDOMAIN for everything else. A request to echo.test is resolved by the
custom resolver and reaches the echo server; an unmapped host fails to
resolve. Adds a parametrized DNS responder script and container helper.
2026-07-16 20:53:30 +08:00
ginuerzh 1fd69ac704 test(e2e): add ingress module e2e test suite
Verifies hostname→endpoint routing at the reverse-proxy tunnel
entrypoint. A public gost runs a tunnel handler with an ingress
mapping example.local→tunnel-UUID and an HTTP entrypoint; an internal
client binds a reverse tunnel forwarding to the echo server. A request
with a mapped Host is routed through the tunnel to the echo server,
while an unmapped Host matches no ingress rule and is rejected.
2026-07-16 20:53:30 +08:00
ginuerzh 9355607ba7 test(e2e): add hosts module e2e test suite
Verify the static hosts mapping (HostMapper) overrides DNS: a mapped
hostname resolves to the configured IP and reaches the echo server,
while an unmapped hostname fails to resolve.
2026-07-16 20:53:30 +08:00
ginuerzh 6746f556b7 test(e2e): add auth module e2e test suite
Verify HTTP proxy authentication for both inline single-user auth and a
named auther with multiple users. Covers valid credentials, wrong
password, missing credentials, and any-user-in-auther acceptance.
2026-07-16 20:53:30 +08:00
ginuerzh 3119cb43f2 test(e2e): add admission module e2e test suite
Verify service-level admission control gating by client source IP, in
both whitelist and blacklist modes. Contrasts a loopback client (curl
inside the gost container) against an external client (curl inside the
echo container) to exercise both admit and deny paths.
2026-07-16 20:53:30 +08:00
ginuerzh c7ea19ec66 test(e2e): add bypass module e2e test suite
Verify both blacklist and whitelist bypass modes: a matching destination
skips the dead chain node and connects directly to the echo server, while a
non-matching destination is forced through the dead node and never reaches it.
2026-07-16 20:53:30 +08:00
ginuerzh b60c87e47f chore: ignore .claude/ session config 2026-07-16 20:53:30 +08:00
ginuerzh 632282436b chore: ignore build artifacts, codegraph index, python cache
.build/, .codegraph/ (generated multi-hundred-MB db), and __pycache__/
are local artifacts that should not be tracked.
2026-07-16 20:53:30 +08:00
ginuerzh fa815bf4a0 test(e2e): add TLS listener rejectUnknownSNI test suite
Covers rejectUnknownSNI with and without a serverNames allow list,
verifying allowed/unknown/empty SNI handshakes via openssl s_client.
2026-07-16 20:53:30 +08:00
ginuerzhandClaude a8bd4a5f16 test(e2e): consolidate selector tests, add round-robin/fifo/backup failover
Merge parallel_selector_test.go into selector_test.go and add e2e coverage
for the round-robin + fail filter, fifo sticky fallback, and backup filter
strategies. Each test drives requests through a hop with one dead node and
asserts the selector marks and skips it so traffic converges on the live node.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 20:53:30 +08:00
iBug 94dcc9c045 chore(release): Make files in tarball owned by root (#889) 2026-07-16 20:53:02 +08:00
ginuerzh 7643e16cca chore: bump go-gost/x to v0.13.12 2026-07-11 22:11:08 +08:00
ginuerzh 64b71bd8a6 test(e2e): add utls dialer regression suite (#888)
Adds an e2e suite (tests/e2e/utls_test.go + testdata/utls/*)
that exercises the utls dialer in a forward-proxy chain:

  curl -> client gost (http proxy :8080)
       -> chain node (http connector + utls dialer)
         -> server gost (http over TLS listener :8443)
           -> tcp-echo

Two cases:

- TestUTLSInsecure: regression for go-gost/gost#887. A utls
  dialer with `secure: false` must still complete the handshake
  (InsecureSkipVerify must be honoured). The old unsafe cast read
  garbage for InsecureSkipVerify and the handshake failed.
- TestUTLSSecureWithCA: exercises the converter's RootCAs /
  ServerName path with a CA-signed server cert.

Uses the deterministic `Chrome` fingerprint (not `randomized`, which
randomises the ClientHello and is flaky against a standard Go TLS
server).

Bumps github.com/go-gost/x to v0.13.11, which fixes the
second half of #887: the curve-preference enum divergence between
crypto/tls and utls (Go 1.24+ appends PQC hybrid curves that
utls does not define).

Related: go-gost/gost#887, go-gost/x#111, go-gost/x#112
2026-07-11 15:46:39 +08:00
ginuerzh ccf2989bc0 gost: bump x to v0.13.9, relay to v0.6.2; add PHT e2e tests
- go.mod: x v0.13.8 → v0.13.9, relay v0.6.1 → v0.6.2
- tests/e2e/pht_test.go: 3 test cases (basic tunnel, PHTs, heartbeat)
- tests/e2e/testdata/pht/: client/server and client_phts/server_phts YAML configs
2026-07-06 22:10:15 +08:00
ginuerzh d54a896a81 update go.mod 2026-07-05 21:22:15 +08:00
ginuerzh 6b7dbf08fa chore: bump go-gost/x to v0.13.8, plugin to v0.5.0 2026-07-05 21:19:32 +08:00
ginuerzh dd56308b2e test(mtls): add e2e tests for mTLS client cert identity forwarding
Tests verify: mTLS proxy works with valid client cert, mTLS rejects
connections without client cert, HTTP auth plugin receives client_cn,
client_san, client_cert_fingerprint via PeerCert context propagation.
2026-07-04 19:00:44 +08:00
ginuerzh eb9dbb1807 chore: bump core to v0.5.3 and x to v0.13.7 2026-07-03 20:20:26 +08:00
ginuerzh 02dc900686 chore: bump go-gost/x to v0.13.5 2026-07-02 21:09:46 +08:00
ginuerzh bb912edecc chore: bump go-gost/x to v0.13.3 2026-06-30 20:59:00 +08:00
ginuerzh ffb61cc19f chore: bump go-gost/core to v0.5.2, go-gost/x to v0.13.2 2026-06-30 11:05:02 +08:00
ginuerzh a864cbcfe5 chore: bump go-gost/x to v0.13.1 2026-06-28 20:06:03 +08:00
ginuerzh b139ed5968 chore: bump go-gost/core to v0.5.1, go-gost/x to v0.13.0, go-gost/plugin to v0.4.0 2026-06-27 23:05:22 +08:00
ginuerzhandClaude 43724a5c40 test: add http2 handler e2e suite and tighten host-mapper + idle-timeout tests
Add tests/e2e/http2_test.go covering the http2 handler/listener/connector/dialer
end-to-end through the canonical gost-as-client pattern (8 subtests: forward,
auth, bypass, probeResist/metadata, h2 stream multiplexing). Include
testdata/http2/{server,server_auth,server_bypass,server_proberesist,client,
client_auth}.yaml. Note that h2/h2c listeners pair with the tunnel handler
and only http2 listener pairs with handler http2 (reads r/w from metadata).

Tighten TestDNSHostMapper by pointing the handler at an unreachable upstream
so unmapped names fail at the exchanger, confirming the host-mapper path was
the sole reason mapped names resolved. Fix http_idle_timeout.py to speak HTTP
through the CONNECT tunnel rather than a raw ping, matching the echo backend.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 20:15:31 +08:00
ginuerzh 0bb9109c71 feat: add comprehensive forward handler e2e tests
Add TCP/UDP forward handler e2e tests covering basic forwarding, alias
(forward/tcp), sniffing, raw pipe, idleTimeout, multi-node protocol
filtering, sniffing bypass (403), stateful UDP, and stateless UDP.

New files:
- forward_test.go: ForwardSuite with 9 test methods
- testdata/forward/: server configs (tcp, udp, stateless, sniffing,
  bypass, multi-node, idleTimeout)
- scripts/tcp_idle_timeout.py, udp_forward_test.py: Python harnesses
  for in-container idle timeout and UDP echo verification
2026-06-27 13:59:22 +08:00
ginuerzh a0427be086 feat: add comprehensive DNS handler e2e tests
Adds 22 DNS e2e subtests across 8 test methods covering: upstream
resolution (A, AAAA, multi-A), TCP mode, bypass rules, host mapper,
exchange failure (graceful error recovery), rate limiter wiring,
invalid query handling, and DNS over TLS (mode: tls).

Includes two authoritative DNS responders (UDP/TCP), a standalone
DNS query client, and a TLS DNS query script.
2026-06-27 00:21:37 +08:00
ginuerzh 590c6f48bd feat: add file handler e2e tests covering GET, PUT, index, auth, and 404
Adds 5 test methods (7 test cases) for the file handler:
GET existing file, GET nonexistent file, GET index.html, PUT upload,
PUT without permission, and auth (no-auth-401, with-auth-success).

Includes test data files and server YAML configs under
tests/e2e/testdata/file/.
2026-06-26 22:19:13 +08:00
ginuerzh 69b561f944 feat: add comprehensive HTTP e2e tests covering connector, TLS, probeResist, idleTimeout, and UDP relay
Adds 12 new HTTP handler test scenarios: HTTP connector (no-auth, auth, TLS
upstream), CONNECT tunnel (no-sniffing, bypass-403), probeResist (host, web,
file, knock), idleTimeout, and UDP relay over HTTP. Introduces
RunGostContainerWithFiles helper for mounting extra files into test containers.
2026-06-26 22:00:35 +08:00
ginuerzh 3c87a8b96e chore(deps): bump go-gost/x to v0.12.5 2026-06-26 20:21:42 +08:00
ginuerzh c9cfc440ad build(deps): bump go-gost/x from v0.12.3 to v0.12.4 2026-06-25 20:50:53 +08:00
ginuerzh c305f93703 chore(deps): bump go-gost/x to v0.12.3 2026-06-23 21:47:44 +08:00
ginuerzh f638d8081f build(deps): bump go-gost/x from v0.12.1 to v0.12.2
x v0.12.2 adds SO_REUSEPORT support for TCP listeners (#654).
2026-06-22 21:46:46 +08:00
ginuerzh 286c1615fd build(deps): bump go-gost/x from v0.12.0 to v0.12.1 2026-06-21 22:55:44 +08:00
ginuerzh b985e5138c fix: use atomic int for multi-worker exit code and clean up cancellation
- Replace bare int with atomic.Int32 to fix data race on ret
- Replace wg.Add/go/defer wg.Done pattern with wg.Go (Go 1.22+)
- Pass context by value instead of pointer
- Add ctx.Err() guard to suppress fatal on cancellation errors
- Add ProcessState nil check before accessing ExitCode
- Move cancel() after wg.Wait() to avoid race where a successful
  worker's deferred cancel kills siblings and triggers log.Fatal
2026-06-21 21:07:37 +08:00
ginuerzh b628475871 fix: enable metrics before loading services
Move xmetrics.Enable(true) from run() to Start(), before loader.Load(),
so that listener wrappers can observe the enabled state at Init time
rather than after wrapper decisions have already been made.
2026-06-21 20:35:19 +08:00
ginuerzh 43dd0a6958 feat: register stdio listener for SSH ProxyCommand support
Adds blank import of x/listener/stdio to register the stdio listener.

Refs go-gost/gost#433
2026-06-21 15:40:41 +08:00
ginuerzh 9d00ad8c8f build(deps): bump go-gost/x from v0.11.1 to v0.12.0 2026-06-20 22:15:57 +08:00
ginuerzh 1bebce0ed7 feat: register utls dialer for TLS ClientHello fingerprint simulation
Blank-import x/dialer/utls to enable the "utls" dialer type via
init() self-registration.

Refs go-gost/gost#31
2026-06-20 20:17:08 +08:00
ginuerzh 1178c4757f feat(cmd): add -R auto-reload flag for periodic config refresh
Introduce -R <duration> flag (e.g., -R 30s, -R 1m) that triggers
periodic config reload. Combined with -C URL support, this enables
centralized fleet management where nodes poll a remote config server.

The reload goroutine reuses the existing SIGHUP reloadConfig path:
parser.Parse() → loader.Load() → p.run(cfg). When -R is zero
(default) behavior is unchanged; SIGHUP still works independently.
2026-06-20 20:12:11 +08:00
ginuerzh 73069f50e3 feat: support multiple -C config files
Change -C flag from string to stringList, allowing:
  gost -C base.yml -C services.yml -C auth.yml

Closes go-gost/gost#150
2026-06-20 18:56:25 +08:00
ginuerzh 06ec0097f9 ci: harden docker/release/nightly GitHub Actions workflows
- buildx: adopt docker/metadata-action for image tags (replaces hand-rolled shell), add gha build cache, concurrency control, explicit checkout, and least-privilege permissions

- release: add explicit permissions and concurrency (no cancel-in-flight); drop dead UPX install step (disabled in .goreleaser.yaml, see #863)

- trigger-nightly: add concurrency, replace archived dev-drprasad/delete-older-releases with native gh, and harden the 24h commit check
2026-06-19 19:11:25 +08:00
ginuerzh 396b0977bd chore: bump x to v0.11.1 2026-06-19 09:29:14 +08:00
Yuan Tong 604dccc7b0 feat: register runix listener (#871) 2026-06-19 09:27:06 +08:00
ginuerzh 6d80e06ab3 chore: bump x to v0.11.0 2026-06-17 22:32:51 +08:00
ginuerzh e2447ce578 fix(e2e): use host network for Docker image builds in DinD environments
Docker-in-Docker containers cannot reach the internet via the default
bridge network, causing 'apk add' in the Dockerfile to hang indefinitely
and e2e tests to timeout after 10 minutes.

Add BuildOptionsModifier with NetworkMode=host to all FromDockerfile
definitions so the build step uses the host's network stack. This has
no negative impact on non-DinD environments where bridge networking
already works.

Also move GostBinPath flag and init() from main_test.go to utils.go
(non-test file) so the symbol is accessible across the package.
2026-06-06 20:34:24 +08:00
ginuerzh 811420e923 chore: bump x to include stateless UDP forwarding (#853) 2026-06-05 23:27:22 +08:00
ginuerzh 374b46dfe1 bump x to v0.10.10 2026-06-03 23:30:01 +08:00
ginuerzh 22edb92084 bump x to v0.10.10 2026-06-03 23:25:54 +08:00
ginuerzh 78a0a8c734 fix: disable UPX compression in Dockerfile to resolve #863 startup regression 2026-06-03 00:13:58 +08:00
ZHAO Jin-Xiang 41e1878ebc feat: add nftables package to Dockerfile (#865)
nftables is a more modern iptables which supports both ipv4 and ipv6.
2026-06-02 23:56:53 +08:00
ginuerzh 5639c90e98 bump x to v0.10.9 2026-06-02 23:54:13 +08:00
ginuerzh 36abf9bcd9 bump x to v0.10.8 2026-06-02 20:07:48 +08:00
ginuerzh d584b7ac61 bump x to v0.10.7 2026-05-31 22:49:10 +08:00
ginuerzh 56e2a1c496 fix: disable UPX compression to resolve #863 startup regression
UPX --best/--lzma/--brute adds ~3s startup time on low-spec systems
(linux/arm) due to in-memory decompression on every invocation of gost -V.
Disabled by default; opt in via GORELEASER_UPX=true for release builds
that need it.
2026-05-31 22:25:57 +08:00
ginuerzh b0bea19275 bump x to v0.10.6 2026-05-31 22:13:42 +08:00
ginuerzh cd64f2edd3 bump x to v0.10.5 2026-05-31 19:50:49 +08:00
ginuerzh 5f04c84e32 bump core to v0.4.1 2026-05-31 19:40:28 +08:00
ginuerzh 600a64e611 bump x to v0.10.4 2026-05-31 17:47:52 +08:00
ginuerzh 3d6b16686b bump x to v0.10.3 2026-05-29 00:29:26 +08:00
ginuerzh 44684d40c3 add CLAUDE.md with build, CLI, and lifecycle documentation 2026-05-23 00:00:56 +08:00
ginuerzh 2be36abe75 bump x to v0.10.1, go-shadowsocks2 to v0.1.3; add gost binary to .gitignore 2026-05-22 23:21:30 +08:00
ginuerzh 8db62785fa Merge pull request #862: Add e2e test cases for shadowsocks
Add comprehensive e2e tests covering TCP and UDP shadowsocks
connections with various cipher modes (aes-128-gcm, aes-256-gcm,
chacha20, 2022-blake3-aes-128, 2022-blake3-aes-256, including
multi-PSK variants).
2026-05-22 23:09:42 +08:00
RMT 8740e6f258 Add e2e test cases for shadowsocks 2026-05-22 14:28:17 +08:00
ginuerzh d4c9ef5056 add masque connector, dialer, and handler registrations; update x to v0.10.0 2026-05-21 23:03:49 +08:00
RMT c06eb0d331 WIP 2026-04-26 22:18:07 +08:00
RMT c8b48dc248 WIP 2026-04-26 22:18:07 +08:00
azoway 45d94cf391 Update install.sh
在alpine服务器上运行安装脚本报错:
curl: (3) URL rejected: Malformed input to a URL function

排查后发现,当前版本部分会匹配出来2个版本的下载链接:
gost_3.2.6_linux_amd64.tar.gz
gost_3.2.6_linux_amd64v3.tar.gz

修改后默认取第一个兼容性更好的版本
2026-04-26 22:16:06 +08:00
nivesh bc96fe3918 streamline download/install process
(get binary only, eliminate unnecessary intermediate files)
2026-04-22 00:25:59 +08:00
nivesh 646d3f906c enhance portability (detect user-preferred bash, replace all greps with awk) 2026-04-22 00:25:59 +08:00
nivesh 4c69940f1e Fix #835 2026-04-22 00:25:59 +08:00
David Manouchehri 3d1f6fcbbb add masque connector, dialer, and handler 2026-04-22 00:23:53 +08:00
ginuerzh e388426ec6 go1.26 2026-04-21 23:59:31 +08:00
ginuerzh 340ba32ef0 v3.2.6 2025-11-22 22:47:02 +08:00
ginuerzh 96551d5fa5 metrics: fix server conn wrapper (#797) 2025-10-11 22:07:41 +08:00
ginuerzh 8d05a6ed93 add multiple entrypoints for tunnel 2025-10-09 22:33:31 +08:00
ginuerzh 0348a16aa9 fix panic for channel close (#779) 2025-09-20 10:06:15 +08:00
ginuerzh 50934e0978 fix sniffing for websocket 2025-09-04 21:32:34 +08:00
ginuerzh c2ed9c6f07 add service option for plugin 2025-08-29 23:38:29 +08:00
ginuerzh 3b9da4e260 go1.25 2025-08-21 22:53:28 +08:00
ginuerzh f0a67a1108 fix bugs 2025-08-21 22:24:42 +08:00
ginuerzh 59c9638ce6 add bypass and admission matcher 2025-08-13 21:27:36 +08:00
ginuerzh 49fa28882f fix issue #760 2025-08-10 19:01:10 +08:00
ginuerzh 08c617b54e v3.2.3 2025-08-09 21:01:25 +08:00
ginuerzh d03b0e2360 v3.2.2 2025-08-05 20:46:21 +08:00
ginuerzh 245d610baf without cancel context 2025-08-05 20:25:24 +08:00
ginuerzh f01f0c9215 fix ws listener 2025-08-05 00:19:51 +08:00
ginuerzh 9b6e9d9cae add context for conn 2025-08-04 19:34:54 +08:00
ginuerzh 1a0e2b06ac add proxyProtocol support for dialer 2025-08-03 15:38:57 +08:00
ginuerzh de8bb498db http2: added non-connect request support #749 2025-07-30 21:44:46 +08:00
ginuerzh 1f735d0649 v3.2.0 2025-07-29 09:15:55 +08:00
ginuerzh d173d167da fix tungo for windows and darwin 2025-07-28 21:21:33 +08:00
ginuerzh b593fdb952 add upx for binary compression 2025-07-26 16:43:49 +08:00
ginuerzh d448628fea add tun2socks (tungo) 2025-07-26 16:43:22 +08:00
ginuerzh 0bfc7f10cd add vtun handle and listener 2025-07-23 22:49:59 +08:00
ginuerzh a24b29c8c2 get real client IP from request 2025-07-15 20:21:52 +08:00
ginuerzh 87af3da1b6 add src field for logger and recorder 2025-07-02 21:49:03 +08:00
ginuerzh dc6fe595e0 read config from stdin #701 2025-06-28 18:20:28 +08:00
ginuerzh db4a0a9259 goreleaser: added linux/loong64 2025-06-28 18:13:45 +08:00
Meng Zhuo 62a097c5e4 enable riscv64 in install.sh 2025-06-27 23:36:31 +08:00
ginuerzh b51cecbbfd add new web APIs 2025-06-25 21:31:22 +08:00
ginuerzh 66a260abd1 fix metrics 2025-06-24 21:23:00 +08:00
ginuerzh 1d3603f9ed Merge pull request #713 from ravitheg/FixSSHVulnerability
Fix ssh vulnerability
2025-05-30 15:21:37 +08:00
ravitheg 952c61c1a2 Update go.sum 2025-05-27 14:09:22 -07:00
ravitheg 6d61ab82db Update go.mod 2025-05-27 14:08:56 -07:00
ginuerzh f61bb2fd72 revert to go1.23 2025-02-18 21:43:03 +08:00
ginuerzh 4c2835db04 go1.24 2025-02-18 21:16:47 +08:00
ginuerzh d662b1fd46 router handler: add cache for sd service 2025-02-09 16:19:40 +08:00
ginuerzh 015923fa5d add entrypoint for router handler 2025-02-08 23:11:18 +08:00
ginuerzh f66fce9d49 feat: add router handler & connector 2025-02-07 15:51:51 +08:00
ginuerzh 982a93eee4 add limiter.scope option for service 2025-01-20 23:27:04 +08:00
ginuerzh 758251d3f9 update go.mod 2025-01-10 21:50:18 +08:00
ginuerzh a1e48e164b update config parsing 2025-01-10 21:41:55 +08:00
ginuerzh 581dd74bbb add config hot reload 2025-01-08 23:16:55 +08:00
ginuerzh 3c7a984aa3 v3.0.0 2024-12-31 20:19:49 +08:00
ginuerzh 2a9b7e7d9b fix panic for stats wrapper 2024-12-27 20:43:42 +08:00
ginuerzh ab2b9714ee add traffic reset for stats observer 2024-12-24 20:13:40 +08:00
ginuerzh 6d38c24d88 handle tls for tunnel entrypoint 2024-12-23 21:35:58 +08:00
ginuerzh f376db2000 fix issue #625 2024-12-18 19:06:59 +08:00
ginuerzh 161d96f582 fix observer for http2 2024-12-16 20:49:41 +08:00
ginuerzh 128c0c3117 update go.sum 2024-12-11 22:31:42 +08:00
ginuerzh 66ce980c36 fix webtransport 2024-12-11 22:28:31 +08:00
ginuerzh 829148d3f8 fix ipv6 port for redirect 2024-12-11 22:05:30 +08:00
ginuerzh c45f148a88 add unix domain socket support for api & metrics services 2024-11-22 22:51:16 +08:00
ginuerzh fdc88af4ac fix http keepalive for forwarding 2024-11-17 16:17:31 +08:00
ginuerzh c41c5bf79a add routing rule for node 2024-11-15 20:30:35 +08:00
ginuerzh b3eeb338fb add custom http response header for reverse proxy 2024-11-07 11:08:38 +08:00
ginuerzh 72f185bbf6 add compression flag option for http request 2024-11-02 20:43:44 +08:00
ginuerzh a7599990a8 update deps 2024-11-01 18:19:23 +08:00
ginuerzh 17ca5600d3 update sample rate for websocket recording 2024-10-31 19:16:12 +08:00
ginuerzh 32ccfe483c fix udp port forwarding 2024-10-22 22:57:18 +08:00
ginuerzh 1d0eb06938 sniffing: fix race condition when sniffing websocket frames 2024-10-20 19:24:40 +08:00
ginuerzh 1fa5067d14 sniffing: full websocket frame recording 2024-10-19 20:41:29 +08:00
ginuerzh 7ddfef85a5 sniffing websocket frame 2024-10-19 19:37:21 +08:00
ginuerzh fa98777ab1 fix handler sniffing 2024-10-16 23:03:27 +08:00
ginuerzh e53486efe0 add more fields for log 2024-10-16 22:17:54 +08:00
ginuerzh 881ebfad5b add ClientAddr for websocket conn 2024-10-16 20:48:07 +08:00
ginuerzh d5bb691b38 tunnel: fix http switching protocols 2024-10-15 19:38:43 +08:00
ginuerzh 24c13b861a update tunnel entrypoint 2024-10-15 18:49:21 +08:00
ginuerzh d6daa8bf56 update sniffer for forward & tunnel handler 2024-10-14 23:10:58 +08:00
ginuerzh d18539ef66 recorder: add input/output traffic stats for handler recorder object 2024-10-11 20:39:29 +08:00
ginuerzh 48b0a690c5 update Dockerfile 2024-10-11 13:10:15 +08:00
ginuerzh a7037a27bf update Dockerfile 2024-10-11 11:28:06 +08:00
ginuerzh ac9960de6d update Dockerfile 2024-10-11 00:48:47 +08:00
ginuerzh 404e099907 update github actions 2024-10-11 00:05:00 +08:00
ginuerzh 94cd590142 add keepalive option for http handler 2024-10-10 22:52:04 +08:00
ginuerzh d3809ec634 add sniffer utility for handler traffic sniffing 2024-10-02 22:53:07 +08:00
ginuerzh efb4da6fa6 add traffic sniffing for handler 2024-09-27 20:54:24 +08:00
ginuerzh 2ec8584e96 add route field for reocrder 2024-09-24 20:25:10 +08:00
ginuerzh f05f5cf862 add tls handshake for recorder 2024-09-20 21:28:09 +08:00
ginuerzh bf3018b5b1 add http body for handler recorder 2024-09-16 19:46:24 +08:00
ginuerzh 073af6163e fix recorder clientIP 2024-09-15 18:38:55 +08:00
ginuerzh ef07551e01 add clientIP for handler recorder object 2024-09-15 18:30:02 +08:00
ginuerzh 8077c039f3 update handler recorder object 2024-09-15 12:14:01 +08:00
ginuerzh 33c8f3595a add recorder for handler 2024-09-14 23:24:17 +08:00
ginuerzh 87bff8cac6 add sniffing.fallback option for red handler 2024-09-04 22:46:03 +08:00
ginuerzh d52fcccf06 add scope parameter for traffic limiter 2024-08-31 09:42:03 +08:00
ginuerzh 18610d4608 fix timeout in router 2024-08-06 21:35:19 +08:00
ginuerzh c1de9e4e72 close udp connection when timeout 2024-08-06 18:35:26 +08:00
ginuerzh 1f7c080043 add riscv64 in buildx 2024-08-01 22:05:15 +08:00
ginuerzh b091f72900 fix issue #546 2024-08-01 20:54:21 +08:00
ginuerzh 8e50e9c085 fix ssu handler port exhaustion 2024-07-31 21:03:59 +08:00
ginuerzh 7a35d25c5c add http body rewrite for forward handler 2024-07-19 20:47:01 +08:00
ginuerzh 8e311e7891 add support for icmpv6 2024-07-15 20:42:30 +08:00
ginuerzh 17fc806c2d fix ipv6 for tproxy 2024-07-11 22:39:15 +08:00
ginuerzh b21fd4a22b fix port range parsing 2024-07-10 23:00:06 +08:00
ginuerzh 3e8a025c45 update go.sum 2024-07-08 22:54:09 +08:00
ginuerzh 0d7a6f8091 add port range support for service 2024-07-08 22:52:01 +08:00
ginuerzh 4a2a2a00de update go.sum 2024-07-04 23:12:01 +08:00
ginuerzh c71e128fec bump deps 2024-07-04 23:09:05 +08:00
ginuerzh 1400b5ff5d remove content-length header for http proxy response 2024-06-25 22:26:11 +08:00
ginuerzh cd4fad8c08 add dialTimeout option for service 2024-06-25 21:44:39 +08:00
ginuerzh 56a68ae06f fix netns for socks5 and relay handler 2024-06-24 21:42:37 +08:00
ginuerzh 5d7100f749 add linux network namespace support for listener and dialer 2024-06-21 23:41:27 +08:00
ginuerzh 12871a4ff3 update README.md 2024-06-18 20:59:46 +08:00
ginuerzh 6130bbc075 update go.mod 2024-06-13 23:29:34 +08:00
ginuerzh 85d4fcaf7d add observePeriod option for observer 2024-06-13 22:45:28 +08:00
ginuerzh 7904e571c1 fix #498 2024-06-11 21:58:48 +08:00
ginuerzh 8253c41de6 fix marker for selector 2024-06-07 22:43:48 +08:00
ginuerzh 2148d10def Merge pull request #488 from cgroschupp/update-mod
remove unnecessary replace and update gost modules
2024-05-31 21:25:47 +08:00
Christian Groschupp 8846c69c05 remove unnecessary replace and update gost modules 2024-05-29 18:40:40 +02:00
ginuerzh 2161b97064 add p2p option for tun handler 2024-04-26 20:58:27 +08:00
ginuerzh e245526b33 update README.md 2024-04-25 22:56:57 +08:00
ginuerzh 60389894c0 update go.mod 2024-04-25 21:50:49 +08:00
dependabot[bot] 9ab142ac99 Bump golang.org/x/net from 0.19.0 to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.19.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.19.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 23:19:10 +08:00
ginuerzh be1b0627d1 Merge pull request #451 from jalalsaberi/master
Add Chinese and English README Button
2024-04-24 23:18:33 +08:00
dependabot[bot] f4115b867d Bump github.com/quic-go/quic-go from 0.40.1 to 0.42.0
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.40.1 to 0.42.0.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Changelog](https://github.com/quic-go/quic-go/blob/master/Changelog.md)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.40.1...v0.42.0)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 23:11:03 +08:00
kLiHz a513417d7e fixup moving to Docker actions 2024-04-24 23:10:14 +08:00
kLiHz 58bd3d062b bump setup-go in workflow 2024-04-24 23:10:14 +08:00
kLiHz d2b79c13a6 move to Docker actions since it is archived 2024-04-24 23:10:14 +08:00
kLiHz fedd7a7b07 fixup bumping ghaction-docker-buildx 2024-04-24 23:10:14 +08:00
kLiHz 27f4b6abeb bump ghaction-docker-buildx in workflow 2024-04-24 23:10:14 +08:00
kLiHz d4aadbbc14 bump delete-older-releases in workflow 2024-04-24 23:10:14 +08:00
kLiHz 4508747635 fix deprecated set-output command in workflow 2024-04-24 23:10:14 +08:00
dependabot[bot] 50844821f8 Bump google.golang.org/protobuf from 1.31.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.31.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 23:09:36 +08:00
Jalal Saberi 29ec7c94ad Add Chinese and English README Button 2024-04-10 19:09:02 +03:30
Jalal Saberi 53d6706b5d Add Chinese and English README Button 2024-04-10 18:53:53 +03:30
ginuerzh 594418db53 added url path rewriting for forwarder node 2024-01-31 23:20:35 +08:00
ginuerzh 72cd708dfe fix tunnel connector selection 2024-01-28 18:57:34 +08:00
ginuerzh b576bb5645 fix tunnel weight 2024-01-28 18:34:44 +08:00
ginuerzh 8c529e3595 add weight for tunnel connector 2024-01-27 23:32:50 +08:00
ginuerzh b75e133719 fix hop http plugin 2024-01-27 21:33:24 +08:00
dependabot[bot] abfbf23842 Bump github.com/quic-go/quic-go from 0.40.0 to 0.40.1
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.40.0 to 0.40.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Changelog](https://github.com/quic-go/quic-go/blob/master/Changelog.md)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.40.0...v0.40.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-18 17:33:31 +08:00
ginuerzh 42d62550bf fix deadlock in websocket client conn 2024-01-12 23:50:27 +08:00
ginuerzh 69ba5864ad fix #390 2024-01-08 21:25:41 +08:00
ginuerzh fdd3eb61c3 add auth CLI support for api & metrics 2024-01-07 19:42:14 +08:00
ginuerzh f68bfdb149 add observer 2024-01-03 20:56:24 +08:00
ginuerzh 9e4bca84f1 fix tls handshake sniffing 2023-12-27 20:08:12 +08:00
ginuerzh 925ff89b78 fix issue #376 2023-12-25 22:11:03 +08:00
ginuerzh ca077d418a fix issue #372 2023-12-20 22:00:23 +08:00
ginuerzh 7264fca4f8 parsing config directly from cmd flag -C 2023-12-19 21:45:34 +08:00
ginuerzh bc37fac037 added logger group for service 2023-12-19 21:44:58 +08:00
ginuerzh dc48c4a3b2 fix auth for file handler 2023-12-16 14:33:52 +08:00
ginuerzh 310614663a check date for nightly trigger 2023-12-02 13:03:16 +08:00
ginuerzh 8f094d86b0 fix issue #338 2023-11-30 19:46:29 +08:00
ginuerzh 638c780acd update go.mod 2023-11-29 21:18:54 +08:00
ginuerzh 1f20f199c0 fix tun config 2023-11-20 20:43:27 +08:00
ginuerzh 8dc6146f79 fix go.mod 2023-11-19 20:03:23 +08:00
ginuerzh ed29b34c68 add logger component 2023-11-19 19:57:02 +08:00
ginuerzh b41aea03da add web api for router 2023-11-19 16:17:01 +08:00
ginuerzh 2c17e4417d add logger option for tun router 2023-11-19 14:41:10 +08:00
ginuerzh ea8ff5f77a add router component 2023-11-19 14:24:48 +08:00
ginuerzh 6762957c65 add traffic limiter for proxy handler 2023-11-18 18:30:04 +08:00
ginuerzh 4c934cd737 add more config options for kcp 2023-11-16 22:19:42 +08:00
ginuerzh 0d494f5ef2 fix #335 2023-11-16 20:39:42 +08:00
ginuerzh bf1bab7d84 fix race condition in relay udp conn 2023-11-14 22:37:33 +08:00
ginuerzh b8383b1076 add range port support for forwarder node 2023-11-14 19:43:57 +08:00
ginuerzh 345da28f01 update ingress interface 2023-11-13 20:42:45 +08:00
ginuerzh 907420d18b add webtransport tunnel 2023-11-10 21:30:20 +08:00
ginuerzh c1437794f8 add path option for hop 2023-11-09 20:36:21 +08:00
ginuerzh c2df5650fb add prefer and only options for resolver 2023-11-08 21:09:08 +08:00
ginuerzh 23506579e7 improve udp listener 2023-11-07 23:11:38 +08:00
ginuerzh 969db8691b update README 2023-11-03 17:48:56 +08:00
ginuerzh 3b0a2f1bae update sd 2023-11-02 22:34:11 +08:00
ginuerzh b2ed4ae9fd update sd 2023-11-02 20:54:59 +08:00
ginuerzh b2784011d0 add service discovery for tunnel 2023-10-31 23:00:32 +08:00
ginuerzh d4e00683c3 add tls options 2023-10-29 10:28:46 +08:00
ginuerzh 471513896d update nightly build schedule 2023-10-28 22:48:59 +08:00
ginuerzh 6967278600 add nightly build 2023-10-28 22:41:14 +08:00
ginuerzh 2898732c20 improve tunnel entrypoint 2023-10-27 22:15:53 +08:00
dependabot[bot] bb0c0450e8 Bump google.golang.org/grpc from 1.58.1 to 1.58.3
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.58.1 to 1.58.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.58.1...v1.58.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-27 22:11:52 +08:00
ginuerzh 6bb7dfea8f fix #315 2023-10-25 23:13:39 +08:00
ginuerzh e27d2bc593 set smux version to 1 2023-10-24 20:49:19 +08:00
ginuerzh 3144d61ecd generate random tunnel ID 2023-10-20 23:57:43 +08:00
ginuerzh e4b68d385a fix race condition 2023-10-19 23:50:27 +08:00
ginuerzh a91300d078 get real client ip for http forwarding 2023-10-18 21:23:38 +08:00
ginuerzh b8785eb71e fix race condition when forwarding http 2023-10-18 19:20:43 +08:00
ginuerzh 67fcd929be fix http traffic forwarding 2023-10-18 14:33:38 +08:00
ginuerzh 48c262bf71 fix websocket for forward handler 2023-10-17 23:26:04 +08:00
ginuerzh 05c35005e9 fix websocket for forward handler 2023-10-17 23:06:02 +08:00
ginuerzh f6112773a1 default to smux version 2 for tunnel 2023-10-17 22:16:28 +08:00
ginuerzh 61002b7c9c add mux config 2023-10-17 21:56:29 +08:00
ginuerzh f8a19e0829 fix http handler for tunnel 2023-10-16 23:56:36 +08:00
ginuerzh b1ace73943 update forward handler 2023-10-16 23:17:51 +08:00
ginuerzh 3d0e993498 fix compile error for serial 2023-10-15 23:57:49 +08:00
ginuerzh 9cbc875965 add tunnel handler and connector 2023-10-15 15:40:49 +08:00
dependabot[bot] 139b934fc6 Bump golang.org/x/net from 0.15.0 to 0.17.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.15.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.15.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-14 21:07:47 +08:00
ginuerzh c1d9228eee add auther for metrics 2023-10-09 21:30:43 +08:00
ginuerzh 2ea7dc250a fix #239 2023-10-08 22:05:39 +08:00
ginuerzh be33046c16 add proxy protocol support for relay tunnel and rtcp 2023-10-08 19:50:12 +08:00
ginuerzh d85b8d487b fix socks5 connector 2023-10-04 16:37:10 +08:00
ginuerzh ab8d77bb5e fix issue #280 2023-09-30 17:55:22 +08:00
ginuerzh 8640596064 go1.21 2023-09-28 21:31:17 +08:00
ginuerzh 8a699e1906 add reload and plugin support for hop 2023-09-28 21:05:44 +08:00
ginuerzh 0d41f5edc8 update go.sum 2023-09-21 21:55:23 +08:00
ginuerzh 87364d2494 add client addr for auth plugin 2023-09-21 20:01:25 +08:00
ginuerzh 6982b8af63 add HTTP based plugin 2023-09-20 22:58:48 +08:00
ginuerzh 0b1540b23a add client ID for plugin service 2023-09-19 22:39:42 +08:00
ginuerzh 1bbeeb7e66 add unix/serial dialer and connector 2023-09-18 23:18:27 +08:00
ginuerzh 5d4db6e377 add recorder for serial handler 2023-09-18 21:13:52 +08:00
ginuerzh 05d06a2718 add recorder for serial handler 2023-09-18 09:43:44 +08:00
ginuerzh 420b36b2f8 update README.md 2023-09-17 00:23:11 +08:00
ginuerzh 3883a4493a add support for unix domain socket 2023-09-16 23:15:49 +08:00
ginuerzh 355aaa7690 fix typos 2023-09-16 21:52:00 +08:00
ginuerzh c73c98974c add support for serial(COM) port redirector 2023-09-16 21:50:54 +08:00
Sam Sesh 98c9cc2821 update 2023-09-15 23:47:01 +08:00
127.0.0.1:443 b48d2c0dbc Update README.md 2023-09-15 23:47:01 +08:00
127.0.0.1:443 b7266f4660 Update README_en.md 2023-09-15 23:47:01 +08:00
Sam Sesh 6883c32157 set 777 per 2023-09-15 23:47:01 +08:00
Sam Sesh f1c5f7598c check root 2023-09-15 23:47:01 +08:00
Sam Sesh c31242efbb add install script 2023-09-15 23:47:01 +08:00
dependabot[bot] 6ee632cee2 Bump github.com/gin-gonic/gin from 1.9.0 to 1.9.1
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.9.0 to 1.9.1.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.9.0...v1.9.1)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-15 23:44:45 +08:00
ginuerzh 8e2060582e add timeout for sniffing 2023-05-21 15:54:57 +08:00
dependabot[bot] 410b4ddf53 Bump github.com/gin-gonic/gin from 1.8.2 to 1.9.0
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.8.2 to 1.9.0.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.8.2...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-10 17:17:53 +08:00
ginuerzh 0b6474d836 increase transport buffer size 2023-05-01 14:06:37 +08:00
ginuerzh 4b6d6e7e7c add token for plugin 2023-04-20 19:07:55 +08:00
ginuerzh b2434dd05b add plugin system 2023-04-18 20:55:04 +08:00
ginuerzh 3d15dedab2 add async option for dns handler 2023-04-14 19:17:10 +08:00
ginuerzh 436864ca92 fix issue #206 2023-04-12 21:32:13 +08:00
ginuerzh 4847f9ee9f add support for windows service 2023-03-31 14:51:08 +08:00
ginuerzh a88b44d01d fix #174: load default CA from ca.pem 2023-03-21 20:25:13 +08:00
ginuerzh 45f1cc2efc fix #188 2023-03-20 18:31:47 +08:00
ginuerzh bfa1530ef6 fix grpc dialer 2023-03-08 00:01:36 +08:00
ginuerzh f02c9810f2 update go.mod 2023-02-26 13:54:17 +08:00
ginuerzh fda6214c12 fix #180: add default server name for tls client config 2023-02-26 13:48:42 +08:00
dependabot[bot] acb8c5bd53 bump golang.org/x/net from 0.5.0 to 0.7.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.5.0 to 0.7.0.
- [Release notes](https://github.com/golang/net/releases)
- [Commits](https://github.com/golang/net/compare/v0.5.0...v0.7.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-26 13:43:20 +08:00
ginuerzh 3f53858c83 update version 2023-02-14 18:57:06 +08:00
ginuerzh a379565412 fix relay connect handler 2023-02-14 18:57:06 +08:00
dependabot[bot] 69089b790f Bump github.com/pion/dtls/v2 from 2.1.5 to 2.2.4
Bumps [github.com/pion/dtls/v2](https://github.com/pion/dtls) from 2.1.5 to 2.2.4.
- [Release notes](https://github.com/pion/dtls/releases)
- [Commits](https://github.com/pion/dtls/compare/v2.1.5...v2.2.4)

---
updated-dependencies:
- dependency-name: github.com/pion/dtls/v2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-14 09:48:22 +08:00
ginuerzh 1c2d9b2a5b relay: fix routing for http traffic from entrypoint 2023-02-12 20:44:50 +08:00
ginuerzh 8d852b62cf relay: add direct routing for tunnel 2023-02-06 21:18:35 +08:00
ginuerzh a80dec0dc1 go1.20 2023-02-04 18:48:20 +08:00
ginuerzh 62ca8ff2de add tunnel option for relay 2023-02-02 23:28:04 +08:00
ginuerzh b8504f7f28 update go.mod 2023-02-02 20:00:12 +08:00
ginuerzh 95a2f0d649 add UDP support for reverse proxy tunnel 2023-02-02 19:20:22 +08:00
ginuerzh d810224e7b add .goreleaser.yaml 2023-01-31 21:24:54 +08:00
ginuerzh db36804bfa add goreleaser github action 2023-01-31 21:24:16 +08:00
ginuerzh ad8af1942d update .gitignore 2023-01-31 19:36:10 +08:00
ginuerzh 3d01f74f7c fix nil route panic 2023-01-31 18:08:50 +08:00
ginuerzh 6b05bcb0ab add auth support for forwarder node 2023-01-31 14:07:20 +08:00
ginuerzh 799c95bfb4 forward non-HTTP traffic based on host 2023-01-30 21:10:59 +08:00
ginuerzh de0a584175 fix http traffic forwarding 2023-01-30 14:40:40 +08:00
ginuerzh 381b1c9bf5 fix http traffic forwarding 2023-01-29 23:33:54 +08:00
ginuerzh ed7d72bfd8 update go.mod 2023-01-29 20:53:33 +08:00
ginuerzh 98aef6ba90 forwarder: add http node settings 2023-01-29 20:33:58 +08:00
ginuerzh dfde6efdeb relay: add private tunnel 2023-01-29 11:57:57 +08:00
ginuerzh 8858598cf0 update go.mod 2023-01-20 10:29:39 +08:00
ginuerzh c765789076 fix race condition in config updating 2023-01-20 10:28:32 +08:00
186 changed files with 10746 additions and 1896 deletions
-89
View File
@@ -1,89 +0,0 @@
# ref: https://docs.docker.com/ci-cd/github-actions/
# https://blog.oddbit.com/post/2020-09-25-building-multi-architecture-im/
name: Docker
on:
push:
branches:
- master
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Prepare
id: prepare
run: |
DOCKER_IMAGE=${{ secrets.DOCKER_IMAGE }}
VERSION=latest
# If this is git tag, use the tag name as a docker tag
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
fi
TAGS="${DOCKER_IMAGE}:${VERSION}"
# If the VERSION looks like a version number, assume that
# this is the most recent version of the image and also
# tag it 'latest'.
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
fi
# Set output parameters.
echo ::set-output name=tags::${TAGS}
echo ::set-output name=docker_image::${DOCKER_IMAGE}
echo ::set-output name=docker_platforms::linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/s390x
# https://github.com/crazy-max/ghaction-docker-buildx
- name: Set up Docker Buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
with:
version: latest
- name: Environment
run: |
echo home=$HOME
echo git_ref=$GITHUB_REF
echo git_sha=$GITHUB_SHA
echo version=${{ steps.prepare.outputs.version }}
echo image=${{ steps.prepare.outputs.docker_image }}
echo platforms=${{ steps.prepare.outputs.docker_platforms }}
echo avail_platforms=${{ steps.buildx.outputs.platforms }}
# https://github.com/actions/checkout
- name: Checkout
uses: actions/checkout@v2
- name: Docker Buildx (no push)
run: |
docker buildx bake \
--set ${{ github.event.repository.name }}.platform=${{ steps.prepare.outputs.docker_platforms }} \
--set ${{ github.event.repository.name }}.output=type=image,push=false \
--set ${{ github.event.repository.name }}.tags="${{ steps.prepare.outputs.tags }}" \
--file docker-compose.yaml
- name: Docker Login
if: success()
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
echo "${DOCKER_PASSWORD}" | docker login --username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Docker Buildx (push)
if: success()
run: |
docker buildx bake \
--set ${{ github.event.repository.name }}.platform=${{ steps.prepare.outputs.docker_platforms }} \
--set ${{ github.event.repository.name }}.output=type=image,push=true \
--set ${{ github.event.repository.name }}.tags="${{ steps.prepare.outputs.tags }}" \
--file docker-compose.yaml
- name: Clear
if: always()
run: |
rm -f ${HOME}/.docker/config.json
+64
View File
@@ -0,0 +1,64 @@
# ref: https://docs.docker.com/ci-cd/github-actions/
# https://blog.oddbit.com/post/2020-09-25-building-multi-architecture-im/
name: docker
on:
push:
branches:
- master
tags:
- 'v*'
permissions:
contents: read
# New pushes cancel an in-progress build for the same ref. Tags are isolated
# by ref, so re-pushing a release tag only de-dupes itself, never another tag.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Auto-generates image tags, replacing the former hand-rolled shell:
# - master push -> :latest
# - clean release tag vX.Y.Z -> :X.Y.Z :X :X.Y :latest
# - nightly tag vX.Y.Z-nightly.<date> -> :X.Y.Z-nightly.<date> (no major/minor/latest)
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_IMAGE }}
flavor: latest=false
tags: |
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')) }}
type=semver,pattern={{version}}
type=semver,pattern={{major}},enable=${{ !contains(github.ref_name, '-') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref_name, '-') }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Buildx and push
uses: docker/build-push-action@v6
with:
platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/s390x,linux/riscv64
push: true
tags: ${{ steps.meta.outputs.tags }}
# Reuse build layers across runs via the GitHub Actions cache.
cache-from: type=gha
cache-to: type=gha,mode=max
+40
View File
@@ -0,0 +1,40 @@
name: goreleaser
on:
push:
# run only against tags
tags:
- 'v*'
permissions:
contents: write
# Never cancel an in-flight release; only de-dupe a re-pushed tag.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
# UPX is disabled in .goreleaser.yaml (see #863); the install step was removed.
- uses: goreleaser/goreleaser-action@v6
with:
# either 'goreleaser' (default) or 'goreleaser-pro':
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Your GoReleaser Pro key, if you are using the 'goreleaser-pro'
# distribution:
# GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
+71
View File
@@ -0,0 +1,71 @@
name: Trigger nightly build
on:
schedule:
# * is a special character in YAML, so you have to quote this string
- cron: '00 15 * * *'
workflow_dispatch:
# Only one nightly run at a time; a new run cancels a stale one.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
check_date:
runs-on: ubuntu-latest
name: Check latest commit
outputs:
should_run: ${{ steps.should_run.outputs.should_run }}
steps:
- uses: actions/checkout@v4
- name: print latest_commit
run: echo ${{ github.sha }}
- id: should_run
name: Skip if no commit in the last 24 hours
if: ${{ github.event_name == 'schedule' }}
run: |
if [ "$(git rev-list --count --after='24 hours' ${{ github.sha }})" -eq 0 ]; then
echo "should_run=false" >> $GITHUB_OUTPUT
fi
trigger-nightly:
needs: check_date
if: ${{ needs.check_date.outputs.should_run != 'false' }}
name: Push tag for nightly build
runs-on: ubuntu-latest
steps:
-
name: 'Checkout'
uses: actions/checkout@v4
with:
token: ${{ secrets.NIGHTLY_BUILD_GH_TOKEN }}
fetch-depth: 0
-
name: 'Push new tag'
run: |
git config user.name "${GITHUB_ACTOR}"
git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
# A previous release was created using a lightweight tag
# git describe by default includes only annotated tags
# git describe --tags includes lightweight tags as well
DESCRIBE=`git tag -l --sort=-v:refname | grep -v nightly | head -n 1`
MAJOR_VERSION=`echo $DESCRIBE | awk '{split($0,a,"."); print a[1]}'`
MINOR_VERSION=`echo $DESCRIBE | awk '{split($0,a,"."); print a[2]}'`
PATCH_VERSION=`echo $DESCRIBE | awk '{split($0,a,"."); print a[3]}'`
PATCH_VERSION="$((${PATCH_VERSION} + 1))"
TAG="${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}-nightly.$(date +'%Y%m%d')"
git tag -a $TAG -m "$TAG: nightly build"
git push origin $TAG
- name: 'Clean up nightly releases'
env:
GH_TOKEN: ${{ secrets.NIGHTLY_BUILD_GH_TOKEN }}
run: |
# Keep the 2 most recent nightly releases; delete the rest and their tags.
# Replaces the archived dev-drprasad/delete-older-releases action.
gh release list --json tagName,createdAt --limit 100 \
--jq '[.[] | select(.tagName | test("nightly"))] | sort_by(.createdAt) | reverse | .[2:] | .[].tagName' \
| while read -r tag; do
gh release delete "$tag" --yes --cleanup-tag || true
done
+16 -2
View File
@@ -32,8 +32,22 @@ _testmain.go
*.bak
cmd/gost/gost
/gost
snap
*.pem
*.yaml
*.txt
/*.yaml
*.txt
dist/
# Build artifacts
.build/
# Codegraph index (generated, multi-hundred-MB db)
.codegraph/
# Python cache
__pycache__/
# Claude session config
.claude/
+119
View File
@@ -0,0 +1,119 @@
version: 2
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
# - go generate ./...
builds:
- id: gost
env:
- CGO_ENABLED=0
main: ./cmd/gost
targets:
- darwin_amd64
- darwin_arm64
- linux_386
- linux_amd64
- linux_amd64_v3
- linux_arm_5
- linux_arm_6
- linux_arm_7
- linux_arm64
- linux_mips_softfloat
- linux_mips_hardfloat
- linux_mipsle_softfloat
- linux_mipsle_hardfloat
- linux_mips64
- linux_mips64le
- linux_s390x
- linux_riscv64
- linux_loong64
- freebsd_386
- freebsd_amd64
- windows_386
- windows_amd64
- windows_amd64_v3
- windows_arm64
- android_arm64
ldflags:
- "-s -w -X 'main.version={{ .Tag }}'"
- id: gost-packages
env:
- CGO_ENABLED=0
main: ./cmd/gost
targets:
- linux_amd64
- linux_amd64_v3
- linux_arm64
ldflags:
- "-s -w -X 'main.version={{ .Tag }}'"
upx:
- # UPX compression. Disabled by default (see #863): upx --best/--lzma/--brute
# adds ~3s startup time on low-spec systems (linux/arm). Release builds can
# opt in via GORELEASER_UPX=true environment variable.
enabled: false
archives:
- ids:
- gost
formats:
- tar.gz
builds_info:
group: root
owner: root
# use zip for windows archives
format_overrides:
- goos: windows
formats:
- zip
nfpms:
- id: packages
package_name: gost
ids:
- gost-packages
vendor: go-gost
homepage: https://gost.run/
maintainer: go-gost contributors
description: GO Simple Tunnel
license: MIT
formats:
- deb
- rpm
bindir: /usr/bin
contents:
- src: gost.service
dst: /usr/lib/systemd/system/gost.service
- src: README.md
dst: /usr/share/doc/gost/README.md
- src: README_en.md
dst: /usr/share/doc/gost/README_en.md
- src: LICENSE
dst: /usr/share/doc/gost/LICENSE
- src: gost.yml
dst: /usr/share/doc/gost/examples/gost.yml
checksum:
name_template: 'checksums.txt'
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
release:
prerelease: auto
mode: keep-existing
# The lines beneath this are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
+66
View File
@@ -0,0 +1,66 @@
# CLAUDE.md — gost/
CLI binary entry point for GOST (GO Simple Tunnel). This module compiles the `gost` command.
## Build & Run
```bash
cd gost && go build ./cmd/gost/...
# Cross-compile
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" ./cmd/gost/...
# Build all platforms
make all
# Run
./gost -L "http://:8080" -F "socks5://:1080"
./gost -C gost.yml
```
## Structure
| File | Purpose |
|------|---------|
| `cmd/gost/main.go` | CLI entry point: flag parsing, multi-worker mode (`--` separator), program launch via `svc.Run` |
| `cmd/gost/program.go` | Service lifecycle (`Init`/`Start`/`Stop`), config loading, API/metrics/profiling servers, SIGHUP reload |
| `cmd/gost/register.go` | Blank imports for all built-in handlers, listeners, dialers, connectors — triggers `init()` registration |
| `cmd/gost/version.go` | Version string (`3.3.0`) |
## CLI Flags
| Flag | Usage |
|------|-------|
| `-L` | Inline service definition (repeatable) |
| `-F` | Inline chain node definition (repeatable) |
| `-C` | Path to config file (YAML/JSON) |
| `-D` / `-DD` | Debug / trace logging |
| `-api` | API service address (e.g. `:8080`) |
| `-metrics` | Prometheus metrics address |
| `-O` | Output merged config as yaml or json, then exit |
| `-V` | Print version and exit |
## Multi-Worker Mode
Arguments separated by ` -- ` spawn multiple worker processes via `exec.CommandContext`. Each worker runs as a child process with `_GOST_ID` set. Any worker's exit cancels all others. This is triggered in `init()` before flag parsing.
## Service Lifecycle
- `Init` → calls `parser.Init()` with all CLI/config inputs
- `Start` → `parser.Parse()` → `loader.Load()` → `p.run()` (starts services, API, metrics, profiling)
- `Stop` → cancels reload context, closes all services
- SIGHUP → `reloadConfig()` re-parses and re-runs without restarting the process
## Key Dependencies
- `github.com/go-gost/core` — interface definitions
- `github.com/go-gost/x` — all implementations, config, registry
- `github.com/judwhite/go-svc` — OS service framework (handles daemon/SIGHUP/SIGTERM)
## Registration Pattern
All components register via `init()` side-effects in their packages. `cmd/gost/register.go` triggers them with blank imports. When adding a new built-in handler/listener/dialer/connector, add a blank import here.
## Tests
Tests are in `tests/e2e/` — integration tests using the built binary. No unit tests. Run with `go test ./tests/e2e/...`.
+22 -13
View File
@@ -1,28 +1,37 @@
FROM --platform=$BUILDPLATFORM golang:1.19-alpine as builder
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.6.1 AS xx
# Convert TARGETPLATFORM to GOARCH format
# https://github.com/tonistiigi/xx
COPY --from=tonistiigi/xx:golang / /
FROM --platform=$BUILDPLATFORM golang:1.26-alpine3.23 AS builder
# UPX compression disabled by default (see #863): upx --best adds ~3s startup
# time on low-spec systems (linux/arm). Builds can opt in by uncommenting the
# upx install line and adding upx --best to the build command below.
# RUN apk add --no-cache upx || echo "upx not found"
COPY --from=xx / /
ARG TARGETPLATFORM
RUN apk add --no-cache musl-dev git gcc
RUN xx-info env
ADD . /src
ENV CGO_ENABLED=0
WORKDIR /src
ENV XX_VERIFY_STATIC=1
ENV GO111MODULE=on
WORKDIR /app
RUN cd cmd/gost && go env && go build
COPY . .
FROM alpine:latest
RUN cd cmd/gost && \
xx-go build -ldflags "-s -w" && \
xx-verify gost
# add iptables for tun/tap
RUN apk add --no-cache iptables
FROM alpine:3.23
# add iptables/nftables for tun/tap
RUN apk add --no-cache iptables nftables
WORKDIR /bin/
COPY --from=builder /src/cmd/gost/gost .
COPY --from=builder /app/cmd/gost/gost .
ENTRYPOINT ["/bin/gost"]
+77 -12
View File
@@ -2,7 +2,7 @@
### GO语言实现的安全隧道
[English README](README_en.md)
[![zh](https://img.shields.io/badge/Chinese%20README-green)](README.md) [![en](https://img.shields.io/badge/English%20README-gray)](README_en.md)
## 功能特性
@@ -10,34 +10,77 @@
- [x] [多级转发链](https://gost.run/concepts/chain/)
- [x] [多协议支持](https://gost.run/tutorials/protocols/overview/)
- [x] [TCP/UDP端口转发](https://gost.run/tutorials/port-forwarding/)
- [x] [反向代理](https://gost.run/tutorials/reverse-proxy/)和[隧道](https://gost.run/tutorials/reverse-proxy-tunnel/)
- [x] [TCP/UDP透明代理](https://gost.run/tutorials/redirect/)
- [x] DNS[解析](https://gost.run/concepts/resolver/)和[代理](https://gost.run/tutorials/dns/)
- [x] [TUN/TAP设备](https://gost.run/tutorials/tuntap/)
- [x] [反向代理](https://gost.run/tutorials/reverse-proxy/)
- [x] [TUN/TAP设备](https://gost.run/tutorials/tuntap/)与[TUN2SOCKS](https://gost.run/tutorials/tungo/)
- [x] [负载均衡](https://gost.run/concepts/selector/)
- [x] [路由控制](https://gost.run/concepts/bypass/)
- [x] [限速限流](https://gost.run/concepts/limiter/)
- [x] [准入控制](https://gost.run/concepts/admission/)
- [x] [动态配置](https://gost.run/tutorials/api/config/)
- [x] [限速限流](https://gost.run/concepts/limiter/)
- [x] [插件系统](https://gost.run/concepts/plugin/)
- [x] [Prometheus监控指标](https://gost.run/tutorials/metrics/)
- [x] [动态配置](https://gost.run/tutorials/api/config/)
- [x] [Web API](https://gost.run/tutorials/api/overview/)
- [ ] Web UI
- [x] [GUI](https://github.com/go-gost/gostctl)/[WebUI](https://github.com/go-gost/gost-ui)
Wiki站点:[https://gost.run](https://gost.run)
## 概览
Telegram讨论群:[https://t.me/gogost](https://t.me/gogost)
![Overview](https://gost.run/images/overview.png)
Google讨论组:[https://groups.google.com/d/forum/go-gost](https://groups.google.com/d/forum/go-gost)
GOST作为隧道有三种主要使用方式。
旧版入口:[v2.gost.run](https://v2.gost.run)
### 正向代理
作为代理服务访问网络,可以组合使用多种协议组成转发链进行转发。
![Proxy](https://gost.run/images/proxy.png)
### 端口转发
将一个服务的端口映射到另外一个服务的端口,同样可以组合使用多种协议组成转发链进行转发。
![Forward](https://gost.run/images/forward.png)
### 反向代理
利用隧道和内网穿透将内网服务暴露到公网访问。
![Reverse Proxy](https://gost.run/images/reverse-proxy.png)
## 下载安装
### 二进制文件
[https://github.com/go-gost/gost/releases](https://github.com/go-gost/gost/releases)
### 系统包(DEB/RPM)
从 [Releases](https://github.com/go-gost/gost/releases) 下载对应架构的安装包(`amd64`、`amd64v3`、`arm64`):
```bash
# Debian/Ubuntu
sudo apt install ./gost_<version>_linux_amd64.deb
# RHEL/Fedora
sudo dnf install ./gost_<version>_linux_amd64.rpm
```
`amd64v3` 包需要支持 AVX2 的 CPU(x86-64-v3),否则请使用 `amd64`。
安装包附带 systemd 服务,但不会自动启用:先创建 `/etc/gost/gost.yml`(示例见 `/usr/share/doc/gost/examples/gost.yml`),再执行 `sudo systemctl enable --now gost`。未提供配置文件时该服务会被跳过,而不是报错。
### 安装脚本
```bash
# 安装最新版本 [https://github.com/go-gost/gost/releases](https://github.com/go-gost/gost/releases)
bash <(curl -fsSL https://github.com/go-gost/gost/raw/master/install.sh) --install
```
```bash
# 选择要安装的版本
bash <(curl -fsSL https://github.com/go-gost/gost/raw/master/install.sh)
```
### 源码编译
```
@@ -52,6 +95,28 @@ go build
docker run --rm gogost/gost -V
```
## 工具
### GUI
[go-gost/gostctl](https://github.com/go-gost/gostctl)
### WebUI
[go-gost/gost-ui](https://github.com/go-gost/gost-ui)
### Shadowsocks Android插件
[xausky/ShadowsocksGostPlugin](https://github.com/xausky/ShadowsocksGostPlugin)
[hamid-nazari/ShadowsocksGostPlugin](https://github.com/hamid-nazari/ShadowsocksGostPlugin)
## 帮助与支持
Wiki站点:[https://gost.run](https://gost.run)
YouTube: [https://www.youtube.com/@gost-tunnel](https://www.youtube.com/@gost-tunnel)
Telegram:[https://t.me/gogost](https://t.me/gogost)
Google讨论组:[https://groups.google.com/d/forum/go-gost](https://groups.google.com/d/forum/go-gost)
旧版入口:[v2.gost.run](https://v2.gost.run)
+78 -11
View File
@@ -2,40 +2,85 @@
### A simple security tunnel written in golang
[![en](https://img.shields.io/badge/English%20README-green)](README_en.md) [![zh](https://img.shields.io/badge/Chinese%20README-gray)](README.md)
## Features
- [x] [Listening on multiple ports](https://gost.run/en/getting-started/quick-start/)
- [x] [Multi-level forwarding chain](https://gost.run/en/concepts/chain/)
- [x] Rich protocol
- [x] [TCP/UDP port forwarding](https://gost.run/en/tutorials/port-forwarding/)
- [x] [Reverse Proxy](https://gost.run/en/tutorials/reverse-proxy/) and [Tunnel](https://gost.run/en/tutorials/reverse-proxy-tunnel/)
- [x] [TCP/UDP transparent proxy](https://gost.run/en/tutorials/redirect/)
- [x] DNS [resolver](https://gost.run/en/concepts/resolver/) and [proxy](https://gost.run/en/tutorials/dns/)
- [x] [TUN/TAP device](https://gost.run/en/tutorials/tuntap/)
- [x] [Reverse Proxy](https://gost.run/en/tutorials/reverse-proxy/)
- [x] [TUN/TAP device](https://gost.run/en/tutorials/tuntap/) and [TUN2SOCKS](https://gost.run/en/tutorials/tungo/)
- [x] [Load balancing](https://gost.run/en/concepts/selector/)
- [x] [Routing control](https://gost.run/en/concepts/bypass/)
- [x] [Bandwidth/Rate Limiter](https://gost.run/en/concepts/limiter/)
- [x] [Admission control](https://gost.run/en/concepts/limiter/)
- [x] [Dynamic configuration](https://gost.run/en/tutorials/api/config/)
- [x] [Bandwidth/Rate Limiter](https://gost.run/en/concepts/limiter/)
- [x] [Plugin System](https://gost.run/en/concepts/plugin/)
- [x] [Prometheus metrics](https://gost.run/en/tutorials/metrics/)
- [x] [Dynamic configuration](https://gost.run/en/tutorials/api/config/)
- [x] [Web API](https://gost.run/en/tutorials/api/overview/)
- [ ] Web UI
- [x] [GUI](https://github.com/go-gost/gostctl)/[WebUI](https://github.com/go-gost/gost-ui)
Wiki: [https://gost.run](https://gost.run/en/)
## Overview
Telegram: [https://t.me/gogost](https://t.me/gogost)
![Overview](https://gost.run/images/overview.png)
Google group: [https://groups.google.com/d/forum/go-gost](https://groups.google.com/d/forum/go-gost)
There are three main ways to use GOST as a tunnel.
Legacy version: [v2.gost.run](https://v2.gost.run/en/)
### Proxy
As a proxy service to access the network, multiple protocols can be used in combination to form a forwarding chain for traffic forwarding.
![Proxy](https://gost.run/images/proxy.png)
### Port Forwarding
Mapping the port of one service to the port of another service, you can also use a combination of multiple protocols to form a forwarding chain for traffic forwarding.
![Forward](https://gost.run/images/forward.png)
### Reverse Proxy
Use tunnel and intranet penetration to expose local services behind NAT or firewall to public network for access.
![Reverse Proxy](https://gost.run/images/reverse-proxy.png)
## Installation
### Binary files
[https://github.com/go-gost/gost/releases](https://github.com/go-gost/gost/releases)
### Packages (DEB/RPM)
Download the package for your architecture from the [releases page](https://github.com/go-gost/gost/releases) (`amd64`, `amd64v3`, `arm64`):
```bash
# Debian/Ubuntu
sudo apt install ./gost_<version>_linux_amd64.deb
# RHEL/Fedora
sudo dnf install ./gost_<version>_linux_amd64.rpm
```
The `amd64v3` package requires an AVX2-capable CPU (x86-64-v3); use `amd64` otherwise.
The packages ship a systemd service, but it is not enabled automatically: create `/etc/gost/gost.yml` first (see `/usr/share/doc/gost/examples/gost.yml`), then run `sudo systemctl enable --now gost`. Without a config file the service is skipped rather than failing.
### install script
```bash
# install latest from [https://github.com/go-gost/gost/releases](https://github.com/go-gost/gost/releases)
bash <(curl -fsSL https://github.com/go-gost/gost/raw/master/install.sh) --install
```
```bash
# select version for install
bash <(curl -fsSL https://github.com/go-gost/gost/raw/master/install.sh)
```
### From source
```
@@ -50,6 +95,28 @@ go build
docker run --rm gogost/gost -V
```
## Tools
### GUI
[go-gost/gostctl](https://github.com/go-gost/gostctl)
### WebUI
[go-gost/gost-ui](https://github.com/go-gost/gost-ui)
### Shadowsocks Android
[xausky/ShadowsocksGostPlugin](https://github.com/xausky/ShadowsocksGostPlugin)
[hamid-nazari/ShadowsocksGostPlugin](https://github.com/hamid-nazari/ShadowsocksGostPlugin)
## Support
Wiki: [https://gost.run](https://gost.run/en/)
YouTube: [https://www.youtube.com/@gost-tunnel](https://www.youtube.com/@gost-tunnel)
Telegram: [https://t.me/gogost](https://t.me/gogost)
Google group: [https://groups.google.com/d/forum/go-gost](https://groups.google.com/d/forum/go-gost)
Legacy version: [v2.gost.run](https://v2.gost.run/en/)
-595
View File
@@ -1,595 +0,0 @@
package main
import (
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
mdutil "github.com/go-gost/core/metadata/util"
"github.com/go-gost/x/config"
"github.com/go-gost/x/limiter/conn"
"github.com/go-gost/x/limiter/traffic"
mdx "github.com/go-gost/x/metadata"
"github.com/go-gost/x/registry"
)
var (
ErrInvalidCmd = errors.New("invalid cmd")
ErrInvalidNode = errors.New("invalid node")
)
type stringList []string
func (l *stringList) String() string {
return fmt.Sprintf("%s", *l)
}
func (l *stringList) Set(value string) error {
*l = append(*l, value)
return nil
}
func buildConfigFromCmd(services, nodes stringList) (*config.Config, error) {
namePrefix := ""
cfg := &config.Config{}
var chain *config.ChainConfig
if len(nodes) > 0 {
chain = &config.ChainConfig{
Name: fmt.Sprintf("%schain-0", namePrefix),
}
cfg.Chains = append(cfg.Chains, chain)
}
for i, node := range nodes {
url, err := normCmd(node)
if err != nil {
return nil, err
}
nodeConfig, err := buildNodeConfig(url)
if err != nil {
return nil, err
}
nodeConfig.Name = fmt.Sprintf("%snode-0", namePrefix)
var nodes []*config.NodeConfig
for _, host := range strings.Split(nodeConfig.Addr, ",") {
if host == "" {
continue
}
nodeCfg := &config.NodeConfig{}
*nodeCfg = *nodeConfig
nodeCfg.Name = fmt.Sprintf("%snode-%d", namePrefix, len(nodes))
nodeCfg.Addr = host
nodes = append(nodes, nodeCfg)
}
mc := nodeConfig.Connector.Metadata
md := mdx.NewMetadata(mc)
hopConfig := &config.HopConfig{
Name: fmt.Sprintf("%shop-%d", namePrefix, i),
Selector: parseSelector(mc),
Nodes: nodes,
}
if v := mdutil.GetString(md, "bypass"); v != "" {
bypassCfg := &config.BypassConfig{
Name: fmt.Sprintf("%sbypass-%d", namePrefix, len(cfg.Bypasses)),
}
if v[0] == '~' {
bypassCfg.Whitelist = true
v = v[1:]
}
for _, s := range strings.Split(v, ",") {
if s == "" {
continue
}
bypassCfg.Matchers = append(bypassCfg.Matchers, s)
}
hopConfig.Bypass = bypassCfg.Name
cfg.Bypasses = append(cfg.Bypasses, bypassCfg)
delete(mc, "bypass")
}
if v := mdutil.GetString(md, "resolver"); v != "" {
resolverCfg := &config.ResolverConfig{
Name: fmt.Sprintf("%sresolver-%d", namePrefix, len(cfg.Resolvers)),
}
for _, rs := range strings.Split(v, ",") {
if rs == "" {
continue
}
resolverCfg.Nameservers = append(
resolverCfg.Nameservers,
&config.NameserverConfig{
Addr: rs,
},
)
}
hopConfig.Resolver = resolverCfg.Name
cfg.Resolvers = append(cfg.Resolvers, resolverCfg)
delete(mc, "resolver")
}
if v := mdutil.GetString(md, "hosts"); v != "" {
hostsCfg := &config.HostsConfig{
Name: fmt.Sprintf("%shosts-%d", namePrefix, len(cfg.Hosts)),
}
for _, s := range strings.Split(v, ",") {
ss := strings.SplitN(s, ":", 2)
if len(ss) != 2 {
continue
}
hostsCfg.Mappings = append(
hostsCfg.Mappings,
&config.HostMappingConfig{
Hostname: ss[0],
IP: ss[1],
},
)
}
hopConfig.Hosts = hostsCfg.Name
cfg.Hosts = append(cfg.Hosts, hostsCfg)
delete(mc, "hosts")
}
if v := mdutil.GetString(md, "interface"); v != "" {
hopConfig.Interface = v
delete(mc, "interface")
}
if v := mdutil.GetInt(md, "so_mark"); v > 0 {
hopConfig.SockOpts = &config.SockOptsConfig{
Mark: v,
}
delete(mc, "so_mark")
}
chain.Hops = append(chain.Hops, hopConfig)
}
for i, svc := range services {
url, err := normCmd(svc)
if err != nil {
return nil, err
}
service, err := buildServiceConfig(url)
if err != nil {
return nil, err
}
service.Name = fmt.Sprintf("%sservice-%d", namePrefix, i)
if chain != nil {
if service.Listener.Type == "rtcp" || service.Listener.Type == "rudp" {
service.Listener.Chain = chain.Name
} else {
service.Handler.Chain = chain.Name
}
}
cfg.Services = append(cfg.Services, service)
mh := service.Handler.Metadata
md := mdx.NewMetadata(mh)
if v := mdutil.GetInt(md, "retries"); v > 0 {
service.Handler.Retries = v
delete(mh, "retries")
}
if v := mdutil.GetString(md, "admission"); v != "" {
admCfg := &config.AdmissionConfig{
Name: fmt.Sprintf("%sadmission-%d", namePrefix, len(cfg.Admissions)),
}
if v[0] == '~' {
admCfg.Whitelist = true
v = v[1:]
}
for _, s := range strings.Split(v, ",") {
if s == "" {
continue
}
admCfg.Matchers = append(admCfg.Matchers, s)
}
service.Admission = admCfg.Name
cfg.Admissions = append(cfg.Admissions, admCfg)
delete(mh, "admission")
}
if v := mdutil.GetString(md, "bypass"); v != "" {
bypassCfg := &config.BypassConfig{
Name: fmt.Sprintf("%sbypass-%d", namePrefix, len(cfg.Bypasses)),
}
if v[0] == '~' {
bypassCfg.Whitelist = true
v = v[1:]
}
for _, s := range strings.Split(v, ",") {
if s == "" {
continue
}
bypassCfg.Matchers = append(bypassCfg.Matchers, s)
}
service.Bypass = bypassCfg.Name
cfg.Bypasses = append(cfg.Bypasses, bypassCfg)
delete(mh, "bypass")
}
if v := mdutil.GetString(md, "resolver"); v != "" {
resolverCfg := &config.ResolverConfig{
Name: fmt.Sprintf("%sresolver-%d", namePrefix, len(cfg.Resolvers)),
}
for _, rs := range strings.Split(v, ",") {
if rs == "" {
continue
}
resolverCfg.Nameservers = append(
resolverCfg.Nameservers,
&config.NameserverConfig{
Addr: rs,
Prefer: mdutil.GetString(md, "prefer"),
},
)
}
service.Resolver = resolverCfg.Name
cfg.Resolvers = append(cfg.Resolvers, resolverCfg)
delete(mh, "resolver")
}
if v := mdutil.GetString(md, "hosts"); v != "" {
hostsCfg := &config.HostsConfig{
Name: fmt.Sprintf("%shosts-%d", namePrefix, len(cfg.Hosts)),
}
for _, s := range strings.Split(v, ",") {
ss := strings.SplitN(s, ":", 2)
if len(ss) != 2 {
continue
}
hostsCfg.Mappings = append(
hostsCfg.Mappings,
&config.HostMappingConfig{
Hostname: ss[0],
IP: ss[1],
},
)
}
service.Hosts = hostsCfg.Name
cfg.Hosts = append(cfg.Hosts, hostsCfg)
delete(mh, "hosts")
}
in := mdutil.GetString(md, "limiter.in")
out := mdutil.GetString(md, "limiter.out")
cin := mdutil.GetString(md, "limiter.conn.in")
cout := mdutil.GetString(md, "limiter.conn.out")
if in != "" || cin != "" || out != "" || cout != "" {
limiter := &config.LimiterConfig{
Name: fmt.Sprintf("%slimiter-%d", namePrefix, len(cfg.Limiters)),
}
if in != "" || out != "" {
limiter.Limits = append(limiter.Limits,
fmt.Sprintf("%s %s %s", traffic.GlobalLimitKey, in, out))
}
if cin != "" || cout != "" {
limiter.Limits = append(limiter.Limits,
fmt.Sprintf("%s %s %s", traffic.ConnLimitKey, cin, cout))
}
service.Limiter = limiter.Name
cfg.Limiters = append(cfg.Limiters, limiter)
delete(mh, "limiter.in")
delete(mh, "limiter.out")
delete(mh, "limiter.conn.in")
delete(mh, "limiter.conn.out")
}
if climit := mdutil.GetInt(md, "climiter"); climit > 0 {
limiter := &config.LimiterConfig{
Name: fmt.Sprintf("%sclimiter-%d", namePrefix, len(cfg.CLimiters)),
Limits: []string{fmt.Sprintf("%s %d", conn.GlobalLimitKey, climit)},
}
service.CLimiter = limiter.Name
cfg.CLimiters = append(cfg.CLimiters, limiter)
delete(mh, "climiter")
}
if rlimit := mdutil.GetFloat(md, "rlimiter"); rlimit > 0 {
limiter := &config.LimiterConfig{
Name: fmt.Sprintf("%srlimiter-%d", namePrefix, len(cfg.RLimiters)),
Limits: []string{fmt.Sprintf("%s %s", conn.GlobalLimitKey, strconv.FormatFloat(rlimit, 'f', -1, 64))},
}
service.RLimiter = limiter.Name
cfg.RLimiters = append(cfg.RLimiters, limiter)
delete(mh, "rlimiter")
}
}
return cfg, nil
}
func buildServiceConfig(url *url.URL) (*config.ServiceConfig, error) {
namePrefix := ""
if v := os.Getenv("_GOST_ID"); v != "" {
namePrefix = fmt.Sprintf("go-%s@", v)
}
var handler, listener string
schemes := strings.Split(url.Scheme, "+")
if len(schemes) == 1 {
handler = schemes[0]
listener = schemes[0]
}
if len(schemes) == 2 {
handler = schemes[0]
listener = schemes[1]
}
svc := &config.ServiceConfig{
Addr: url.Host,
}
if h := registry.HandlerRegistry().Get(handler); h == nil {
handler = "auto"
}
if ln := registry.ListenerRegistry().Get(listener); ln == nil {
listener = "tcp"
if handler == "ssu" {
listener = "udp"
}
}
// forward mode
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" {
svc.Forwarder = &config.ForwarderConfig{
// Targets: strings.Split(remotes, ","),
}
for i, addr := range strings.Split(remotes, ",") {
svc.Forwarder.Nodes = append(svc.Forwarder.Nodes,
&config.ForwardNodeConfig{
Name: fmt.Sprintf("%starget-%d", namePrefix, i),
Addr: addr,
})
}
if handler != "relay" {
if listener == "tcp" || listener == "udp" ||
listener == "rtcp" || listener == "rudp" ||
listener == "tun" || listener == "tap" ||
listener == "dns" {
handler = listener
} else {
handler = "forward"
}
}
}
var auth *config.AuthConfig
if url.User != nil {
auth = &config.AuthConfig{
Username: url.User.Username(),
}
auth.Password, _ = url.User.Password()
}
m := map[string]any{}
for k, v := range url.Query() {
if len(v) > 0 {
m[k] = v[0]
}
}
md := mdx.NewMetadata(m)
if sa := mdutil.GetString(md, "auth"); sa != "" {
au, err := parseAuthFromCmd(sa)
if err != nil {
return nil, err
}
auth = au
}
delete(m, "auth")
tlsConfig := &config.TLSConfig{
CertFile: mdutil.GetString(md, "certFile", "cert"),
KeyFile: mdutil.GetString(md, "keyFile", "key"),
CAFile: mdutil.GetString(md, "caFile", "ca"),
}
delete(m, "certFile")
delete(m, "cert")
delete(m, "keyFile")
delete(m, "key")
delete(m, "caFile")
delete(m, "ca")
if tlsConfig.CertFile == "" {
tlsConfig = nil
}
if v := mdutil.GetString(md, "dns"); v != "" {
md.Set("dns", strings.Split(v, ","))
}
if svc.Forwarder != nil {
svc.Forwarder.Selector = parseSelector(m)
}
svc.Handler = &config.HandlerConfig{
Type: handler,
Auth: auth,
Metadata: m,
}
svc.Listener = &config.ListenerConfig{
Type: listener,
TLS: tlsConfig,
Metadata: m,
}
svc.Metadata = m
if svc.Listener.Type == "ssh" || svc.Listener.Type == "sshd" {
svc.Handler.Auth = nil
svc.Listener.Auth = auth
}
return svc, nil
}
func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
var connector, dialer string
schemes := strings.Split(url.Scheme, "+")
if len(schemes) == 1 {
connector = schemes[0]
dialer = schemes[0]
}
if len(schemes) == 2 {
connector = schemes[0]
dialer = schemes[1]
}
node := &config.NodeConfig{
Addr: url.Host,
}
if c := registry.ConnectorRegistry().Get(connector); c == nil {
connector = "http"
}
if d := registry.DialerRegistry().Get(dialer); d == nil {
dialer = "tcp"
if connector == "ssu" {
dialer = "udp"
}
}
var auth *config.AuthConfig
if url.User != nil {
auth = &config.AuthConfig{
Username: url.User.Username(),
}
auth.Password, _ = url.User.Password()
}
m := map[string]any{}
for k, v := range url.Query() {
if len(v) > 0 {
m[k] = v[0]
}
}
md := mdx.NewMetadata(m)
if sauth := mdutil.GetString(md, "auth"); sauth != "" && auth == nil {
au, err := parseAuthFromCmd(sauth)
if err != nil {
return nil, err
}
auth = au
}
delete(m, "auth")
tlsConfig := &config.TLSConfig{
CertFile: mdutil.GetString(md, "certFile", "cert"),
KeyFile: mdutil.GetString(md, "keyFile", "key"),
CAFile: mdutil.GetString(md, "caFile", "ca"),
Secure: mdutil.GetBool(md, "secure"),
ServerName: mdutil.GetString(md, "serverName"),
}
if tlsConfig.ServerName == "" {
tlsConfig.ServerName = url.Hostname()
}
delete(m, "certFile")
delete(m, "cert")
delete(m, "keyFile")
delete(m, "key")
delete(m, "caFile")
delete(m, "ca")
delete(m, "secure")
delete(m, "serverName")
if !tlsConfig.Secure && tlsConfig.CertFile == "" && tlsConfig.CAFile == "" {
tlsConfig = nil
}
node.Connector = &config.ConnectorConfig{
Type: connector,
Auth: auth,
Metadata: m,
}
node.Dialer = &config.DialerConfig{
Type: dialer,
TLS: tlsConfig,
Metadata: m,
}
if node.Dialer.Type == "ssh" || node.Dialer.Type == "sshd" {
node.Connector.Auth = nil
node.Dialer.Auth = auth
}
return node, nil
}
func normCmd(s string) (*url.URL, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, ErrInvalidCmd
}
if s[0] == ':' || !strings.Contains(s, "://") {
s = "auto://" + s
}
url, err := url.Parse(s)
if err != nil {
return nil, err
}
if url.Scheme == "https" {
url.Scheme = "http+tls"
}
return url, nil
}
func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
v, err := base64.StdEncoding.DecodeString(sa)
if err != nil {
return nil, err
}
cs := string(v)
n := strings.IndexByte(cs, ':')
if n < 0 {
return &config.AuthConfig{
Username: cs,
}, nil
}
return &config.AuthConfig{
Username: cs[:n],
Password: cs[n+1:],
}, nil
}
func parseSelector(m map[string]any) *config.SelectorConfig {
md := mdx.NewMetadata(m)
strategy := mdutil.GetString(md, "strategy")
maxFails := mdutil.GetInt(md, "maxFails", "max_fails")
failTimeout := mdutil.GetDuration(md, "failTimeout", "fail_timeout")
if strategy == "" && maxFails <= 0 && failTimeout <= 0 {
return nil
}
if strategy == "" {
strategy = "round"
}
if maxFails <= 0 {
maxFails = 1
}
if failTimeout <= 0 {
failTimeout = 30 * time.Second
}
delete(m, "strategy")
delete(m, "maxFails")
delete(m, "max_fails")
delete(m, "failTimeout")
delete(m, "fail_timeout")
return &config.SelectorConfig{
Strategy: strategy,
MaxFails: maxFails,
FailTimeout: failTimeout,
}
}
-204
View File
@@ -1,204 +0,0 @@
package main
import (
"io"
"os"
"path/filepath"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
"github.com/go-gost/x/api"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
metrics "github.com/go-gost/x/metrics/service"
"github.com/go-gost/x/registry"
"gopkg.in/natefinch/lumberjack.v2"
)
func buildService(cfg *config.Config) (services []service.Service) {
if cfg == nil {
return
}
for _, autherCfg := range cfg.Authers {
if auther := parsing.ParseAuther(autherCfg); auther != nil {
if err := registry.AutherRegistry().Register(autherCfg.Name, auther); err != nil {
log.Fatal(err)
}
}
}
for _, admissionCfg := range cfg.Admissions {
if adm := parsing.ParseAdmission(admissionCfg); adm != nil {
if err := registry.AdmissionRegistry().Register(admissionCfg.Name, adm); err != nil {
log.Fatal(err)
}
}
}
for _, bypassCfg := range cfg.Bypasses {
if bp := parsing.ParseBypass(bypassCfg); bp != nil {
if err := registry.BypassRegistry().Register(bypassCfg.Name, bp); err != nil {
log.Fatal(err)
}
}
}
for _, resolverCfg := range cfg.Resolvers {
r, err := parsing.ParseResolver(resolverCfg)
if err != nil {
log.Fatal(err)
}
if r != nil {
if err := registry.ResolverRegistry().Register(resolverCfg.Name, r); err != nil {
log.Fatal(err)
}
}
}
for _, hostsCfg := range cfg.Hosts {
if h := parsing.ParseHosts(hostsCfg); h != nil {
if err := registry.HostsRegistry().Register(hostsCfg.Name, h); err != nil {
log.Fatal(err)
}
}
}
for _, ingressCfg := range cfg.Ingresses {
if h := parsing.ParseIngress(ingressCfg); h != nil {
if err := registry.IngressRegistry().Register(ingressCfg.Name, h); err != nil {
log.Fatal(err)
}
}
}
for _, recorderCfg := range cfg.Recorders {
if h := parsing.ParseRecorder(recorderCfg); h != nil {
if err := registry.RecorderRegistry().Register(recorderCfg.Name, h); err != nil {
log.Fatal(err)
}
}
}
for _, limiterCfg := range cfg.Limiters {
if h := parsing.ParseTrafficLimiter(limiterCfg); h != nil {
if err := registry.TrafficLimiterRegistry().Register(limiterCfg.Name, h); err != nil {
log.Fatal(err)
}
}
}
for _, limiterCfg := range cfg.CLimiters {
if h := parsing.ParseConnLimiter(limiterCfg); h != nil {
if err := registry.ConnLimiterRegistry().Register(limiterCfg.Name, h); err != nil {
log.Fatal(err)
}
}
}
for _, limiterCfg := range cfg.RLimiters {
if h := parsing.ParseRateLimiter(limiterCfg); h != nil {
if err := registry.RateLimiterRegistry().Register(limiterCfg.Name, h); err != nil {
log.Fatal(err)
}
}
}
for _, hopCfg := range cfg.Hops {
hop, err := parsing.ParseHop(hopCfg)
if err != nil {
log.Fatal(err)
}
if hop != nil {
if err := registry.HopRegistry().Register(hopCfg.Name, hop); err != nil {
log.Fatal(err)
}
}
}
for _, chainCfg := range cfg.Chains {
c, err := parsing.ParseChain(chainCfg)
if err != nil {
log.Fatal(err)
}
if c != nil {
if err := registry.ChainRegistry().Register(chainCfg.Name, c); err != nil {
log.Fatal(err)
}
}
}
for _, svcCfg := range cfg.Services {
svc, err := parsing.ParseService(svcCfg)
if err != nil {
log.Fatal(err)
}
if svc != nil {
if err := registry.ServiceRegistry().Register(svcCfg.Name, svc); err != nil {
log.Fatal(err)
}
}
services = append(services, svc)
}
return
}
func logFromConfig(cfg *config.LogConfig) logger.Logger {
if cfg == nil {
cfg = &config.LogConfig{}
}
opts := []xlogger.LoggerOption{
xlogger.FormatLoggerOption(logger.LogFormat(cfg.Format)),
xlogger.LevelLoggerOption(logger.LogLevel(cfg.Level)),
}
var out io.Writer = os.Stderr
switch cfg.Output {
case "none", "null":
return xlogger.Nop()
case "stdout":
out = os.Stdout
case "stderr", "":
out = os.Stderr
default:
if cfg.Rotation != nil {
out = &lumberjack.Logger{
Filename: cfg.Output,
MaxSize: cfg.Rotation.MaxSize,
MaxAge: cfg.Rotation.MaxAge,
MaxBackups: cfg.Rotation.MaxBackups,
LocalTime: cfg.Rotation.LocalTime,
Compress: cfg.Rotation.Compress,
}
} else {
os.MkdirAll(filepath.Dir(cfg.Output), 0755)
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Warn(err)
} else {
out = f
}
}
}
opts = append(opts, xlogger.OutputLoggerOption(out))
return xlogger.NewLogger(opts...)
}
func buildAPIService(cfg *config.APIConfig) (service.Service, error) {
auther := parsing.ParseAutherFromAuth(cfg.Auth)
if cfg.Auther != "" {
auther = registry.AutherRegistry().Get(cfg.Auther)
}
return api.NewService(
cfg.Addr,
api.PathPrefixOption(cfg.PathPrefix),
api.AccessLogOption(cfg.AccessLog),
api.AutherOption(auther),
)
}
func buildMetricsService(cfg *config.MetricsConfig) (service.Service, error) {
return metrics.NewService(
cfg.Addr,
metrics.PathOption(cfg.Path),
)
}
+48 -210
View File
@@ -4,73 +4,88 @@ import (
"context"
"flag"
"fmt"
"net/http"
"log"
_ "net/http/pprof"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"sync/atomic"
"time"
"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"
xmetrics "github.com/go-gost/x/metrics"
"github.com/go-gost/x/registry"
"github.com/judwhite/go-svc"
)
var (
log logger.Logger
type stringList []string
cfgFile string
func (l *stringList) String() string {
return fmt.Sprintf("%s", *l)
}
func (l *stringList) Set(value string) error {
*l = append(*l, value)
return nil
}
var (
cfgFiles stringList
outputFormat string
services stringList
nodes stringList
debug bool
trace bool
apiAddr string
metricsAddr string
reload time.Duration
)
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile | log.Lmicroseconds)
args := strings.Join(os.Args[1:], " ")
if strings.Contains(args, " -- ") {
var (
wg sync.WaitGroup
ret int
ret atomic.Int32
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wargsList := strings.Split(" "+args+" ", " -- ")
for wid, wargs := range strings.Split(" "+args+" ", " -- ") {
wg.Add(1)
go func(wid int, wargs string) {
defer wg.Done()
defer cancel()
worker(wid, strings.Split(wargs, " "), &ctx, &ret)
}(wid, strings.TrimSpace(wargs))
ctx, cancel := context.WithCancel(context.Background())
for wid, wargs := range wargsList {
wg.Go(func() {
worker(wid, strings.Split(strings.TrimSpace(wargs), " "), ctx, &ret)
})
}
wg.Wait()
cancel()
os.Exit(ret)
os.Exit(int(ret.Load()))
}
}
func worker(id int, args []string, ctx *context.Context, ret *int) {
cmd := exec.CommandContext(*ctx, os.Args[0], args...)
func worker(id int, args []string, ctx context.Context, ret *atomic.Int32) {
cmd := exec.CommandContext(ctx, os.Args[0], args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), fmt.Sprintf("_GOST_ID=%d", id))
cmd.Run()
if cmd.ProcessState.Exited() {
*ret = cmd.ProcessState.ExitCode()
if err := cmd.Run(); err != nil {
// Context cancellation is expected when one worker exits early.
// Only log fatal on other errors.
if ctx.Err() == nil {
log.Fatal(err)
}
return
}
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
ret.Store(int32(cmd.ProcessState.ExitCode()))
}
}
@@ -79,12 +94,14 @@ func init() {
flag.Var(&services, "L", "service list")
flag.Var(&nodes, "F", "chain node list")
flag.StringVar(&cfgFile, "C", "", "configuration file")
flag.Var(&cfgFiles, "C", "config file(s), URL(s), or inline JSON")
flag.BoolVar(&printVersion, "V", false, "print version")
flag.StringVar(&outputFormat, "O", "", "output format, one of yaml|json format")
flag.BoolVar(&debug, "D", false, "debug mode")
flag.BoolVar(&trace, "DD", false, "trace mode")
flag.StringVar(&apiAddr, "api", "", "api service address")
flag.StringVar(&metricsAddr, "metrics", "", "metrics service address")
flag.DurationVar(&reload, "R", 0, "auto reload period (e.g. 30s, 1m)")
flag.Parse()
if printVersion {
@@ -92,194 +109,15 @@ func init() {
version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
os.Exit(0)
}
log = xlogger.NewLogger()
logger.SetDefault(log)
}
func main() {
cfg := &config.Config{}
if cfgFile != "" {
if err := cfg.ReadFile(cfgFile); err != nil {
log.Fatal(err)
}
}
cmdCfg, err := buildConfigFromCmd(services, nodes)
if err != nil {
log.Fatal(err)
}
cfg = mergeConfig(cfg, cmdCfg)
if len(cfg.Services) == 0 && apiAddr == "" {
if err := cfg.Load(); err != nil {
log.Fatal(err)
}
}
if v := os.Getenv("GOST_PROFILING"); v != "" {
cfg.Profiling = &config.ProfilingConfig{
Addr: v,
}
}
if v := os.Getenv("GOST_METRICS"); v != "" {
cfg.Metrics = &config.MetricsConfig{
Addr: v,
}
}
if v := os.Getenv("GOST_LOGGER_LEVEL"); v != "" {
cfg.Log = &config.LogConfig{
Level: v,
}
}
if v := os.Getenv("GOST_API"); v != "" {
cfg.API = &config.APIConfig{
Addr: v,
}
}
if debug {
if cfg.Log == nil {
cfg.Log = &config.LogConfig{}
}
cfg.Log.Level = string(logger.DebugLevel)
}
if apiAddr != "" {
cfg.API = &config.APIConfig{
Addr: apiAddr,
}
}
if metricsAddr != "" {
cfg.Metrics = &config.MetricsConfig{
Addr: metricsAddr,
}
}
log = logFromConfig(cfg.Log)
log := xlogger.NewLogger()
logger.SetDefault(log)
if outputFormat != "" {
if err := cfg.Write(os.Stdout, outputFormat); err != nil {
log.Fatal(err)
}
os.Exit(0)
}
p := &program{}
if cfg.Profiling != nil {
go func() {
addr := cfg.Profiling.Addr
if addr == "" {
addr = ":6060"
}
log.Info("profiling server on ", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}()
}
if cfg.API != nil {
s, err := buildAPIService(cfg.API)
if err != nil {
log.Fatal(err)
}
defer s.Close()
go func() {
log.Info("api service on ", s.Addr())
log.Fatal(s.Serve())
}()
}
if cfg.Metrics != nil {
xmetrics.Init(xmetrics.NewMetrics())
if cfg.Metrics.Addr != "" {
s, err := buildMetricsService(cfg.Metrics)
if err != nil {
log.Fatal(err)
}
go func() {
defer s.Close()
log.Info("metrics service on ", s.Addr())
log.Fatal(s.Serve())
}()
}
}
parsing.BuildDefaultTLSConfig(cfg.TLS)
services := buildService(cfg)
for _, svc := range services {
svc := svc
go func() {
svc.Serve()
// svc.Close()
}()
}
config.SetGlobal(cfg)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
for sig := range sigs {
switch sig {
case syscall.SIGHUP:
return
default:
for name, srv := range registry.ServiceRegistry().GetAll() {
srv.Close()
log.Debugf("service %s shutdown", name)
}
return
}
if err := svc.Run(p); err != nil {
logger.Default().Fatal(err)
}
}
func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
if cfg1 == nil {
return cfg2
}
if cfg2 == nil {
return cfg1
}
cfg := &config.Config{
Services: append(cfg1.Services, cfg2.Services...),
Chains: append(cfg1.Chains, cfg2.Chains...),
Hops: append(cfg1.Hops, cfg2.Hops...),
Authers: append(cfg1.Authers, cfg2.Authers...),
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
Resolvers: append(cfg1.Resolvers, cfg2.Resolvers...),
Hosts: append(cfg1.Hosts, cfg2.Hosts...),
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
RLimiters: append(cfg1.RLimiters, cfg2.RLimiters...),
TLS: cfg1.TLS,
Log: cfg1.Log,
API: cfg1.API,
Metrics: cfg1.Metrics,
Profiling: cfg1.Profiling,
}
if cfg2.TLS != nil {
cfg.TLS = cfg2.TLS
}
if cfg2.Log != nil {
cfg.Log = cfg2.Log
}
if cfg2.API != nil {
cfg.API = cfg2.API
}
if cfg2.Metrics != nil {
cfg.Metrics = cfg2.Metrics
}
if cfg2.Profiling != nil {
cfg.Profiling = cfg2.Profiling
}
return cfg
}
+296
View File
@@ -0,0 +1,296 @@
package main
import (
"context"
"errors"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
api_service "github.com/go-gost/x/api/service"
xauth "github.com/go-gost/x/auth"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/loader"
auth_parser "github.com/go-gost/x/config/parsing/auth"
"github.com/go-gost/x/config/parsing/parser"
xmetrics "github.com/go-gost/x/metrics"
metrics "github.com/go-gost/x/metrics/service"
xplugin "github.com/go-gost/x/plugin"
"github.com/go-gost/x/registry"
"github.com/judwhite/go-svc"
)
type program struct {
srvApi service.Service
srvMetrics service.Service
srvProfiling *http.Server
cancel context.CancelFunc
}
func (p *program) Init(env svc.Environment) error {
parser.Init(parser.Args{
CfgFiles: cfgFiles,
Services: services,
Nodes: nodes,
Debug: debug,
Trace: trace,
ApiAddr: apiAddr,
MetricsAddr: metricsAddr,
})
return nil
}
func (p *program) Start() error {
cfg, err := parser.Parse()
if err != nil {
return err
}
if outputFormat != "" {
if err := cfg.Write(os.Stdout, outputFormat); err != nil {
return err
}
os.Exit(0)
}
config.Set(cfg)
// Enable metrics before loading services so that listener wrappers
// can observe the enabled state at Init time.
if cfg.Metrics != nil && cfg.Metrics.Addr != "" {
xmetrics.Enable(true)
}
if err := loader.Load(cfg); err != nil {
return err
}
if err := p.run(cfg); err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.reload(ctx)
return nil
}
func (p *program) run(cfg *config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
go func() {
svc.Serve()
}()
}
if p.srvApi != nil {
p.srvApi.Close()
p.srvApi = nil
}
if cfg.API != nil {
s, err := buildApiService(cfg.API)
if err != nil {
return err
}
p.srvApi = s
go func() {
defer s.Close()
log := logger.Default().WithFields(map[string]any{"kind": "service", "service": "@api"})
log.Info("listening on ", s.Addr())
if err := s.Serve(); !errors.Is(err, http.ErrServerClosed) {
log.Error(err)
}
}()
}
if p.srvMetrics != nil {
p.srvMetrics.Close()
p.srvMetrics = nil
}
if cfg.Metrics != nil && cfg.Metrics.Addr != "" {
s, err := buildMetricsService(cfg.Metrics)
if err != nil {
return err
}
p.srvMetrics = s
go func() {
defer s.Close()
log := logger.Default().WithFields(map[string]any{"kind": "service", "service": "@metrics"})
log.Info("listening on ", s.Addr())
if err := s.Serve(); !errors.Is(err, http.ErrServerClosed) {
log.Error(err)
}
}()
}
if p.srvProfiling != nil {
p.srvProfiling.Close()
p.srvProfiling = nil
}
if cfg.Profiling != nil {
addr := cfg.Profiling.Addr
if addr == "" {
addr = ":6060"
}
s := &http.Server{
Addr: addr,
}
p.srvProfiling = s
go func() {
defer s.Close()
log := logger.Default().WithFields(map[string]any{"kind": "service", "service": "@profiling"})
log.Info("listening on ", addr)
if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Error(err)
}
}()
}
return nil
}
func (p *program) Stop() error {
if p.cancel != nil {
p.cancel()
}
for name, srv := range registry.ServiceRegistry().GetAll() {
srv.Close()
logger.Default().Debugf("service %s shutdown", name)
}
if p.srvApi != nil {
p.srvApi.Close()
logger.Default().Debug("service @api shutdown")
}
if p.srvMetrics != nil {
p.srvMetrics.Close()
logger.Default().Debug("service @metrics shutdown")
}
if p.srvProfiling != nil {
p.srvProfiling.Close()
logger.Default().Debug("service @profiling shutdown")
}
xplugin.Shutdown()
return nil
}
func (p *program) reload(ctx context.Context) {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
var ticker <-chan time.Time
if reload > 0 {
t := time.NewTicker(reload)
defer t.Stop()
ticker = t.C
}
for {
select {
case <-c:
if err := p.reloadConfig(); err != nil {
logger.Default().Error(err)
} else {
logger.Default().Info("config reloaded")
}
case <-ticker:
if err := p.reloadConfig(); err != nil {
logger.Default().Errorf("auto reload: %v", err)
} else {
logger.Default().Debug("config auto reloaded")
}
case <-ctx.Done():
return
}
}
}
func (p *program) reloadConfig() error {
cfg, err := parser.Parse()
if err != nil {
return err
}
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
return err
}
if err := p.run(cfg); err != nil {
return err
}
return nil
}
func buildApiService(cfg *config.APIConfig) (service.Service, error) {
var authers []auth.Authenticator
if auther := auth_parser.ParseAutherFromAuth(cfg.Auth); auther != nil {
authers = append(authers, auther)
}
if cfg.Auther != "" {
authers = append(authers, registry.AutherRegistry().Get(cfg.Auther))
}
var auther auth.Authenticator
if len(authers) > 0 {
auther = xauth.AuthenticatorGroup(authers...)
}
network := "tcp"
addr := cfg.Addr
if strings.HasPrefix(addr, "unix://") {
network = "unix"
addr = strings.TrimPrefix(addr, "unix://")
}
return api_service.NewService(
network, addr,
api_service.PathPrefixOption(cfg.PathPrefix),
api_service.AccessLogOption(cfg.AccessLog),
api_service.AutherOption(auther),
)
}
func buildMetricsService(cfg *config.MetricsConfig) (service.Service, error) {
auther := auth_parser.ParseAutherFromAuth(cfg.Auth)
if cfg.Auther != "" {
auther = registry.AutherRegistry().Get(cfg.Auther)
}
network := "tcp"
addr := cfg.Addr
if strings.HasPrefix(addr, "unix://") {
network = "unix"
addr = strings.TrimPrefix(addr, "unix://")
}
return metrics.NewService(
network, addr,
metrics.PathOption(cfg.Path),
metrics.AutherOption(auther),
)
}
+28
View File
@@ -6,13 +6,19 @@ import (
_ "github.com/go-gost/x/connector/forward"
_ "github.com/go-gost/x/connector/http"
_ "github.com/go-gost/x/connector/http2"
_ "github.com/go-gost/x/connector/masque"
_ "github.com/go-gost/x/connector/relay"
_ "github.com/go-gost/x/connector/router"
_ "github.com/go-gost/x/connector/serial"
_ "github.com/go-gost/x/connector/sni"
_ "github.com/go-gost/x/connector/socks/v4"
_ "github.com/go-gost/x/connector/socks/v5"
_ "github.com/go-gost/x/connector/ss"
_ "github.com/go-gost/x/connector/ss/udp"
_ "github.com/go-gost/x/connector/sshd"
_ "github.com/go-gost/x/connector/tcp"
_ "github.com/go-gost/x/connector/tunnel"
_ "github.com/go-gost/x/connector/unix"
// Register dialers
_ "github.com/go-gost/x/dialer/direct"
@@ -22,32 +28,44 @@ import (
_ "github.com/go-gost/x/dialer/http2"
_ "github.com/go-gost/x/dialer/http2/h2"
_ "github.com/go-gost/x/dialer/http3"
_ "github.com/go-gost/x/dialer/http3/masque"
_ "github.com/go-gost/x/dialer/http3/wt"
_ "github.com/go-gost/x/dialer/icmp"
_ "github.com/go-gost/x/dialer/kcp"
_ "github.com/go-gost/x/dialer/mtcp"
_ "github.com/go-gost/x/dialer/mtls"
_ "github.com/go-gost/x/dialer/mws"
_ "github.com/go-gost/x/dialer/obfs/http"
_ "github.com/go-gost/x/dialer/obfs/tls"
_ "github.com/go-gost/x/dialer/pht"
_ "github.com/go-gost/x/dialer/quic"
_ "github.com/go-gost/x/dialer/serial"
_ "github.com/go-gost/x/dialer/ssh"
_ "github.com/go-gost/x/dialer/sshd"
_ "github.com/go-gost/x/dialer/tcp"
_ "github.com/go-gost/x/dialer/tls"
_ "github.com/go-gost/x/dialer/udp"
_ "github.com/go-gost/x/dialer/unix"
_ "github.com/go-gost/x/dialer/utls"
_ "github.com/go-gost/x/dialer/ws"
// Register handlers
_ "github.com/go-gost/x/handler/api"
_ "github.com/go-gost/x/handler/auto"
_ "github.com/go-gost/x/handler/dns"
_ "github.com/go-gost/x/handler/file"
_ "github.com/go-gost/x/handler/forward/local"
_ "github.com/go-gost/x/handler/forward/remote"
_ "github.com/go-gost/x/handler/http"
_ "github.com/go-gost/x/handler/http2"
_ "github.com/go-gost/x/handler/http3"
_ "github.com/go-gost/x/handler/masque"
_ "github.com/go-gost/x/handler/metrics"
_ "github.com/go-gost/x/handler/redirect/tcp"
_ "github.com/go-gost/x/handler/redirect/udp"
_ "github.com/go-gost/x/handler/relay"
_ "github.com/go-gost/x/handler/router"
_ "github.com/go-gost/x/handler/serial"
_ "github.com/go-gost/x/handler/sni"
_ "github.com/go-gost/x/handler/socks/v4"
_ "github.com/go-gost/x/handler/socks/v5"
@@ -56,6 +74,9 @@ import (
_ "github.com/go-gost/x/handler/sshd"
_ "github.com/go-gost/x/handler/tap"
_ "github.com/go-gost/x/handler/tun"
_ "github.com/go-gost/x/handler/tungo"
_ "github.com/go-gost/x/handler/tunnel"
_ "github.com/go-gost/x/handler/unix"
// Register listeners
_ "github.com/go-gost/x/listener/dns"
@@ -66,8 +87,10 @@ import (
_ "github.com/go-gost/x/listener/http2/h2"
_ "github.com/go-gost/x/listener/http3"
_ "github.com/go-gost/x/listener/http3/h3"
_ "github.com/go-gost/x/listener/http3/wt"
_ "github.com/go-gost/x/listener/icmp"
_ "github.com/go-gost/x/listener/kcp"
_ "github.com/go-gost/x/listener/mtcp"
_ "github.com/go-gost/x/listener/mtls"
_ "github.com/go-gost/x/listener/mws"
_ "github.com/go-gost/x/listener/obfs/http"
@@ -78,12 +101,17 @@ import (
_ "github.com/go-gost/x/listener/redirect/udp"
_ "github.com/go-gost/x/listener/rtcp"
_ "github.com/go-gost/x/listener/rudp"
_ "github.com/go-gost/x/listener/runix"
_ "github.com/go-gost/x/listener/serial"
_ "github.com/go-gost/x/listener/stdio"
_ "github.com/go-gost/x/listener/ssh"
_ "github.com/go-gost/x/listener/sshd"
_ "github.com/go-gost/x/listener/tap"
_ "github.com/go-gost/x/listener/tcp"
_ "github.com/go-gost/x/listener/tls"
_ "github.com/go-gost/x/listener/tun"
_ "github.com/go-gost/x/listener/tungo"
_ "github.com/go-gost/x/listener/udp"
_ "github.com/go-gost/x/listener/unix"
_ "github.com/go-gost/x/listener/ws"
)
+2 -2
View File
@@ -1,5 +1,5 @@
package main
const (
version = "3.0.0-rc.3"
var (
version = "3.3.1"
)
-4
View File
@@ -1,4 +0,0 @@
version: "3.4"
services:
gost:
build: .
+144 -81
View File
@@ -1,112 +1,175 @@
module github.com/go-gost/gost
go 1.18
replace github.com/templexxx/cpu v0.0.7 => github.com/templexxx/cpu v0.0.10-0.20211111114238-98168dcec14a
go 1.26.3
require (
github.com/go-gost/core v0.0.0-20230114050924-1a8c1ccb1dc5
github.com/go-gost/x v0.0.0-20230115094412-834c6447ca15
gopkg.in/natefinch/lumberjack.v2 v2.0.0
github.com/go-gost/core v0.6.1
github.com/go-gost/x v0.19.5
github.com/judwhite/go-svc v1.2.1
github.com/moby/moby/client v0.4.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
)
require (
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/coreos/go-iptables v0.5.0 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gin-contrib/cors v1.3.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.8.2 // indirect
github.com/go-gost/gosocks4 v0.0.1 // indirect
github.com/go-gost/gosocks5 v0.3.1-0.20211109033403-d894d75b7f09 // indirect
github.com/go-gost/relay v0.2.0 // indirect
github.com/go-gost/tls-dissector v0.0.2-0.20220408131628-aac992c27451 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.11.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/cors v1.7.2 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-gost/go-shadowsocks2 v0.1.4 // indirect
github.com/go-gost/gosocks4 v0.1.0 // indirect
github.com/go-gost/gosocks5 v0.5.0 // indirect
github.com/go-gost/plugin v0.8.1 // indirect
github.com/go-gost/quic-dissector v0.1.0 // indirect
github.com/go-gost/relay v0.7.0 // indirect
github.com/go-gost/tls-dissector v0.3.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/google/pprof v0.0.0-20221219190121-3cb0bae90811 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid v1.3.1 // indirect
github.com/klauspost/reedsolomon v1.9.9 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lucas-clemente/quic-go v0.31.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/marten-seemann/qpack v0.3.0 // indirect
github.com/marten-seemann/qtls-go1-18 v0.1.3 // indirect
github.com/marten-seemann/qtls-go1-19 v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/miekg/dns v1.1.50 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/reedsolomon v1.12.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/miekg/dns v1.1.61 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mmcloughlin/avo v0.0.0-20200803215136-443f81d77104 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.3.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.1 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/ginkgo/v2 v2.6.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pion/dtls/v2 v2.1.5 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/transport v0.14.1 // indirect
github.com/pion/udp v0.1.1 // indirect
github.com/pires/go-proxyproto v0.6.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.2 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.11.1 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/rs/xid v1.3.0 // indirect
github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/shoenig/go-m1cpu v0.2.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.14.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/templexxx/cpu v0.0.7 // indirect
github.com/templexxx/xorsimd v0.4.1 // indirect
github.com/tjfoc/gmsm v1.3.2 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
github.com/vishvananda/netlink v1.1.0 // indirect
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
github.com/xtaci/kcp-go/v5 v5.6.1 // indirect
github.com/xtaci/smux v1.5.16 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/xjasonlyu/tun2socks/v2 v2.6.0 // indirect
github.com/xtaci/kcp-go/v5 v5.6.72 // indirect
github.com/xtaci/smux v1.5.57 // indirect
github.com/xtaci/tcpraw v1.2.25 // indirect
github.com/yl2chen/cidranger v1.0.2 // indirect
golang.org/x/crypto v0.4.0 // indirect
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/net v0.4.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.4.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478 // indirect
google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zalando/go-keyring v0.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.7.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.48.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 // indirect
)
+349 -673
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
[Unit]
Description=GO Simple Tunnel
Documentation=https://gost.run/
After=network-online.target nss-lookup.target
ConditionPathExists=/etc/gost/gost.yml
[Service]
Type=simple
WorkingDirectory=/run/gost
ExecStart=/usr/bin/gost -C /etc/gost/gost.yml
ExecReload=/bin/kill -SIGHUP $MAINPID
DynamicUser=yes
RuntimeDirectory=gost
LogsDirectory=gost
TimeoutStopSec=5s
Restart=on-failure
RestartSec=1s
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
PrivateTmp=yes
PrivateDevices=no
ProtectSystem=full
ProtectHostname=yes
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
SystemCallFilter=~@privileged @mount @debug @cpu-emulation @obsolete
SystemCallFilter=@chown
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target
Executable
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Check Root User
# If you want to run as another user, please modify $EUID to be owned by this user
if [[ "$EUID" -ne '0' ]]; then
echo "$(tput setaf 1)Error: You must run this script as root!$(tput sgr0)"
exit 1
fi
# Set the desired GitHub repository
repo="go-gost/gost"
base_url="https://api.github.com/repos/$repo/releases"
# Function to download and install gost
install_gost() {
version=$1
# Detect the operating system
if [[ "$(uname)" == "Linux" ]]; then
os="linux"
elif [[ "$(uname)" == "Darwin" ]]; then
os="darwin"
elif [[ "$(uname)" == "MINGW"* ]]; then
os="windows"
else
echo "Unsupported operating system."
exit 1
fi
# Detect the CPU architecture
arch=$(uname -m)
case $arch in
x86_64)
cpu_arch="amd64"
;;
armv5*)
cpu_arch="armv5"
;;
armv6*)
cpu_arch="armv6"
;;
armv7*)
cpu_arch="armv7"
;;
aarch64|arm64)
cpu_arch="arm64"
;;
i686)
cpu_arch="386"
;;
mips64*)
cpu_arch="mips64"
;;
mips*)
cpu_arch="mips"
;;
mipsel*)
cpu_arch="mipsle"
;;
riscv64)
cpu_arch="riscv64"
;;
*)
echo "Unsupported CPU architecture."
exit 1
;;
esac
get_download_url="$base_url/tags/$version"
download_url=$(curl -s "$get_download_url" | awk -F'"' -v re=".*${os}.*${cpu_arch}.*" '/"browser_download_url":/ && $4 ~ re { print $4 }' | head -n 1)
# Download and install the binary
install_path="/usr/local/bin"
echo "Downloading and installing gost version $version..."
curl -fsSL "$download_url" | tar -xzC "$install_path" gost
chmod +x "$install_path/gost"
# Remove binary from macOS quarantine when installing for first time
[[ "$os" == "darwin" ]] && { xattr -d com.apple.quarantine "$install_path/gost" 2>&-; }
echo "gost installation completed!"
}
# Retrieve available versions from GitHub API
versions=$(curl -s "$base_url" | awk -F'"' '/"tag_name":/ {print $4}')
# Check if --install option provided
if [[ "$1" == "--install" ]]; then
# Install the latest version automatically
latest_version=$(echo "$versions" | head -n 1)
install_gost $latest_version
else
# Display available versions to the user
echo "Available gost versions:"
select version in $versions; do
if [[ -n $version ]]; then
install_gost $version
break
else
echo "Invalid choice! Please select a valid option."
fi
done
fi
+4
View File
@@ -0,0 +1,4 @@
FROM alpine:3.22
# add tools needed by e2e containers and health checks
RUN apk add --no-cache iptables curl netcat-openbsd python3 openssl
+276
View File
@@ -0,0 +1,276 @@
# 执行计划:HTTP Handler E2E 测试 + utils.go 重构
## 背景
从 PLAN.md 的 Step 0 和 Step 1A 开始实施。HTTP proxy 是 gost 最基础的协议,目前 e2e 测试仅覆盖 shadowsocks 和 parallel selector。本计划先重构 utils.go 提取公共 helper,然后用 helper 写 HTTP handler 测试套件,最后同步重构 shadowsocks_test.go。
## 变更文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `tests/e2e/utils.go` | 修改 | 新增 `RunTCPCase` / `RunUDPCase` |
| `tests/e2e/http_test.go` | 新建 | HTTP proxy 测试套件 |
| `tests/e2e/testdata/http/server.yaml` | 新建 | 无 auth 的 HTTP proxy 服务器 |
| `tests/e2e/testdata/http/server_auth.yaml` | 新建 | 带 auth 的 HTTP proxy 服务器 |
| `tests/e2e/shadowsocks_test.go` | 修改 | 重构为使用 `RunTCPCase` / `RunUDPCase` |
---
## Step 1: 修改 `utils.go` — 提取 RunTCPCase / RunUDPCase
从 `shadowsocks_test.go` 的 `runTCPCase` 和 `runUDPCase` 提取为包级函数。关键差异:参数需要传入 echo container IP 和 echo container 本身(用于 dump logs)。
### RunTCPCase 签名与逻辑
```go
// RunTCPCase runs a full TCP proxy test case:
// start server → render client config → start client → curl assertion → cleanup.
func RunTCPCase(t *testing.T, ctx context.Context, networkName, echoIP string,
name, serverConfig, clientConfig string)
```
逻辑(从 shadowsocks_test.go:132-160 提取,一字不差):
1. 生成 serverAlias = `name + "-server"`
2. `RunGostContainerWithOptions(ctx, networkName, serverConfig, [serverAlias], ["8388/tcp"])`
3. `defer serverC.Terminate(ctx)`
4. `RenderConfig(clientConfig, {ServerAddr: serverAlias + ":8388"})`
5. `defer os.Remove(rendered)`
6. `RunGostContainerWithPorts(ctx, networkName, rendered, "8080/tcp")`
7. `defer clientC.Terminate(ctx)`
8. `clientC.Exec(ctx, ["curl", "-v", "-s", "-x", "http://127.0.0.1:8080", "http://<echoIP>:5678"])`
9. 断言 exitCode==0 且 body 包含 `"hello-gost"`,失败时 DumpLogs
**问题:端口硬编码**。shadowsocks 用 8388 作为服务端口,但其他协议可能用不同端口。需要参数化。
### 修正设计 — 参数化端口
```go
type TCPOptions struct {
ServerPort string // 容器内服务端口,默认 "8388/tcp"
ClientPort string // 客户端代理端口,默认 "8080/tcp"
}
func RunTCPCase(t *testing.T, ctx context.Context, networkName, echoIP string,
name, serverConfig, clientConfig string, opts ...TCPOptions)
```
当 `opts` 为空时使用默认值 `{ServerPort: "8388/tcp", ClientPort: "8080/tcp"}`。这样 shadowsocks 测试无需任何改动,而 HTTP 测试可传 `TCPOptions{ServerPort: "8080/tcp", ClientPort: "8080/tcp"}`。
等一下 — 仔细看 shadowsocks 的 server 容器暴露的是 `8388/tcp`,HTTP proxy 场景下只有一个 gost 容器(不做 server→client 链路),直接暴露 proxy 端口。
**重新思考:HTTP proxy 不需要 server+client 两容器模式。**
HTTP proxy 测试是最简单的模式 — 只需一个 gost 容器运行 HTTP proxy,然后容器内 curl 通过它访问 echo server。这和 parallel_selector_test.go 的模式一致。但为了统一框架和后续协议(SOCKS5、relay 等都需要 server+client 模式),我们应该:
- HTTP 的"无 auth"用例:**单容器模式**(同 parallel_selector)
- HTTP 的"带 auth"用例:**也可以单容器**,只需在 curl 加 `--proxy-user`
所以 HTTP proxy 不需要 server/client 分离,但 RunTCPCase helper 仍然服务于 SOCKS5/relay 等需要链路的协议。
### 最终 RunTCPCase 设计
```go
func RunTCPCase(t *testing.T, ctx context.Context, networkName, echoIP, name string,
serverConfig, clientConfig string, serverPort, clientPort string)
```
- `serverPort`: 服务端容器暴露端口,如 `"8388/tcp"`
- `clientPort`: 客户端容器暴露端口,如 `"8080/tcp"`
- 渲染模板时用 `ServerAddr: serverAlias + ":" + strings.TrimSuffix(serverPort, "/tcp")`
### RunUDPCase 设计
```go
func RunUDPCase(t *testing.T, ctx context.Context, networkName, name string,
serverConfig, clientConfig string, serverPort, clientPort string)
```
逻辑从 shadowsocks_test.go:76-126 提取。
---
## Step 2: 新建 `testdata/http/` 配置文件
### `testdata/http/server.yaml` — 无 auth 的 HTTP proxy
```yaml
services:
- name: http-proxy
addr: :8080
handler:
type: http
listener:
type: tcp
```
单容器即可测试:gost 启动后在 :8080 提供 HTTP proxy 服务。
### `testdata/http/server_auth.yaml` — 带 auth 的 HTTP proxy
```yaml
services:
- name: http-proxy-auth
addr: :8080
handler:
type: http
auther: auther-0
listener:
type: tcp
authers:
- name: auther-0
auths:
- username: user
password: pass
```
---
## Step 3: 新建 `http_test.go`
### 套件结构
```go
type HTTPSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *HTTPSuite) SetupSuite() // 启动 TCP echo
func (s *HTTPSuite) TearDownSuite() // 关闭 echo
func (s *HTTPSuite) TestHTTPProxy() // 无 auth
func (s *HTTPSuite) TestHTTPProxyAuth() // 带 auth
func TestHTTPSuite(t *testing.T) {
suite.Run(t, new(HTTPSuite))
}
```
### TestHTTPProxy — 单容器模式(同 parallel_selector)
```go
func (s *HTTPSuite) TestHTTPProxy() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-proxy logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
```
### TestHTTPProxyAuth — 带 auth 验证
```go
func (s *HTTPSuite) TestHTTPProxyAuth() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_auth.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// 测试1:无 auth 应失败(407)
s.T().Run("no-auth-should-fail", func(t *testing.T) {
cmd := []string{"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-x", "http://127.0.0.1:8080", fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
// curl exit code 非 0 或 HTTP status 是 407
// 注意:gost HTTP proxy 在无 auth 时返回 407,curl 不会自动重试
// 所以这里验证返回码是 407 或 curl 返回非零
httpStatus := strings.TrimSpace(string(body))
s.Assert().True(code != 0 || httpStatus == "407",
"expected auth failure, got status: %s, exit code: %d", httpStatus, code)
})
// 测试2:正确 auth 应成功
s.T().Run("with-auth-should-succeed", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-x",
"http://user:pass@127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-proxy-auth logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
}
```
### 注意事项
- HTTP proxy 是**单容器模式**,不需要 server/client 分离(不像 shadowsocks 需要解密/加密链路)
- auth 测试分两步:先验证无 auth 被拒(407),再验证正确 auth 成功
- curl 使用 `http://user:pass@host:port` 格式传递 proxy auth
---
## Step 4: 重构 `shadowsocks_test.go`
将 `runTCPCase` 和 `runUDPCase` 方法替换为调用 `utils.go` 中的 `RunTCPCase` / `RunUDPCase`:
**Before**:
```go
func (s *ShadowsocksSuite) TestShadowsocksTCP() {
s.runTCPCase("aes256gcm", "testdata/shadowsocks/tcp_server_aes256gcm.yaml", "testdata/shadowsocks/tcp_client_aes256gcm.yaml")
s.runTCPCase("chacha20", "testdata/shadowsocks/tcp_server_chacha20.yaml", "testdata/shadowsocks/tcp_client_chacha20.yaml")
}
```
**After**:
```go
func (s *ShadowsocksSuite) TestShadowsocksTCP() {
RunTCPCase(s.T(), s.ctx, SharedNetworkName, s.echoIP, "aes256gcm",
"testdata/shadowsocks/tcp_server_aes256gcm.yaml",
"testdata/shadowsocks/tcp_client_aes256gcm.yaml",
"8388/tcp", "8080/tcp")
RunTCPCase(s.T(), s.ctx, SharedNetworkName, s.echoIP, "chacha20",
"testdata/shadowsocks/tcp_server_chacha20.yaml",
"testdata/shadowsocks/tcp_client_chacha20.yaml",
"8388/tcp", "8080/tcp")
}
```
删除 `runTCPCase` 和 `runUDPCase` 方法。UDP 测试同理。
---
## 实施顺序
1. **`utils.go`** — 添加 `RunTCPCase` + `RunUDPCase`(不删除任何现有函数)
2. **`testdata/http/`** — 创建 `server.yaml` + `server_auth.yaml`
3. **`http_test.go`** — 创建 HTTP 套件
4. **编译验证** — `cd gost && go build ./... && go vet ./tests/e2e/...`
5. **`shadowsocks_test.go`** — 重构为使用新 helper
6. **最终编译验证** — `go build ./... && go vet ./tests/e2e/...`
7. **运行测试** — `go test ./tests/e2e/ -v -run TestHTTPSuite -timeout 5m`
8. **回归测试** — `go test ./tests/e2e/ -v -run TestShadowsocksSuite -timeout 5m`
---
## 验证
```bash
cd /config/workspace/go-gost/gost
go build ./...
go vet ./tests/e2e/...
go test ./tests/e2e/ -v -run TestHTTPSuite -timeout 5m
go test ./tests/e2e/ -v -run TestShadowsocksSuite -timeout 5m
```
+183
View File
@@ -0,0 +1,183 @@
# Plan: Expand E2E Test Suite
## Context
The e2e test framework (`tests/e2e/`) currently covers only **2 protocols** (Shadowsocks, parallel selector) out of **~30+ registered protocols** in gost. This plan systematically adds test coverage for all major protocols, following the existing Docker-based testcontainers pattern exactly.
**Key insight**: gost auto-generates self-signed TLS certs when none are provided (`x/config/parsing/tls.go` — `BuildDefaultTLSConfig`), and clients with default config skip cert verification. This means TLS-based protocols (ws→wss, h2c→h2, quic, etc.) can be tested **without mounting cert files**.
---
## Step 0: Refactor `utils.go` — Add Reusable Test Helpers
**File**: `tests/e2e/utils.go`
Extract the repeated patterns from `shadowsocks_test.go` into reusable helpers:
1. **`RunTCPCase(t, ctx, networkName, echoIP, name, serverConfig, clientConfig string)`** — Full lifecycle: start server → render client config → start client → curl assertion → cleanup. Extracts the logic from `ShadowsocksSuite.runTCPCase`.
2. **`RunUDPCase(t, ctx, networkName, name, serverConfig, clientConfig string)`** — Same for UDP: start server → render client → start client → dial UDP → retry loop → assertion → cleanup. Extracts from `runUDPCase`.
These eliminate ~40 lines of boilerplate per suite. Each suite's test methods become one-liners:
```go
func (s *SOCKS5Suite) TestSOCKS5TCP() {
RunTCPCase(s.T(), s.ctx, SharedNetworkName, s.echoIP, "socks5-tcp",
"testdata/socks5/tcp_server.yaml", "testdata/socks5/tcp_client.yaml")
}
```
---
## Step 1: Tier 1 — Core Protocols (Plain TCP, No Special Infrastructure)
Each suite follows the exact pattern: suite struct → `SetupSuite` (echo containers) → `TearDownSuite` → test methods calling `RunTCPCase`/`RunUDPCase` → `suite.Run()` entry point.
### 1A. HTTP Proxy Suite
- **Files**: `http_test.go`, `testdata/http/` (2 configs)
- **Configs**: `server.yaml` (handler `http`, listener `tcp`), `server_auth.yaml` (+ basic auth)
- **Tests**: `TestHTTPProxy`, `TestHTTPProxyAuth` (curl with `--proxy-user`)
- **Note**: Simplest test — single gost container acts as HTTP proxy
### 1B. SOCKS5 Suite
- **Files**: `socks5_test.go`, `testdata/socks5/` (4 configs)
- **Configs**: `tcp_server.yaml` (handler `socks5`), `tcp_client.yaml` (connector `socks5`, dialer `tcp`), + auth variants
- **Tests**: `TestSOCKS5TCP`, `TestSOCKS5TCPAuth`
### 1C. SOCKS4 Suite
- **Files**: `socks4_test.go`, `testdata/socks4/` (4 configs)
- **Configs**: `tcp_server.yaml` (handler `socks4`), `tcp_client.yaml` (connector `socks4`), + socks4a variants
- **Tests**: `TestSOCKS4TCP`, `TestSOCKS4aTCP`
### 1D. HTTP2 Cleartext Suite
- **Files**: `http2_test.go`, `testdata/http2/` (2 configs)
- **Configs**: `tcp_server.yaml` (handler `http2`, listener `http2`), `tcp_client.yaml` (connector `http2`, dialer `http2`)
- **Tests**: `TestHTTP2TCP`
### 1E. Relay Suite
- **Files**: `relay_test.go`, `testdata/relay/` (4 configs)
- **Configs**: `tcp_server.yaml` (handler `relay`, listener `tcp`), `tcp_client.yaml` (connector `relay`, dialer `tcp`), + auth variants
- **Tests**: `TestRelayTCP`, `TestRelayTCPAuth`
---
## Step 2: Tier 2 — TLS/Transport Protocols
Same pattern but using TLS listeners/dialers. No explicit cert files needed — gost auto-generates them.
### 2A. TLS Suite
- **Files**: `tls_test.go`, `testdata/tls/` (4 configs)
- **Tests**: `TestTLSHTTPProxy` (handler `http` + listener `tls`), `TestTLSSOCKS5` (handler `socks5` + listener `tls`)
### 2B. WebSocket Suite
- **Files**: `ws_test.go`, `testdata/ws/` (4 configs)
- **Tests**: `TestWSSOCKS5` (dialer `ws`), `TestWSSSOCKS5` (dialer `wss`)
### 2C. gRPC Suite
- **Files**: `grpc_test.go`, `testdata/grpc/` (2 configs)
- **Tests**: `TestGRPCSOCKS5` (listener `grpc`, metadata `insecure: true`)
### 2D. HTTP/2 TLS (h2) Suite
- **Files**: `h2_test.go`, `testdata/h2/` (2 configs)
- **Tests**: `TestH2HTTP2Proxy` (listener `h2`, dialer `h2`)
### 2E. QUIC Suite
- **Files**: `quic_test.go`, `testdata/quic/` (2 configs)
- **Tests**: `TestQUICHTTP` (listener `quic`, handler `http` — uses UDP ports)
### 2F. KCP Suite
- **Files**: `kcp_test.go`, `testdata/kcp/` (2 configs)
- **Tests**: `TestKCPSOCKS5` (listener `kcp`, handler `socks5` — uses UDP)
### 2G. Multiplex Variants (mws/mwss/mtls/mtcp)
- **Files**: `mux_test.go`, `testdata/mux/` (6 configs)
- **Tests**: `TestMWSSOCKS5`, `TestMWSSSOCKS5`, `TestMtlSSOCKS5`, `TestMtcpSOCKS5`
### 2H. Obfuscation (ohttp/otls)
- **Files**: `obfs_test.go`, `testdata/obfs/` (4 configs)
- **Tests**: `TestOHTTP`, `TestOTLS`
### 2I. PHT (HTTP Pipe)
- **Files**: `pht_test.go`, `testdata/pht/` (4 configs)
- **Tests**: `TestPHT`, `TestPHTS`
---
## Step 3: Tier 3 — Special Infrastructure (Deferred)
These require Docker capabilities (`NET_ADMIN`, `NET_RAW`), SSH key generation, or iptables setup. Implemented after Tiers 1-2 are stable.
| Protocol | Challenge | Approach |
|----------|-----------|----------|
| SSH/sshd | Host key generation | Generate SSH key at test time, mount into container |
| HTTP/3/WebTransport | QUIC+UDP, TLS | Uses UDP like QUIC suite; auto certs work |
| TUN/TAP | `CAP_NET_ADMIN`, `/dev/net/tun` | Privileged container + routing setup |
| Tungo/MASQUE | Same as TUN | Same approach |
| ICMP | `CAP_NET_RAW`, raw sockets | Privileged container |
| Redirect (red/redu) | iptables rules | Privileged + iptables setup in container |
| DTLS | UDP + TLS | Auto certs; UDP port mapping |
---
## Files to Create/Modify
### New files (per suite):
- `tests/e2e/<protocol>_test.go` — Suite struct + test methods
- `tests/e2e/testdata/<protocol>/` — Server + client YAML configs
### Modified files:
- `tests/e2e/utils.go` — Add `RunTCPCase`, `RunUDPCase` helpers
- `tests/e2e/shadowsocks_test.go` — Refactor to use new helpers (optional, reduces duplication)
- `tests/e2e/README.md` — Document new suites
### Reference files (read-only):
- `tests/e2e/shadowsocks_test.go` — Pattern template for all suites
- `tests/e2e/testdata/shadowsocks/tcp_server_aes256gcm.yaml` — Server config template
- `tests/e2e/testdata/shadowsocks/tcp_client_aes256gcm.yaml` — Client config template
---
## Config Pattern Reference
**Server** (all protocols):
```yaml
services:
- name: <proto>-server
addr: :<port>
handler:
type: <handler-type>
listener:
type: <listener-type>
```
**Client** (all protocols):
```yaml
services:
- name: http-proxy
addr: :8080
handler:
type: http
chain: <proto>-chain
listener:
type: tcp
chains:
- name: <proto>-chain
hops:
- name: <proto>-hop
nodes:
- name: <proto>-node
addr: {{.ServerAddr}}
connector:
type: <connector-type>
dialer:
type: <dialer-type>
```
---
## Verification
After each suite is implemented:
1. `cd gost && go build ./...` — Ensure compilation
2. `go vet ./tests/e2e/` — Static analysis
3. `go test ./tests/e2e/ -v -run TestXxxSuite -timeout 5m` — Run individual suite
4. `go test ./tests/e2e/ -v -timeout 10m` — Run all suites together
+53
View File
@@ -0,0 +1,53 @@
# End-to-End Tests
Integration tests that spin up real gost instances inside Docker containers and verify protocol behavior over the network.
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) (running daemon)
- Go toolchain (for compiling the gost binary under test)
## Running
From the repository root:
```bash
# Run all e2e tests
go test ./tests/e2e/ -v -timeout 10m
# Run a specific test suite
go test ./tests/e2e/ -v -run TestShadowsocksSuite -timeout 5m
go test ./tests/e2e/ -v -run TestParallelSelectorSuite -timeout 5m
# Use a pre-built gost binary (skips compilation)
go test ./tests/e2e/ -v -gost-bin /path/to/gost
```
## Architecture
```
tests/e2e/
├── Dockerfile # Shared base image (Alpine + curl, python3, etc.)
├── main_test.go # TestMain: compiles gost, creates Docker network
├── utils.go # Helpers: container lifecycle, config rendering
├── scripts/
│ ├── tcp_echo.py # HTTP echo server (responds with "hello-gost")
│ └── udp_echo.py # UDP echo server (reflects payloads)
├── testdata/ # config files or data files for running cases
├── shadowsocks_test.go # Shadowsocks protocol tests
└── selector_test.go # Node selector tests (round-robin, fifo, backup, parallel)
```
### How it works
1. **TestMain** (`main_test.go`) compiles the gost binary from `../../cmd/gost` (unless `-gost-bin` is provided) and creates a shared Docker network for all containers.
2. Each test suite starts echo server containers (TCP/UDP), then launches separate gost containers for server and client roles.
3. Client configs use Go template syntax (`{{.ServerAddr}}`) so the server address is injected at runtime.
4. Tests verify end-to-end connectivity by sending traffic through the gost proxy chain and checking that the echo server responds correctly.
## Tips
- Increase `-timeout` for CI or slow networks. Container image builds on first run take extra time.
- Use `-gost-bin` to avoid recompiling when iterating on tests locally.
- Add `-v` to see container log output on failure.
- RunGostContainer will wait for exposedPorts automatically, but it's not reliable for udp ports. So, you should check the readiness of udp ports inside cases.
+111
View File
@@ -0,0 +1,111 @@
package e2e
import (
"context"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// AdmissionSuite verifies service-level admission control, which gates
// connections by the client's source address. It contrasts a loopback client
// (curl run inside the gost container, source 127.0.0.1) against an external
// client (curl run inside the echo container, source = its container IP).
type AdmissionSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *AdmissionSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *AdmissionSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// curlIn runs curl inside the given container, proxying through the gost proxy
// at proxyAddr to the echo server, and returns the response body. An admitted
// request returns "hello-gost"; a denied connection returns an empty body.
func (s *AdmissionSuite) curlIn(c testcontainers.Container, proxyAddr string) string {
cmd := []string{
"curl", "-s",
"-x", fmt.Sprintf("http://%s", proxyAddr),
fmt.Sprintf("http://%s:5678", s.echoIP),
}
_, out, err := c.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestWhitelistAdmitLoopback verifies that a whitelisted source (127.0.0.1,
// a loopback client inside the gost container) is admitted and reaches the
// echo server.
func (s *AdmissionSuite) TestWhitelistAdmitLoopback() {
gostC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/admission/whitelist.yaml", []string{"gost-proxy"}, []string{"8080/tcp"})
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.curlIn(gostC, "127.0.0.1:8080")
s.Require().Contains(body, "hello-gost")
}
// TestWhitelistDenyExternal verifies that a non-whitelisted source (an external
// container, not 127.0.0.1) is denied, so the request never reaches the echo
// server.
func (s *AdmissionSuite) TestWhitelistDenyExternal() {
gostC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/admission/whitelist.yaml", []string{"gost-proxy"}, []string{"8080/tcp"})
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.curlIn(s.echoC, "gost-proxy:8080")
s.Require().NotContains(body, "hello-gost")
}
// TestBlacklistDenyLoopback verifies that a blacklisted source (127.0.0.1,
// a loopback client inside the gost container) is denied.
func (s *AdmissionSuite) TestBlacklistDenyLoopback() {
gostC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/admission/blacklist.yaml", []string{"gost-proxy"}, []string{"8080/tcp"})
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.curlIn(gostC, "127.0.0.1:8080")
s.Require().NotContains(body, "hello-gost")
}
// TestBlacklistAdmitExternal verifies that a source outside the blacklist (an
// external container, not 127.0.0.1) is admitted and reaches the echo server.
func (s *AdmissionSuite) TestBlacklistAdmitExternal() {
gostC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/admission/blacklist.yaml", []string{"gost-proxy"}, []string{"8080/tcp"})
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.curlIn(s.echoC, "gost-proxy:8080")
s.Require().Contains(body, "hello-gost")
}
func TestAdmissionSuite(t *testing.T) {
suite.Run(t, new(AdmissionSuite))
}
+123
View File
@@ -0,0 +1,123 @@
package e2e
import (
"context"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// AuthSuite verifies proxy authentication on the HTTP handler, using both
// inline single-user auth and a named auther with multiple users. Requests
// carry proxy credentials via curl's -x http://user:pass@host form; a rejected
// request gets a 407 and never reaches the echo server.
type AuthSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *AuthSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *AuthSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// proxyRequest sends one request through the gost proxy using the given
// userinfo ("user:pass", or "" for no credentials) and returns the response
// body. An authenticated request returns "hello-gost"; a rejected one returns
// an empty body.
func (s *AuthSuite) proxyRequest(gostC testcontainers.Container, userinfo string) string {
proxy := "http://127.0.0.1:8080"
if userinfo != "" {
proxy = fmt.Sprintf("http://%s@127.0.0.1:8080", userinfo)
}
cmd := []string{
"curl", "-s",
"-x", proxy,
fmt.Sprintf("http://%s:5678", s.echoIP),
}
_, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
func (s *AuthSuite) runGost(cfg string) testcontainers.Container {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, "8080/tcp")
s.Require().NoError(err)
return gostC
}
// TestInlineAuthValid verifies that correct inline credentials are accepted.
func (s *AuthSuite) TestInlineAuthValid() {
gostC := s.runGost("testdata/auth/inline.yaml")
defer gostC.Terminate(s.ctx)
s.Require().Contains(s.proxyRequest(gostC, "user:pass"), "hello-gost")
}
// TestInlineAuthWrongPassword verifies that a wrong password is rejected.
func (s *AuthSuite) TestInlineAuthWrongPassword() {
gostC := s.runGost("testdata/auth/inline.yaml")
defer gostC.Terminate(s.ctx)
s.Require().NotContains(s.proxyRequest(gostC, "user:wrong"), "hello-gost")
}
// TestInlineAuthMissing verifies that a request without credentials is rejected.
func (s *AuthSuite) TestInlineAuthMissing() {
gostC := s.runGost("testdata/auth/inline.yaml")
defer gostC.Terminate(s.ctx)
s.Require().NotContains(s.proxyRequest(gostC, ""), "hello-gost")
}
// TestNamedAutherFirstUser verifies that the first user in a named auther is
// accepted.
func (s *AuthSuite) TestNamedAutherFirstUser() {
gostC := s.runGost("testdata/auth/auther.yaml")
defer gostC.Terminate(s.ctx)
s.Require().Contains(s.proxyRequest(gostC, "alice:secret"), "hello-gost")
}
// TestNamedAutherSecondUser verifies that any user in a named auther, not just
// the first, is accepted.
func (s *AuthSuite) TestNamedAutherSecondUser() {
gostC := s.runGost("testdata/auth/auther.yaml")
defer gostC.Terminate(s.ctx)
s.Require().Contains(s.proxyRequest(gostC, "bob:hunter2"), "hello-gost")
}
// TestNamedAutherInvalid verifies that credentials not in the named auther are
// rejected.
func (s *AuthSuite) TestNamedAutherInvalid() {
gostC := s.runGost("testdata/auth/auther.yaml")
defer gostC.Terminate(s.ctx)
s.Require().NotContains(s.proxyRequest(gostC, "alice:wrong"), "hello-gost")
}
func TestAuthSuite(t *testing.T) {
suite.Run(t, new(AuthSuite))
}
+116
View File
@@ -0,0 +1,116 @@
package e2e
import (
"context"
"fmt"
"io"
"os"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type BypassSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *BypassSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *BypassSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// proxyRequest sends one request through the gost proxy and returns the
// response body. A bypassed (direct) request that reaches the echo server
// returns "hello-gost"; a request forced through the dead chain node returns
// a 503 and no echo body.
func (s *BypassSuite) proxyRequest(gostC testcontainers.Container, port, target string) string {
cmd := []string{
"curl", "-s",
"-x", fmt.Sprintf("http://127.0.0.1:%s", port),
target,
}
_, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestBlacklistBypassDirect verifies that a destination matching a blacklist
// bypass rule skips the (dead) chain node and connects directly to the echo
// server, so the request succeeds despite the dead node.
func (s *BypassSuite) TestBlacklistBypassDirect() {
cfg, err := RenderConfig("testdata/bypass/blacklist.yaml", ConfigData{ServerAddr: s.echoIP})
s.Require().NoError(err)
defer os.Remove(cfg)
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.proxyRequest(gostC, "8080", fmt.Sprintf("http://%s:5678", s.echoIP))
s.Require().Contains(body, "hello-gost")
}
// TestBlacklistBypassViaChain verifies that a destination NOT matching the
// blacklist bypass is routed through the chain node. With the node dead the
// request never reaches the echo server, confirming the prior success was due
// to the bypass.
func (s *BypassSuite) TestBlacklistBypassViaChain() {
cfg, err := RenderConfig("testdata/bypass/blacklist.yaml", ConfigData{ServerAddr: s.echoIP})
s.Require().NoError(err)
defer os.Remove(cfg)
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.proxyRequest(gostC, "8080", "http://10.0.0.1:5678")
s.Require().NotContains(body, "hello-gost")
}
// TestWhitelistBypassDirect verifies that a destination outside the whitelist
// rule is bypassed (connects directly) and reaches the echo server even though
// the chain node is dead.
func (s *BypassSuite) TestWhitelistBypassDirect() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/bypass/whitelist.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.proxyRequest(gostC, "8080", fmt.Sprintf("http://%s:5678", s.echoIP))
s.Require().Contains(body, "hello-gost")
}
// TestWhitelistBypassViaChain verifies that a destination matching the
// whitelist rule is forced through the chain node; with the node dead the
// request never reaches the echo server.
func (s *BypassSuite) TestWhitelistBypassViaChain() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/bypass/whitelist.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
body := s.proxyRequest(gostC, "8080", "http://10.0.0.1:5678")
s.Require().NotContains(body, "hello-gost")
}
func TestBypassSuite(t *testing.T) {
suite.Run(t, new(BypassSuite))
}
+106
View File
@@ -0,0 +1,106 @@
package e2e
import (
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type ChainGroupSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *ChainGroupSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *ChainGroupSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// proxyRequest sends a request through the gost proxy, using the given
// target hostname (not IP) so that Host() matchers at the chain-group level
// can match against it.
func (s *ChainGroupSuite) proxyRequestHost(gostC testcontainers.Container, proxyPort, targetHost string) (int, string) {
cmd := []string{
"curl", "-s",
"-x", fmt.Sprintf("http://127.0.0.1:%s", proxyPort),
fmt.Sprintf("http://%s:5678", targetHost),
}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return code, string(body)
}
// proxyRequestIP sends a request via IP (like existing tests do).
func (s *ChainGroupSuite) proxyRequestIP(gostC testcontainers.Container, port string) (int, string) {
return s.proxyRequestHost(gostC, port, s.echoIP)
}
// TestMatcherRoutesByHost verifies that a chainGroup with per-entry Host() matchers
// routes traffic to the chain whose matcher matches the target hostname. The
// non-matching chain has a dead relay, so if the matcher fails to filter, requests
// would fail.
func (s *ChainGroupSuite) TestMatcherRoutesByHost() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/chaingroup/matcher.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Requests with hostname "tcp-echo" (the echo container's network alias)
// must match Host("tcp-echo") → chain-target → live relay → echo server.
for range 10 {
code, body := s.proxyRequestHost(gostC, "8080", "tcp-echo")
s.Require().Equal(0, code, "Host('tcp-echo') must match the target chain")
s.Require().Contains(body, "hello-gost")
}
}
// TestProbeMarksDeadChain verifies that a TCP probe detects a dead chain entry
// and marks it before real traffic, so the FailFilter excludes it. With the dead
// entry pre-marked, every request succeeds.
func (s *ChainGroupSuite) TestProbeMarksDeadChain() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/chaingroup/probe.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The probe fires immediately at startup; by the time we send requests
// the dead chain entry is already marked. All requests converge to the
// live chain.
failures := 0
for range 10 {
code, body := s.proxyRequestIP(gostC, "8080")
if code != 0 || !strings.Contains(body, "hello-gost") {
failures++
}
}
// At most one failure is acceptable (the first request might race with
// the probe's first Mark). Every request after must succeed.
s.Require().LessOrEqual(failures, 0,
"probe must pre-mark the dead chain entry; all requests must succeed")
}
func TestChainGroupSuite(t *testing.T) {
suite.Run(t, new(ChainGroupSuite))
}
+360
View File
@@ -0,0 +1,360 @@
package e2e
import (
"context"
"encoding/base64"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type DNSSuite struct {
suite.Suite
ctx context.Context
}
func (s *DNSSuite) SetupSuite() {
s.ctx = context.Background()
}
// dnsQuery sends a DNS query via the Python client and retries on failure.
// expected can be an IP string, "empty" (expect zero answer records), or "" (no check).
func (s *DNSSuite) dnsQuery(gostC testcontainers.Container, mode, host, port, qname, qtype, expected string) {
args := []string{"python3", "/scripts/dns_query.py", mode, host, port, qname, qtype}
if expected != "" {
args = append(args, expected)
}
for i := range 5 {
code, out, err := gostC.Exec(s.ctx, args)
if err != nil {
s.T().Logf("query attempt %d exec error: %v", i+1, err)
time.Sleep(time.Second)
continue
}
body, err := io.ReadAll(out)
if err != nil {
s.T().Logf("query attempt %d read error: %v", i+1, err)
time.Sleep(time.Second)
continue
}
output := string(body)
s.T().Logf("query attempt %d: %s", i+1, strings.TrimSpace(output))
if code == 0 {
return
}
time.Sleep(time.Second)
}
s.T().Fatalf("DNS query %s %s %s %s failed after 5 retries", mode, qname, qtype, host)
}
// dnsQueryOnce sends a single DNS query with no retries. Returns exit code and output.
func (s *DNSSuite) dnsQueryOnce(gostC testcontainers.Container, mode, host, port, qname, qtype string) (int, string) {
args := []string{"python3", "/scripts/dns_query.py", mode, host, port, qname, qtype}
code, out, err := gostC.Exec(s.ctx, args)
if err != nil {
return 1, fmt.Sprintf("exec error: %v", err)
}
body, _ := io.ReadAll(out)
return code, string(body)
}
// dnsQueryWithDelay calls dnsQuery with a small wait before the first attempt.
func (s *DNSSuite) dnsQueryWithDelay(gostC testcontainers.Container, mode, host, port, qname, qtype, expected string) {
time.Sleep(500 * time.Millisecond)
s.dnsQuery(gostC, mode, host, port, qname, qtype, expected)
}
// sendRaw sends raw data via base64+nc and returns the response.
func (s *DNSSuite) sendRaw(gostC testcontainers.Container, host, port, data string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(data))
cmd := []string{"sh", "-c",
fmt.Sprintf("echo %s | base64 -d | nc -w 3 -u %s %s", encoded, host, port)}
_, out, _ := gostC.Exec(s.ctx, cmd)
b, _ := io.ReadAll(out)
return string(b)
}
// startDNSResponder starts the UDP DNS responder and returns the container.
func (s *DNSSuite) startDNSResponder() testcontainers.Container {
dnsC, err := RunDNSResponderContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
return dnsC
}
// startGostWithQueryScript starts a gost container with dns_query.py mounted.
func (s *DNSSuite) startGostWithQueryScript(yamlPath, exposedPort string) testcontainers.Container {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
yamlPath,
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644},
},
exposedPort)
s.Require().NoError(err)
return gostC
}
// ---------------------------------------------------------------------------
// Upstream resolution
// ---------------------------------------------------------------------------
func (s *DNSSuite) TestDNSUpstream() {
dnsC := s.startDNSResponder()
defer dnsC.Terminate(s.ctx)
gostC := s.startGostWithQueryScript("testdata/dns/server_upstream.yaml", "1053/udp")
defer gostC.Terminate(s.ctx)
s.T().Run("a-record", func(t *testing.T) {
s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053", "test.example.com", "A", "10.0.0.1")
})
s.T().Run("aaaa-record", func(t *testing.T) {
s.dnsQuery(gostC, "udp", "127.0.0.1", "1053", "example.com", "AAAA", "::1")
})
s.T().Run("second-a-record", func(t *testing.T) {
s.dnsQuery(gostC, "udp", "127.0.0.1", "1053", "test2.example.com", "A", "10.0.0.2")
})
}
// ---------------------------------------------------------------------------
// TCP mode
// ---------------------------------------------------------------------------
func (s *DNSSuite) TestDNSTCP() {
s.T().Log("start TCP DNS responder container...")
dnsC, err := RunTCPDNSResponderContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
defer dnsC.Terminate(s.ctx)
gostC := s.startGostWithQueryScript("testdata/dns/server_tcp.yaml", "1053/tcp")
defer gostC.Terminate(s.ctx)
s.dnsQueryWithDelay(gostC, "tcp", "127.0.0.1", "1053", "test.example.com", "A", "10.0.0.1")
s.dnsQuery(gostC, "tcp", "127.0.0.1", "1053", "test2.example.com", "A", "10.0.0.2")
}
// ---------------------------------------------------------------------------
// Bypass rules
// ---------------------------------------------------------------------------
func (s *DNSSuite) TestDNSBypass() {
dnsC := s.startDNSResponder()
defer dnsC.Terminate(s.ctx)
gostC := s.startGostWithQueryScript("testdata/dns/server_bypass.yaml", "1053/udp")
defer gostC.Terminate(s.ctx)
s.T().Run("blocked-domain-empty-answer", func(t *testing.T) {
s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053",
"test.example.com", "A", "empty")
})
s.T().Run("non-blocked-domain", func(t *testing.T) {
s.dnsQuery(gostC, "udp", "127.0.0.1", "1053",
"test2.example.com", "A", "10.0.0.2")
})
}
// ---------------------------------------------------------------------------
// Host mapper
// ---------------------------------------------------------------------------
// TestDNSHostMapper verifies DNS resolution via the host mapper before
// reaching the upstream exchanger.
//
// Config: server_hosts.yaml maps mapped.example.com → 10.0.0.100 and
// points the handler at an unreachable upstream (udp://127.0.0.1:1).
// The handler checks the host mapper before the exchange path, so
// mapped names resolve without needing any upstream DNS. Unmapped
// names must fall through to the exchanger, which fails — confirming
// the host-mapper path was the only reason mapped names worked.
func (s *DNSSuite) TestDNSHostMapper() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/dns/server_hosts.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644},
},
"1053/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("mapped-a-record", func(t *testing.T) {
// mapped.example.com → host mapper returns 10.0.0.100
// without contacting any upstream.
s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053",
"mapped.example.com", "A", "10.0.0.100")
})
s.T().Run("unmapped-domain", func(t *testing.T) {
// test.example.com is not in the hosts mapping. The handler
// will fall through to the exchanger path, which fails because
// no upstream is configured. Query should return empty/NXDOMAIN.
code, output := s.dnsQueryOnce(gostC, "udp", "127.0.0.1", "1053",
"test.example.com", "A")
s.T().Logf("unmapped query: code=%d, %s", code, strings.TrimSpace(output))
// Non-zero exit indicates no valid response — expected since
// there's no working upstream.
s.Assert().NotEqual(0, code,
"unmapped domain should fail with no upstream")
})
}
// ---------------------------------------------------------------------------
// Exchange failure (unreachable upstream)
// ---------------------------------------------------------------------------
// TestDNSExchangeFailure verifies graceful handling when the upstream DNS
// exchanger is unreachable.
//
// Config: server_exchange_failure.yaml points to udp://127.0.0.1:1 which
// is unreachable. The handler must not crash or leak goroutines when the
// upstream exchange fails.
func (s *DNSSuite) TestDNSExchangeFailure() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/dns/server_exchange_failure.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644},
},
"1053/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("unreachable-upstream", func(t *testing.T) {
// Send query once — the exchange will fail (timeout / ICMP
// unreachable). No response is expected.
code, output := s.dnsQueryOnce(gostC, "udp", "127.0.0.1", "1053",
"test.example.com", "A")
s.T().Logf("exchange failure result: code=%d, %s",
code, strings.TrimSpace(output))
// Non-zero exit is expected since the exchange fails.
s.Assert().NotEqual(0, code,
"exchange failure should return non-zero exit")
})
s.T().Run("container-alive-after-failure", func(t *testing.T) {
// Verify the gost container is still running after the
// failed exchange — proves no crash or hang.
aliveCode, _, aliveErr := gostC.Exec(s.ctx, []string{"true"})
s.Require().NoError(aliveErr,
"container exec should succeed after exchange failure")
s.Require().Equal(0, aliveCode,
"gost container should be alive after exchange failure")
})
}
// ---------------------------------------------------------------------------
// Rate limiter
// ---------------------------------------------------------------------------
// TestDNSRateLimiter verifies that rate limiting configuration is accepted
// and the handler processes queries through the rate limiter path without
// crashing. Uses a generous global limit (1000/s) so queries pass.
func (s *DNSSuite) TestDNSRateLimiter() {
dnsC := s.startDNSResponder()
defer dnsC.Terminate(s.ctx)
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/dns/server_rlimiter.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644},
},
"1053/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("query-with-limiter", func(t *testing.T) {
s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053",
"test.example.com", "A", "10.0.0.1")
})
s.T().Run("second-query-with-limiter", func(t *testing.T) {
s.dnsQuery(gostC, "udp", "127.0.0.1", "1053",
"test2.example.com", "A", "10.0.0.2")
})
}
// ---------------------------------------------------------------------------
// Invalid query
// ---------------------------------------------------------------------------
// TestDNSInvalidQuery verifies the DNS listener gracefully handles malformed
// DNS messages. Sends garbage bytes and checks the gost container is
// unaffected (no crash, no hang).
func (s *DNSSuite) TestDNSInvalidQuery() {
gostC := s.startGostWithQueryScript("testdata/dns/server_upstream.yaml", "1053/udp")
defer gostC.Terminate(s.ctx)
s.T().Run("send-garbage-bytes", func(t *testing.T) {
// Send 5 bytes of garbage via UDP. The miekg/dns server
// will fail to parse them and discard the packet.
s.sendRaw(gostC, "127.0.0.1", "1053", "garbage!")
})
s.T().Run("container-alive-after-garbage", func(t *testing.T) {
// Verify the gost container is still alive after receiving
// invalid data.
aliveCode, _, aliveErr := gostC.Exec(s.ctx, []string{"true"})
s.Require().NoError(aliveErr,
"container exec should succeed after invalid query")
s.Require().Equal(0, aliveCode,
"gost container should be alive after invalid query")
})
}
// ---------------------------------------------------------------------------
// DNS over TLS
// ---------------------------------------------------------------------------
// TestDNSTLS verifies the DNS listener in TLS mode (DNS over TLS).
// Starts the UDP DNS responder (unencrypted), a gost DNS server with
// listener mode: tls, and queries through the TLS endpoint.
func (s *DNSSuite) TestDNSTLS() {
dnsC := s.startDNSResponder()
defer dnsC.Terminate(s.ctx)
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/dns/server_tls.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/dns_tls_query.py", ContainerFilePath: "/scripts/dns_tls_query.py", FileMode: 0644},
},
"1053/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Query via TLS
args := []string{"python3", "/scripts/dns_tls_query.py",
"127.0.0.1", "1053", "test.example.com", "A", "10.0.0.1"}
for i := range 5 {
code, out, err := gostC.Exec(s.ctx, args)
if err != nil {
s.T().Logf("tls query attempt %d exec error: %v", i+1, err)
time.Sleep(time.Second)
continue
}
body, err := io.ReadAll(out)
if err != nil {
s.T().Logf("tls query attempt %d read error: %v", i+1, err)
time.Sleep(time.Second)
continue
}
output := string(body)
s.T().Logf("tls query attempt %d: %s", i+1, strings.TrimSpace(output))
if code == 0 {
return
}
time.Sleep(time.Second)
}
s.T().Fatal("TLS query failed after 5 retries")
}
func TestDNSSuite(t *testing.T) {
suite.Run(t, new(DNSSuite))
}
+143
View File
@@ -0,0 +1,143 @@
package e2e
import (
"context"
"encoding/base64"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/moby/moby/client"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
type FailCodesSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
statusC testcontainers.Container
}
func (s *FailCodesSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
statusC, err := RunStatusBackendContainer(s.ctx, SharedNetworkName, 429)
s.Require().NoError(err)
s.statusC = statusC
}
func (s *FailCodesSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
if s.statusC != nil {
s.statusC.Terminate(s.ctx)
}
}
func (s *FailCodesSuite) sendRawHTTP(gostC testcontainers.Container, host, port string) string {
req := fmt.Sprintf(
"GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n",
s.echoIP,
)
encoded := base64.StdEncoding.EncodeToString([]byte(req))
cmd := []string{"sh", "-c",
fmt.Sprintf("echo %s | base64 -d | nc -w 5 %s %s", encoded, host, port)}
_, out, _ := gostC.Exec(s.ctx, cmd)
b, _ := io.ReadAll(out)
return string(b)
}
// TestFailCodesConvergence: reverse proxy (tcp handler + sniffing) with FIFO.
// node-429 is first. After the first 429 response, failCodes marks it (Count=1).
// FailFilter (maxFails=1) excludes it. All subsequent requests go to node-good.
func (s *FailCodesSuite) TestFailCodesConvergence() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/failcodes/failcodes.yaml",
nil,
"8080/tcp",
)
s.Require().NoError(err)
defer func() {
DumpLogs(s.T(), s.ctx, "gost-failcodes", gostC)
gostC.Terminate(s.ctx)
}()
time.Sleep(500 * time.Millisecond)
const totalRequests = 20
var successCount, failCount int
for range totalRequests {
body := s.sendRawHTTP(gostC, "127.0.0.1", "8080")
if strings.Contains(body, "hello-gost") {
successCount++
s.T().Logf(" ✓ 200 (node-good)")
} else if strings.Contains(body, "status-429") {
failCount++
s.T().Logf(" ✗ 429 (node-429)")
} else {
s.T().Logf(" ? %s", strings.TrimSpace(body)[:80])
}
}
s.T().Logf("Results: %d successes, %d failures out of %d requests",
successCount, failCount, totalRequests)
// After the first 429 marks node-429, FailFilter excludes it for 10s.
// At most 1-2 requests may fail before the marker is set.
s.Require().LessOrEqual(failCount, 2,
"failCodes: node-429 must be excluded after first 429 response. "+
"Got %d failures out of %d requests", failCount, totalRequests)
}
func TestFailCodesSuite(t *testing.T) {
suite.Run(t, new(FailCodesSuite))
}
func RunStatusBackendContainer(ctx context.Context, networkName string, statusCode int) (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: ".",
Dockerfile: "Dockerfile",
Repo: "gost-e2e",
Tag: "latest",
KeepImage: true,
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
opts.NetworkMode = "host"
},
},
Networks: []string{networkName},
NetworkAliases: map[string][]string{
networkName: {"status-backend"},
},
Files: []testcontainers.ContainerFile{
{HostFilePath: "scripts/http_status_backend.py", ContainerFilePath: "/scripts/http_status_backend.py", FileMode: 0644},
},
ExposedPorts: []string{"5680/tcp"},
Cmd: []string{
"python3", "/scripts/http_status_backend.py",
fmt.Sprintf("%d", statusCode), "5680",
},
WaitingFor: wait.ForExposedPort(),
}
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
}
+194
View File
@@ -0,0 +1,194 @@
package e2e
import (
"context"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type FileSuite struct {
suite.Suite
ctx context.Context
}
func (s *FileSuite) SetupSuite() {
s.ctx = context.Background()
}
func (s *FileSuite) TearDownSuite() {}
// TestFileGetExisting verifies that GET on an existing file returns
// the file content with HTTP 200. Covers the basic file serving path:
// file handler → http.FileServer → file read.
func (s *FileSuite) TestFileGetExisting() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/file/server.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/file/hello.txt", ContainerFilePath: "/srv/files/hello.txt", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/hello.txt"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost-file") {
DumpLogs(s.T(), s.ctx, "file-server logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost-file")
}
// TestFileGetNotFound verifies that GET on a nonexistent file returns
// 404 Not Found.
func (s *FileSuite) TestFileGetNotFound() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/file/server.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/file/hello.txt", ContainerFilePath: "/srv/files/hello.txt", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"http://127.0.0.1:8080/nonexistent.txt"}
_, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ := io.ReadAll(out)
s.Assert().Contains(string(body), "404")
}
// TestFileGetIndexHtml verifies that GET / serves index.html when
// present in the served directory. Covers the default index document
// behavior of http.FileServer.
func (s *FileSuite) TestFileGetIndexHtml() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/file/server.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/file/index.html", ContainerFilePath: "/srv/files/index.html", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "gost file index") {
DumpLogs(s.T(), s.ctx, "file-server index logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "gost file index")
}
// TestFilePutUpload verifies that PUT uploads a file when file.put is
// enabled. Uploads a file and then reads it back via GET to confirm
// the content was persisted.
func (s *FileSuite) TestFilePutUpload() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/file/server_put.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/file/.empty", ContainerFilePath: "/srv/files/.empty", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Create source content inside the container.
_, _, err = gostC.Exec(s.ctx, []string{
"sh", "-c", "printf 'uploaded-content' > /tmp/upload_src.txt",
})
s.Require().NoError(err)
// Upload via PUT.
_, out, err := gostC.Exec(s.ctx, []string{
"curl", "-v", "-s", "-T", "/tmp/upload_src.txt",
"http://127.0.0.1:8080/uploaded.txt",
})
s.Require().NoError(err)
putBody, _ := io.ReadAll(out)
s.T().Logf("PUT response:\n%s", string(putBody))
// Read back the uploaded file via GET.
_, out2, err := gostC.Exec(s.ctx, []string{
"curl", "-v", "-s",
"http://127.0.0.1:8080/uploaded.txt",
})
s.Require().NoError(err)
body, _ := io.ReadAll(out2)
s.Assert().Contains(string(body), "uploaded-content")
}
// TestFilePutNoPermission verifies that PUT returns 405 Method Not
// Allowed when file.put is not enabled (default: false).
func (s *FileSuite) TestFilePutNoPermission() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/file/server.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/file/.empty", ContainerFilePath: "/srv/files/.empty", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-T", "/dev/null", "http://127.0.0.1:8080/test.txt"}
_, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ := io.ReadAll(out)
s.Assert().Contains(string(body), "405")
}
// TestFileAuth verifies authentication on the file handler.
// Without credentials the handler returns 401 Unauthorized,
// with valid credentials it serves the file.
func (s *FileSuite) TestFileAuth() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/file/server_auth.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/file/hello.txt", ContainerFilePath: "/srv/files/hello.txt", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("no-auth-401", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"http://127.0.0.1:8080/hello.txt"}
_, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
s.Assert().Contains(string(body), "401")
})
s.T().Run("with-auth-success", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-u", "user:pass",
"http://127.0.0.1:8080/hello.txt"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost-file") {
DumpLogs(s.T(), s.ctx, "file-server auth logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost-file")
})
}
func TestFileSuite(t *testing.T) {
suite.Run(t, new(FileSuite))
}
+318
View File
@@ -0,0 +1,318 @@
package e2e
import (
"context"
"encoding/base64"
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type ForwardSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
udpC testcontainers.Container
}
func (s *ForwardSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
s.T().Logf("start udp echo container...")
udpC, err := RunUDPEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.udpC = udpC
}
// sendRaw sends a raw HTTP request via netcat and returns the response.
// Uses base64 to avoid shell quoting issues with CRLF bytes.
func (s *ForwardSuite) sendRaw(gostC testcontainers.Container, host, port, data string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(data))
cmd := []string{"sh", "-c",
fmt.Sprintf("echo %s | base64 -d | nc -w 5 %s %s", encoded, host, port)}
_, out, _ := gostC.Exec(s.ctx, cmd)
b, _ := io.ReadAll(out)
return string(b)
}
func (s *ForwardSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
if s.udpC != nil {
s.udpC.Terminate(s.ctx)
}
}
// TestTCPForward verifies basic TCP forward handler (handler type: tcp).
// The forward handler pipes raw TCP connections to the configured forwarder
// node (tcp-echo:5678). curl connects directly to the handler port and sends
// an HTTP request, expecting the echo server's "hello-gost" response.
func (s *ForwardSuite) TestTCPForward() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl directly to the handler port (not via -x proxy flag).
// The TCP forward handler pipes our connection to tcp-echo:5678.
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "tcp-forward logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestForwardAlias verifies that the "forward" handler type (alias for "tcp")
// works identically to the "tcp" handler type.
func (s *ForwardSuite) TestForwardAlias() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "forward-alias logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestTCPForwardSniffing verifies TCP forward handler with sniffing enabled.
// When sniffing is enabled and the connection starts with HTTP data, the handler
// detects the protocol via sniffing.Sniff and delegates to the HTTP sniffer
// for protocol-aware forwarding. The result is the same as raw forwarding:
// "hello-gost" from the echo server.
func (s *ForwardSuite) TestTCPForwardSniffing() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server_sniffing.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl directly — sniffing detects HTTP and handles via sniffer.
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "tcp-forward-sniffing logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestTCPForwardRaw verifies raw TCP forwarding by sending an HTTP request
// via netcat through the forward handler. This tests the handleRawForwarding
// code path (no sniffing), proving that raw bytes are piped through correctly.
func (s *ForwardSuite) TestTCPForwardRaw() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Send raw HTTP request via nc (not curl) to exercise the raw pipe path.
resp := s.sendRaw(gostC, "127.0.0.1", "8000",
"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n")
s.Assert().Contains(resp, "hello-gost",
"raw request through TCP forward should reach echo server")
}
// TestTCPForwardIdleTimeout verifies that idleTimeout closes the pipe after
// a period of inactivity. The forward handler's xnet.Pipe uses idleTimeout
// as a read deadline on the upstream connection — if no data flows for
// that duration, the pipe closes both directions.
func (s *ForwardSuite) TestTCPForwardIdleTimeout() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_idle_timeout.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/tcp_idle_timeout.py", ContainerFilePath: "/scripts/tcp_idle_timeout.py", FileMode: 0644},
},
"8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The Python script:
// 1. Connects to gost TCP forward port
// 2. Sends HTTP GET → expects "hello-gost" confirm pipe is alive
// 3. Waits > idleTimeout (3s + 2s buffer)
// 4. Sends more data — expects connection to be closed
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/tcp_idle_timeout.py",
"127.0.0.1", "8000", "3",
})
output, _ := io.ReadAll(out)
s.T().Logf("idle timeout output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "tcp-idle-timeout logs", gostC)
}
s.Require().Equal(0, code, "idle timeout test script should exit 0")
}
// TestUDPForward verifies basic UDP forwarding (handler: udp, listener: udp).
// The handler uses handleRawDatagram via the stateful UDP listener's
// per-client session conns (which implement net.PacketConn).
// A Python script inside the gost container sends a UDP datagram through
// the forward handler and verifies the echo response from udp-echo:5679.
func (s *ForwardSuite) TestUDPForward() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_udp.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/udp_forward_test.py", ContainerFilePath: "/scripts/udp_forward_test.py", FileMode: 0644},
},
"9000/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/udp_forward_test.py",
"127.0.0.1", "9000",
})
output, _ := io.ReadAll(out)
s.T().Logf("udp forward output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "udp-forward logs", gostC)
}
s.Require().Equal(0, code, "udp forward test script should exit 0")
}
// TestUDPForwardStateless verifies UDP forwarding with stateless mode.
// Both listener and handler use stateless: true, so each datagram is a
// single request-response cycle with no per-client session tracking.
func (s *ForwardSuite) TestUDPForwardStateless() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_udp_stateless.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/udp_forward_test.py", ContainerFilePath: "/scripts/udp_forward_test.py", FileMode: 0644},
},
"9000/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/udp_forward_test.py",
"127.0.0.1", "9000",
})
output, _ := io.ReadAll(out)
s.T().Logf("udp forward stateless output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "udp-forward-stateless logs", gostC)
}
s.Require().Equal(0, code, "udp forward stateless test script should exit 0")
}
// TestTCPForwardSniffingBypass verifies that when sniffing is enabled and a
// bypass rule blocks the target, the HTTP sniffer returns 403 Forbidden.
// The forward handler delegates to the HTTP sniffer (via handleSniffedProtocol),
// and the sniffer's resolveHTTPNode checks h.options.Bypass and returns 403
// when the destination is matched.
func (s *ForwardSuite) TestTCPForwardSniffingBypass() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server_bypass_sniffing.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl connects directly; sniffing detects HTTP. The sniffer bypass
// check matches 0.0.0.0/0 and returns 403 Forbidden.
cmd := []string{"curl", "-v", "-s", "-D", "-", "-o", "/dev/null",
"http://127.0.0.1:8000/"}
_, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ := io.ReadAll(out)
output := string(body)
s.Assert().Contains(output, "403",
"bypass should return 403 Forbidden from sniffer")
}
// TestTCPForwardMultiNodeProtocol verifies that sniffing + protocol-filtered
// forwarder nodes correctly routes an HTTP request to the node with matching
// protocol (http), rather than the one with protocol: tls.
//
// Config has two nodes:
// - echo-http (protocol: http) → tcp-echo:5678 (works, returns "hello-gost")
// - echo-tls (protocol: tls) → tcp-echo:1 (closed port, would fail)
//
// With sniffing enabled, an HTTP request is detected as protocol "http",
// Select("http") filters to only the echo-http node. If protocol filtering
// fails and the tls node is selected instead, the connection to port 1
// fails and the test fails.
func (s *ForwardSuite) TestTCPForwardMultiNodeProtocol() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server_multi_node.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl sends an HTTP request → sniffed as "http" → Select("http")
// filters to echo-http (protocol: http) → pipes to tcp-echo:5678
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "tcp-forward-multi-node logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost",
"HTTP request should route to the http-protocol node via protocol filtering")
}
// TestUDPForwardQUICSniffing verifies that the UDP forward handler with
// sniffing enabled correctly detects QUIC traffic and forwards the
// datagram. A pre-built QUIC Initial packet is sent through the proxy
// to the UDP echo server; the test passes when the echoed datagram
// comes back with the same length.
func (s *ForwardSuite) TestUDPForwardQUICSniffing() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_udp_sniffing_quic.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/udp_quic_forward_test.py", ContainerFilePath: "/scripts/udp_quic_forward_test.py", FileMode: 0644},
},
"9000/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/udp_quic_forward_test.py",
"127.0.0.1", "9000",
})
output, _ := io.ReadAll(out)
s.T().Logf("udp quic forward output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "udp-quic-forward logs", gostC)
}
s.Require().Equal(0, code, "udp quic forward test script should exit 0")
}
func TestForwardSuite(t *testing.T) {
suite.Run(t, new(ForwardSuite))
}
+85
View File
@@ -0,0 +1,85 @@
package e2e
import (
"context"
"fmt"
"io"
"os"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// HostsSuite verifies the static hosts mapping (HostMapper), which overrides
// DNS for matched hostnames. A mapped hostname resolves to the configured IP
// and reaches the echo server; an unmapped hostname fails to resolve.
type HostsSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
gostC testcontainers.Container
}
func (s *HostsSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
cfg, err := RenderConfig("testdata/hosts/hosts.yaml", ConfigData{ServerAddr: s.echoIP})
s.Require().NoError(err)
defer os.Remove(cfg)
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, "8080/tcp")
s.Require().NoError(err)
s.gostC = gostC
}
func (s *HostsSuite) TearDownSuite() {
if s.gostC != nil {
s.gostC.Terminate(s.ctx)
}
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// proxyRequest sends a request to the given hostname through the gost proxy and
// returns the response body. A resolvable host that reaches the echo server
// returns "hello-gost"; an unresolvable host returns an empty body.
func (s *HostsSuite) proxyRequest(host string) string {
cmd := []string{
"curl", "-s",
"-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", host),
}
_, out, err := s.gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestMappedHostname verifies that a hostname in the hosts mapping resolves to
// the configured IP and reaches the echo server.
func (s *HostsSuite) TestMappedHostname() {
s.Require().Contains(s.proxyRequest("echo.internal"), "hello-gost")
}
// TestUnmappedHostname verifies that a hostname absent from the mapping fails to
// resolve, so the request never reaches the echo server.
func (s *HostsSuite) TestUnmappedHostname() {
s.Require().NotContains(s.proxyRequest("nomap.internal"), "hello-gost")
}
func TestHostsSuite(t *testing.T) {
suite.Run(t, new(HostsSuite))
}
+216
View File
@@ -0,0 +1,216 @@
package e2e
import (
"context"
"fmt"
"io"
"os"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// HTTP2Suite covers the HTTP/2 proxy handler (handler type: http2) together
// with the HTTP/2 listener (listener type: http2). The http2 listener wraps
// the underlying TCP listener with TLS and configures an h2 server, so the
// handler is always reached over HTTP/2 frames.
//
// Because Alpine's curl cannot be relied on to speak HTTP/2 proxy to the
// server directly, the suite uses the canonical GOST chaining pattern: a
// client container exposes a plain HTTP proxy which forwards through an
// http2 connector + http2 dialer to the http2 server. This exercises the
// h2 listener, the h2 handler, and the h2 connector/dialer together.
type HTTP2Suite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *HTTP2Suite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *HTTP2Suite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// startChain brings up an http2 server (alias "h2-server") and an http client
// container that chains to it through connector/dialer type http2. The client
// exposes port 8080 for curl. The rendered client config is cleaned up by the
// caller-supplied template path resolving {{.ServerAddr}} to h2-server:8443.
func (s *HTTP2Suite) startChain(serverYAML, clientTmpl string) (testcontainers.Container, testcontainers.Container) {
s.T().Helper()
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
serverYAML, []string{"h2-server"}, []string{"8443/tcp"})
s.Require().NoError(err)
rendered, err := RenderConfig(clientTmpl, ConfigData{ServerAddr: "h2-server:8443"})
s.Require().NoError(err)
s.T().Cleanup(func() { os.Remove(rendered) })
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
return serverC, clientC
}
// curlEcho runs curl through the client's local http proxy and returns the
// process exit code plus the captured body.
func (s *HTTP2Suite) curlEcho(clientC testcontainers.Container) (int, string) {
s.T().Helper()
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, _ := clientC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
return code, string(body)
}
func (s *HTTP2Suite) dump(label string, cs ...testcontainers.Container) {
for _, c := range cs {
DumpLogs(s.T(), s.ctx, label, c)
}
}
// TestHTTP2ForwardProxy verifies the core HTTP/2 proxy path: a plain HTTP
// request through the client proxy is tunneled to the http2 server via an h2
// CONNECT stream and reaches the echo backend.
//
// Covers:
// - listener type: http2 (TLS + h2 server)
// - handler type: http2 (CONNECT tunnel + bidirectional pipe)
// - connector type: http2, dialer type: http2 (h2 client)
func (s *HTTP2Suite) TestHTTP2ForwardProxy() {
serverC, clientC := s.startChain("testdata/http2/server.yaml", "testdata/http2/client.yaml")
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
code, body := s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("http2-forward logs", clientC, serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(body, "hello-gost")
}
// TestHTTP2Auth verifies proxy authentication over the h2 tunnel, plus
// authBasicRealm and hash metadata parsing on the http2 handler.
//
// - with auth: CONNECT succeeds → echo body returned
// - without auth: server rejects CONNECT (407) → client cannot reach echo
func (s *HTTP2Suite) TestHTTP2Auth() {
s.T().Run("with-auth-success", func(t *testing.T) {
serverC, clientC := s.startChain("testdata/http2/server_auth.yaml", "testdata/http2/client_auth.yaml")
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
code, body := s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("http2-auth-success logs", clientC, serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(body, "hello-gost")
})
s.T().Run("no-auth-fails", func(t *testing.T) {
// Server requires auth; client sends none → the http2 CONNECT is
// rejected with 407, so curl cannot obtain the echo body.
serverC, clientC := s.startChain("testdata/http2/server_auth.yaml", "testdata/http2/client.yaml")
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
code, body := s.curlEcho(clientC)
if strings.Contains(body, "hello-gost") {
s.dump("http2-auth-noauth logs", clientC, serverC)
}
s.Require().Zero(code,
"curl should still receive an HTTP response from the local proxy")
s.Require().NotContains(body, "hello-gost",
"without auth the echo backend must be unreachable")
})
}
// TestHTTP2Bypass verifies that a bypass matcher on the http2 handler blocks
// the CONNECT target after authentication succeeds. The server returns 403, so
// the client cannot reach the echo backend.
func (s *HTTP2Suite) TestHTTP2Bypass() {
serverC, clientC := s.startChain("testdata/http2/server_bypass.yaml", "testdata/http2/client_auth.yaml")
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
code, body := s.curlEcho(clientC)
if strings.Contains(body, "hello-gost") {
s.dump("http2-bypass logs", clientC, serverC)
}
s.Require().Zero(code,
"curl should still receive an HTTP response from the local proxy")
s.Require().NotContains(body, "hello-gost",
"bypass should prevent reaching the echo backend")
}
// TestHTTP2ProbeResist verifies that probeResist, header and authBasicRealm
// metadata parse cleanly on the http2 handler and do not break the success
// path. With correct credentials the request still reaches the echo backend.
func (s *HTTP2Suite) TestHTTP2ProbeResist() {
serverC, clientC := s.startChain("testdata/http2/server_proberesist.yaml", "testdata/http2/client_auth.yaml")
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
code, body := s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("http2-proberesist logs", clientC, serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(body, "hello-gost")
}
// TestHTTP2Multiplex verifies HTTP/2 stream multiplexing: many concurrent
// requests through the client proxy are tunneled as parallel h2 streams over a
// single underlying connection to the http2 server. All requests must reach
// the echo backend.
func (s *HTTP2Suite) TestHTTP2Multiplex() {
serverC, clientC := s.startChain("testdata/http2/server.yaml", "testdata/http2/client.yaml")
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
const n = 6
var wg sync.WaitGroup
wg.Add(n)
failed := make(chan string, n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
_, body := s.curlEcho(clientC)
if !strings.Contains(body, "hello-gost") {
failed <- "missing echo body"
}
}()
}
wg.Wait()
close(failed)
if len(failed) > 0 {
s.dump("http2-multiplex logs", clientC, serverC)
}
s.Require().Empty(failed, "all concurrent h2 requests should reach the echo backend")
}
func TestHTTP2Suite(t *testing.T) {
suite.Run(t, new(HTTP2Suite))
}
+257
View File
@@ -0,0 +1,257 @@
package e2e
import (
"context"
"io"
"strings"
"testing"
"time"
"github.com/moby/moby/client"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
type HTTPCacheSuite struct {
suite.Suite
ctx context.Context
beC testcontainers.Container
staleC testcontainers.Container
}
func (s *HTTPCacheSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Log("start http cache backend container...")
beC, err := RunCacheBackendContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.beC = beC
s.T().Log("start http cache serve-stale backend container...")
staleC, err := RunServeStaleBackendContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.staleC = staleC
}
func (s *HTTPCacheSuite) TearDownSuite() {
if s.beC != nil {
s.beC.Terminate(s.ctx)
}
if s.staleC != nil {
s.staleC.Terminate(s.ctx)
}
}
// TestHTTPCacheHit verifies that the HTTP response cache returns the cached
// response on repeat requests. The test backend returns a monotonically
// increasing counter. With cache enabled, every request returns the same
// counter value (the cached response from the first request).
func (s *HTTPCacheSuite) TestHTTPCacheHit() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http_cache/server_cache.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
// First request — cache miss, backend returns "cache-test-N"
code, out, err := ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body1, _ := io.ReadAll(out)
if code != 0 || !strings.Contains(string(body1), "cache-test-") {
DumpLogs(s.T(), s.ctx, "cache-proxy logs (first req)", gostC)
}
s.Require().Equal(0, code, "first request should succeed")
// The backend counter is shared across subtests (a fresh counter backend is
// not started per test), so the first request here is not necessarily #1.
// Caching is proven by body2==body1 and body3==body1 below.
s.Require().Contains(string(body1), "cache-test-",
"first request should get a backend response")
// Second request — cache hit, same response
code, out, err = ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body2, _ := io.ReadAll(out)
if code != 0 {
DumpLogs(s.T(), s.ctx, "cache-proxy logs (second req)", gostC)
}
s.Require().Equal(0, code, "second request should succeed")
s.Require().Equal(string(body1), string(body2),
"cached response should equal first response")
// Third request — cache hit, same
code, out, err = ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body3, _ := io.ReadAll(out)
if code != 0 {
DumpLogs(s.T(), s.ctx, "cache-proxy logs (third req)", gostC)
}
s.Require().Equal(0, code, "third request should succeed")
s.Require().Equal(string(body1), string(body3),
"cached response should be consistent")
}
// TestHTTPCacheDisabled verifies that without a named cache, every request
// reaches the backend and gets a fresh response (monotonically increasing
// counter). This is the control test: it proves the counting backend works
// and that the cache test actually validates caching, not incidental
// idempotency of the backend.
func (s *HTTPCacheSuite) TestHTTPCacheDisabled() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http_cache/server_nocache.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
code, out, err := ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body1, _ := io.ReadAll(out)
if code != 0 || !strings.Contains(string(body1), "cache-test-") {
DumpLogs(s.T(), s.ctx, "no-cache proxy logs (first req)", gostC)
}
s.Require().Equal(0, code)
code, out, err = ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body2, _ := io.ReadAll(out)
if code != 0 {
DumpLogs(s.T(), s.ctx, "no-cache proxy logs (second req)", gostC)
}
s.Require().Equal(0, code)
// Without a named cache, each request hits the backend directly.
// The backend increments a counter per request, so consecutive
// requests return different bodies.
s.Require().NotEqual(string(body1), string(body2),
"without cache, consecutive requests should return different responses")
}
// TestHTTPCacheServeStale verifies that a stale (expired) cached response is
// served when the upstream fetch fails and cache.serveStale is enabled.
//
// The serve-stale backend serves one HTTP request, then shuts down its HTTP
// server and switches to accepting TCP connections and immediately closing
// them. This makes the upstream dial succeed but the HTTP response read fail,
// which triggers the serve-stale path.
//
// Steps:
// 1. First request: cache miss, backend responds → cache stores the response
// with TTL 3s. The backend then switches to connection-dropping mode.
// 2. Wait 4s for the cache entry to expire.
// 3. Second request: cache hit (stale), dial succeeds (TCP acceptor accepts),
// but no HTTP response arrives → http.ReadResponse fails → serveStale
// returns the cached "cache-test-1".
func (s *HTTPCacheSuite) TestHTTPCacheServeStale() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http_cache/server_cache_servestale.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
// First request — cache miss, backend returns "cache-test-1"
code, out, err := ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body1, _ := io.ReadAll(out)
if code != 0 || !strings.Contains(string(body1), "cache-test-") {
DumpLogs(s.T(), s.ctx, "serve-stale logs (first req)", gostC)
}
s.Require().Equal(0, code, "first request should succeed")
s.Require().Contains(string(body1), "cache-test-1",
"first request should get backend response #1")
// At this point the backend has shut down its HTTP server and switched to
// connection-dropping mode. Wait for cache TTL (3s) to expire.
time.Sleep(4 * time.Second)
// Second request — stale cache hit, upstream connects but yields no
// response → serve-stale returns the expired cached response.
code, out, err = ExecOutput(s.ctx, gostC, cmd)
s.Require().NoError(err)
body2, _ := io.ReadAll(out)
if code != 0 || !strings.Contains(string(body2), "cache-test-") {
DumpLogs(s.T(), s.ctx, "serve-stale logs (second req)", gostC)
}
s.Require().Equal(0, code, "serve-stale should return the cached response")
s.Require().Equal(string(body1), string(body2),
"serve-stale should return the expired cached response")
}
func TestHTTPCacheSuite(t *testing.T) {
suite.Run(t, new(HTTPCacheSuite))
}
// RunCacheBackendContainer starts a container running the cache test HTTP server
// that returns a monotonically increasing counter in each response body.
func RunCacheBackendContainer(ctx context.Context, networkName string) (testcontainers.Container, error) {
req := cacheBackendContainerRequest(ctx, networkName)
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
}
func cacheBackendContainerRequest(_ context.Context, networkName string) testcontainers.ContainerRequest {
return testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: ".",
Dockerfile: "Dockerfile",
Repo: "gost-e2e",
Tag: "latest",
KeepImage: true,
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
opts.NetworkMode = "host"
},
},
Networks: []string{networkName},
NetworkAliases: map[string][]string{
networkName: {"cache-backend"},
},
Files: []testcontainers.ContainerFile{
{HostFilePath: "scripts/http_cache_backend.py", ContainerFilePath: "/scripts/http_cache_backend.py", FileMode: 0644},
},
ExposedPorts: []string{"5677/tcp"},
Cmd: []string{"python3", "/scripts/http_cache_backend.py"},
WaitingFor: wait.ForExposedPort(),
}
}
// RunServeStaleBackendContainer starts a container running the serve-stale test
// HTTP server. It serves one request with a counter body, then replaces itself
// with a raw TCP socket that accepts connections and immediately closes them.
// This makes upstream dials succeed but HTTP response reads fail, which
// triggers the serve-stale path in the sniffer.
func RunServeStaleBackendContainer(ctx context.Context, networkName string) (testcontainers.Container, error) {
req := serveStaleBackendContainerRequest(ctx, networkName)
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
}
func serveStaleBackendContainerRequest(_ context.Context, networkName string) testcontainers.ContainerRequest {
return testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: ".",
Dockerfile: "Dockerfile",
Repo: "gost-e2e",
Tag: "latest",
KeepImage: true,
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
opts.NetworkMode = "host"
},
},
Networks: []string{networkName},
NetworkAliases: map[string][]string{
networkName: {"cache-servestale"},
},
Files: []testcontainers.ContainerFile{
{HostFilePath: "scripts/http_cache_servestale_backend.py", ContainerFilePath: "/scripts/http_cache_servestale_backend.py", FileMode: 0644},
},
ExposedPorts: []string{"5676/tcp"},
Cmd: []string{"python3", "/scripts/http_cache_servestale_backend.py"},
WaitingFor: wait.ForExposedPort(),
}
}
+108
View File
@@ -0,0 +1,108 @@
package e2e
import (
"context"
"encoding/base64"
"encoding/binary"
"fmt"
"hash/crc32"
"io"
"os"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// HTTPPolicyBypassSuite reproduces the destination-policy bypass where a valid
// Gost-Target header replaces req.Host (evaluated by the bypass) but, before the
// fix, left req.URL.Host (dialed by the transport) as the absolute-URL host.
// The echo server stands in for the protected backend.
type HTTPPolicyBypassSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *HTTPPolicyBypassSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *HTTPPolicyBypassSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// sendRaw sends a raw HTTP request to the proxy inside the gost container.
func (s *HTTPPolicyBypassSuite) sendRaw(gostC testcontainers.Container, data string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(data))
cmd := []string{"sh", "-c",
fmt.Sprintf("echo %s | base64 -d | nc -w 5 127.0.0.1 8080", encoded)}
_, out, _ := gostC.Exec(s.ctx, cmd)
b, _ := io.ReadAll(out)
return string(b)
}
// encodeTarget encodes a host:port in the GOST v2 Gost-Target format:
// raw-URL-base64( big-endian-CRC32(name) + raw-URL-base64(name) ).
func encodeTarget(name string) string {
v := []byte(name)
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, crc32.ChecksumIEEE(v))
inner := base64.RawURLEncoding.EncodeToString(v)
return base64.RawURLEncoding.EncodeToString(append(b, []byte(inner)...))
}
// TestGostTargetPolicyBypass asserts that a Gost-Target header naming an
// allowed authority cannot route a request to a blocked absolute-URL authority.
func (s *HTTPPolicyBypassSuite) TestGostTargetPolicyBypass() {
cfg, err := RenderConfig("testdata/http/server_policy_bypass.yaml", ConfigData{ServerAddr: s.echoIP})
s.Require().NoError(err)
defer os.Remove(cfg)
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
target := fmt.Sprintf("http://%s:5678/", s.echoIP)
marker := "hello-gost"
// Control: absolute-form request to the blocked address, no target header.
// The bypass must deny it before it reaches the echo server.
control := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s:5678\r\nConnection: close\r\n\r\n",
target, s.echoIP)
out := s.sendRaw(gostC, control)
s.Require().Contains(out, "403", "control request to blocked address should be denied")
s.Require().NotContains(out, marker, "control request must not reach the backend")
// Exploit: same request plus a valid Gost-Target header naming an allowed
// authority. Before the fix the transport dials the URL host (echo server)
// while the policy checks the header host, leaking the backend response.
exploit := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s:5678\r\nGost-Target: %s\r\nConnection: close\r\n\r\n",
target, s.echoIP, encodeTarget("allowed.example.com:80"))
out = s.sendRaw(gostC, exploit)
s.Require().NotContains(out, marker,
"Gost-Target header must not bypass the destination policy to reach the blocked backend")
// Fail-closed control: a malformed target header is ignored, so the request
// is still evaluated (and denied) against the absolute-URL authority.
malformed := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s:5678\r\nGost-Target: %s\r\nConnection: close\r\n\r\n",
target, s.echoIP, "not-a-valid-target")
out = s.sendRaw(gostC, malformed)
s.Require().Contains(out, "403", "malformed target header must be ignored and the request denied")
s.Require().NotContains(out, marker, "malformed target header must not reach the backend")
}
func TestHTTPPolicyBypassSuite(t *testing.T) {
suite.Run(t, new(HTTPPolicyBypassSuite))
}
+627
View File
@@ -0,0 +1,627 @@
package e2e
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type HTTPSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *HTTPSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
// sendRaw sends a raw HTTP request via netcat and returns the response.
// Uses base64 to avoid shell quoting issues with CRLF bytes.
func (s *HTTPSuite) sendRaw(gostC testcontainers.Container, host, port, data string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(data))
cmd := []string{"sh", "-c",
fmt.Sprintf("echo %s | base64 -d | nc -w 5 %s %s", encoded, host, port)}
_, out, _ := gostC.Exec(s.ctx, cmd)
b, _ := io.ReadAll(out)
return string(b)
}
func (s *HTTPSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// TestHTTPProxy verifies basic HTTP forward proxy (no auth, no metadata).
// Covers: handler type http, listener type tcp, basic GET via proxy.
func (s *HTTPSuite) TestHTTPProxy() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl -x uses HTTP forward proxy (GET with absolute URL)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-proxy logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestHTTPProxyAuth verifies proxy authentication and the authBasicRealm
// metadata parameter.
//
// Covers:
// - auther: basic auth on HTTP proxy
// - authBasicRealm: custom realm string in 407 Proxy-Authenticate header
func (s *HTTPSuite) TestHTTPProxyAuth() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_auth.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("no-auth-407", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-D", "-", "-o", "/dev/null",
"-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
_, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
output := string(body)
s.Assert().Contains(output, "407",
"no auth should return 407")
s.Assert().Contains(output, "gost-e2e-realm",
"authBasicRealm should appear in 407 Proxy-Authenticate")
})
s.T().Run("wrong-auth-407", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-x", "http://wrong:pass@127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
_, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
s.Assert().Contains(string(body), "407",
"wrong password should return 407")
})
s.T().Run("with-auth-success", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-x", "http://user:pass@127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-proxy-auth logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
}
// TestHTTPProxyMetadata verifies:
// - probeResist: code:404 — unauthorised clients see 404 instead of 407
// - keepalive: config parsing (applied at Init time)
// - compression: config parsing (applied at Init time)
func (s *HTTPSuite) TestHTTPProxyMetadata() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_metadata.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("probe-resist-404", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
_, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
output := string(body)
s.Assert().NotContains(output, "407",
"probeResist should hide 407 status")
s.Assert().Contains(output, "404",
"probeResist should return configured decoy code")
})
s.T().Run("with-auth-success", func(t *testing.T) {
cmd := []string{"curl", "-v", "-s", "-x", "http://user:pass@127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-metadata logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
}
// TestHTTPProxyHeaders verifies:
// - header: custom response headers (X-Proxy-Info, X-Custom) on
// proxy-originated error responses. The header metadata is set on
// the skeleton response used for error paths after authentication.
// - proxyAgent: custom Proxy-Agent header in proxy responses.
//
// Uses a bypass matcher (0.0.0.0/0) that blocks all traffic after auth
// succeeds → requests hit 403 Forbidden path which carries custom headers.
func (s *HTTPSuite) TestHTTPProxyHeaders() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_headers.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Auth succeeds, but target is bypassed → 403 Forbidden
// The 403 response is built from the skeleton resp which
// carries h.md.header (custom headers) and h.md.proxyAgent.
s.T().Log("bypass: expect 403 with custom headers")
cmd := []string{"curl", "-v", "-s", "-D", "-", "-o", "/dev/null",
"-x", "http://user:pass@127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
_, out, _ := gostC.Exec(s.ctx, cmd)
headers, _ := io.ReadAll(out)
output := string(headers)
// Bypass → 403 Forbidden
s.Assert().Contains(output, "403",
"bypass should return 403")
// header: custom X-Proxy-Info header on error response
s.Assert().Contains(output, "X-Proxy-Info: gost-e2e",
"custom header should appear in 403 response")
// header: custom X-Custom header on error response
s.Assert().Contains(output, "X-Custom: test-value",
"custom header should appear in 403 response")
// proxyAgent: custom Proxy-Agent header on error response
s.Assert().Contains(output, "Proxy-Agent: gost-e2e/1.0",
"proxyAgent should appear in 403 response")
}
// TestHTTPConnect verifies HTTP CONNECT tunnel:
// - sniffing-enabled: sniffing:true + sniffing.timeout:2s
// - no-sniffing: plain CONNECT (default, no metadata)
// - bypass-403: CONNECT blocked by (0.0.0.0/0) bypass
//
// Uses curl --proxytunnel (-p) to force CONNECT method instead of GET.
func (s *HTTPSuite) TestHTTPConnect() {
s.T().Run("sniffing-enabled", func(t *testing.T) {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_connect.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-p", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-connect sniffing logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
s.T().Run("no-sniffing", func(t *testing.T) {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-p", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-connect no-sniffing logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
s.T().Run("bypass-403", func(t *testing.T) {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_connect_bypass.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-D", "-", "-o", "/dev/null",
"-p", "-x", "http://user:pass@127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
_, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
output := string(body)
s.Assert().Contains(output, "403",
"CONNECT bypass should return 403")
})
}
// TestHTTPProxyDirectRequest verifies that a non-proxy-form HTTP request
// (raw GET / sent to the proxy port) is rejected with 400 Bad Request.
// A raw request with empty Host header has no valid scheme to infer,
// so the handler returns 400.
func (s *HTTPSuite) TestHTTPProxyDirectRequest() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
"GET / HTTP/1.0\r\nHost: bad!\r\n\r\n")
s.Assert().Contains(resp, "400",
"non-proxy GET should return 400")
}
// TestHTTPProxyPOST verifies that POST method forwarding works through
// the HTTP forward proxy. The echo server only handles GET, so the proxy
// should forward the POST request and return whatever the upstream sends
// back (501 Not Implemented from the echo server — not a proxy error).
// 501 is the real upstream response, proving the proxy forwarded it.
func (s *HTTPSuite) TestHTTPProxyPOST() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678/", s.echoIP)}
_, out, _ := gostC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
output := string(body)
// The echo server's BaseHTTPRequestHandler defaults to 501 for POST.
// If we get 501 through the proxy, it means the method was forwarded.
s.Assert().Contains(output, "501",
"POST through proxy should be forwarded, echo server returns 501")
}
// TestHTTPConnectUnreachable verifies that CONNECT to an unreachable target
// returns 503 Service Unavailable.
func (s *HTTPSuite) TestHTTPConnectUnreachable() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_connect.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Send a raw CONNECT request to a port where nothing is listening.
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
fmt.Sprintf("CONNECT %s:1 HTTP/1.0\r\nHost: %s:1\r\n\r\n", s.echoIP, s.echoIP))
s.Assert().Contains(resp, "503",
"CONNECT to unreachable target should return 503")
}
// TestHTTPConnector verifies HTTP connector type: a gost client uses an HTTP
// connector to forward through an upstream HTTP proxy (no auth), reaching the
// target via the proxy chain. This covers the server+client two-container
// pattern where the client connects to the server via connector type: http.
func (s *HTTPSuite) TestHTTPConnector() {
// Start upstream HTTP proxy server (no auth).
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", []string{"http-server"}, []string{"8080/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
// Render client config that chains through the upstream server.
rendered, err := RenderConfig("testdata/http/client_connector.yaml",
ConfigData{ServerAddr: "http-server:8080"})
s.Require().NoError(err)
defer os.Remove(rendered)
// Start client with the rendered config.
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
// Request through client proxy → HTTP connector → upstream HTTP server → target.
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-connector client logs", clientC)
DumpLogs(s.T(), s.ctx, "http-connector server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestHTTPConnectorAuth verifies HTTP connector with authentication:
// no-auth → 407, correct auth → success.
func (s *HTTPSuite) TestHTTPConnectorAuth() {
// Start upstream HTTP proxy with auth and network alias.
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/http/server_auth.yaml", []string{"http-server"}, []string{"8080/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
s.T().Run("no-auth-407", func(t *testing.T) {
// Send raw request directly to server container (no auth) → 407.
resp := s.sendRaw(serverC, "127.0.0.1", "8080",
"GET http://127.0.0.1:5678/ HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
s.Assert().Contains(resp, "407",
"no auth via upstream should return 407")
})
s.T().Run("correct-auth-success", func(t *testing.T) {
rendered, err := RenderConfig("testdata/http/client_connector_auth.yaml",
ConfigData{ServerAddr: "http-server:8080"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-connector-auth client logs", clientC)
DumpLogs(s.T(), s.ctx, "http-connector-auth server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
}
// TestHTTPProbeResistHost verifies probeResist host type.
// When auth fails, the handler pipes the raw request to
// the decoy host (tcp-echo:5678). The TCP echo server echoes
// back the request bytes rather than returning a 407.
func (s *HTTPSuite) TestHTTPProbeResistHost() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_proberesist_host.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Without auth, probeResist host pipes the raw request to tcp-echo:5678.
// The HTTP echo server returns 200 OK with "hello-gost", proving the
// request reached the decoy host rather than getting a 407 response.
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
"GET http://127.0.0.1:5678/ HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
s.Assert().NotContains(resp, "407",
"probeResist host should hide 407 response")
s.Assert().Contains(resp, "hello-gost",
"probeResist host should pipe request to decoy host")
}
// TestHTTPProbeResistKnock verifies probeResist knock mechanism.
// Clients that connect with a recognized knock Host header
// get a normal 407 auth challenge instead of the decoy response.
//
// Uses server_proberesist_knock.yaml:
// - probeResist: code:404 (decoy 404 for unknown hosts)
// - knock: secret.example.com (known hosts get normal 407)
func (s *HTTPSuite) TestHTTPProbeResistKnock() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_proberesist_knock.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
s.T().Run("unknown-host-decoy", func(t *testing.T) {
// URL hostname doesn't match knock list → probeResist fires → 404
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
"GET http://127.0.0.1:5678/ HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
s.Assert().Contains(resp, "404",
"unknown host should get decoy 404")
s.Assert().NotContains(resp, "407",
"unknown host should NOT get 407")
})
s.T().Run("knock-host-407", func(t *testing.T) {
// URL hostname matches knock list → normal 407 auth challenge.
// Note: knock checks req.URL.Hostname(), not the Host header.
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
"GET http://secret.example.com:5678/ HTTP/1.0\r\nHost: secret.example.com\r\n\r\n")
s.Assert().Contains(resp, "407",
"knock host should get normal 407")
})
}
// TestHTTPConnectorTLS verifies HTTP connector using TLS dialer
// to upstream HTTPS proxy (listener type: tls).
// The TLS listener auto-generates a self-signed cert;
// the TLS dialer defaults to InsecureSkipVerify=true, so
// handshake succeeds without explicit cert configuration.
func (s *HTTPSuite) TestHTTPConnectorTLS() {
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/http/server_tls.yaml", []string{"tls-server"}, []string{"8443/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
rendered, err := RenderConfig("testdata/http/client_connector_tls.yaml",
ConfigData{ServerAddr: "tls-server:8443"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "http-connector-tls client logs", clientC)
DumpLogs(s.T(), s.ctx, "http-connector-tls server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestHTTPProbeResistWeb verifies probeResist web type.
// On auth failure, the handler fetches the decoy URL
// (tcp-echo:5678) and returns its response body.
func (s *HTTPSuite) TestHTTPProbeResistWeb() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server_proberesist_web.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Without auth, probeResist web fetches http://tcp-echo:5678
// and returns the echo server's response ("hello-gost").
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
"GET http://127.0.0.1:5678/ HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
s.Assert().NotContains(resp, "407",
"probeResist web should hide 407 response")
s.Assert().Contains(resp, "hello-gost",
"probeResist web should return decoy response body")
}
// TestHTTPProbeResistFile verifies probeResist file type.
// On auth failure, the handler reads a local file and
// returns its contents as the HTTP response body.
func (s *HTTPSuite) TestHTTPProbeResistFile() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/http/server_proberesist_file.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "testdata/http/decoy.html", ContainerFilePath: "/tmp/decoy.html", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Without auth, probeResist file reads /tmp/decoy.html and
// returns "decoy-response" rather than a 407.
resp := s.sendRaw(gostC, "127.0.0.1", "8080",
"GET http://127.0.0.1:5678/ HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
s.Assert().NotContains(resp, "407",
"probeResist file should hide 407 response")
s.Assert().Contains(resp, "decoy-response",
"probeResist file should return decoy file content")
}
// TestHTTPIdleTimeout verifies idleTimeout on CONNECT tunnels.
// After the configured idle timeout, the pipe between client
// and target should close.
func (s *HTTPSuite) TestHTTPIdleTimeout() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/http/server_idle_timeout.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/http_idle_timeout.py", ContainerFilePath: "/scripts/http_idle_timeout.py", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The Python script:
// 1. Opens a CONNECT tunnel to tcp-echo:5678
// 2. Sends and receives data (confirm tunnel is alive)
// 3. Waits > idleTimeout (3s + 2s buffer)
// 4. Sends more data — expects connection to be closed
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/http_idle_timeout.py",
"127.0.0.1", "8080", "3",
})
output, _ := io.ReadAll(out)
s.T().Logf("idle timeout output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "http-idle-timeout logs", gostC)
}
s.Require().Equal(0, code, "idle timeout test script should exit 0")
}
// TestHTTPUDPRelay verifies UDP relay over HTTP. Uses
// X-Gost-Protocol: udp in the CONNECT request to establish
// a SOCKS5 UDP tunnel through the HTTP handler.
func (s *HTTPSuite) TestHTTPUDPRelay() {
// Start UDP echo container on the shared network.
udpC, err := RunUDPEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
defer udpC.Terminate(s.ctx)
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/http/server_udp.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/http_udp_relay.py", ContainerFilePath: "/scripts/http_udp_relay.py", FileMode: 0644},
},
"8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The Python script:
// 1. Connects to gost HTTP proxy
// 2. Sends CONNECT with X-Gost-Protocol: udp
// 3. After 200 OK, sends a SOCKS5 UDP frame targeting udp-echo:5679
// 4. Reads back the echoed frame
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/http_udp_relay.py",
"127.0.0.1", "8080",
})
output, _ := io.ReadAll(out)
s.T().Logf("udp relay output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "http-udp logs", gostC)
}
s.Require().Equal(0, code, "udp relay test script should exit 0")
}
// TestHTTPProxyOriginForm verifies the nginx proxy_pass scenario
// (go-gost/gost#679): an upstream client sends an origin-form request
// ("GET / HTTP/1.1" with only a Host header) instead of the proxy-form
// absolute URL. The proxy must resolve the target from the Host header.
func (s *HTTPSuite) TestHTTPProxyOriginForm() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/http/server.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Origin-form request exactly as nginx proxy_pass emits it, with the
// Host header pointing at the echo server via its network alias
// ("tcp-echo", registered on SharedNetworkName).
req := "GET / HTTP/1.1\r\nHost: tcp-echo:5678\r\nConnection: close\r\n\r\n"
out := s.sendRaw(gostC, "127.0.0.1", "8080", req)
if !strings.Contains(out, "200") {
DumpLogs(s.T(), s.ctx, "http-proxy-origin-form logs", gostC)
}
s.Assert().Contains(out, "200 OK", "origin-form request should be proxied by Host header")
s.Assert().Contains(out, "hello-gost", "echo server response body should come through")
}
func TestHTTPSuite(t *testing.T) {
suite.Run(t, new(HTTPSuite))
}
+90
View File
@@ -0,0 +1,90 @@
package e2e
import (
"context"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// IngressSuite verifies hostname→endpoint routing at the reverse-proxy tunnel
// entrypoint. A public gost runs a tunnel handler with an ingress mapping
// example.local→tunnel-UUID and an HTTP entrypoint on :8420. An internal client
// binds a reverse tunnel (tunnel.id=UUID) that forwards to the echo server.
//
// A request to the entrypoint with Host:example.local is routed via the ingress
// table to the tunnel and reaches the echo server ("hello-gost"); a request
// with an unmapped Host matches no ingress rule and is rejected with no route.
type IngressSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
serverC testcontainers.Container
clientC testcontainers.Container
}
func (s *IngressSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/ingress/server.yaml", []string{"gost-server"}, []string{"8420/tcp"})
s.Require().NoError(err)
s.serverC = serverC
clientC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/ingress/client.yaml", []string{"gost-client"}, []string{"8423/tcp"})
s.Require().NoError(err)
s.clientC = clientC
}
func (s *IngressSuite) TearDownSuite() {
for _, c := range []testcontainers.Container{s.clientC, s.serverC, s.echoC} {
if c != nil {
c.Terminate(s.ctx)
}
}
}
// request sends an HTTP GET to the server entrypoint with the given Host
// header. When waitTunnel is true it retries until the reverse tunnel is bound
// (the mapped-host happy path); otherwise it makes a single attempt (the
// unmapped-host case, which should never reach the echo server). Returns the
// response body.
func (s *IngressSuite) request(host string, waitTunnel bool) string {
loop := "for i in $(seq 1 30); do "
if !waitTunnel {
loop = "for i in 1; do "
}
cmd := []string{
"sh", "-c",
fmt.Sprintf("%sbody=$(curl -s -H 'Host: %s' http://gost-server:8420/); echo \"$body\" | grep -q hello-gost && { echo \"$body\"; exit 0; }; sleep 1; done; echo \"$body\"", loop, host),
}
_, out, err := s.echoC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestMappedHostname verifies that a Host matching an ingress rule is routed
// through the tunnel to the echo server.
func (s *IngressSuite) TestMappedHostname() {
s.Require().Contains(s.request("example.local", true), "hello-gost")
}
// TestUnmappedHostname verifies that a Host with no ingress rule is rejected
// (no route to host) and never reaches the echo server.
func (s *IngressSuite) TestUnmappedHostname() {
s.Require().NotContains(s.request("nomapped.local", false), "hello-gost")
}
func TestIngressSuite(t *testing.T) {
suite.Run(t, new(IngressSuite))
}
+50
View File
@@ -0,0 +1,50 @@
package e2e
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
"testing"
"github.com/testcontainers/testcontainers-go/network"
)
var SharedNetworkName string
func TestMain(m *testing.M) {
flag.Parse()
ctx := context.Background()
shouldCleanup := false
if GostBinPath == "" {
GostBinPath = "/tmp/gost-test-bin"
cmd := exec.Command("go", "build", "-o", GostBinPath, "../../cmd/gost")
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("Failed to compile gost: %v\n", err)
os.Exit(1)
}
shouldCleanup = true
}
net, err := network.New(ctx)
if err != nil {
fmt.Printf("Failed to create network: %v\n", err)
os.Exit(1)
}
SharedNetworkName = net.Name
code := m.Run()
net.Remove(ctx)
if shouldCleanup {
os.Remove(GostBinPath)
}
os.Exit(code)
}
+292
View File
@@ -0,0 +1,292 @@
package e2e
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"os"
"strings"
"testing"
"time"
"github.com/moby/moby/client"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
type MTLSSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
pluginC testcontainers.Container
certDir string
}
func (s *MTLSSuite) SetupSuite() {
s.ctx = context.Background()
certDir, err := os.MkdirTemp("", "gost-mtls-certs-*")
s.Require().NoError(err)
s.certDir = certDir
s.generateCerts()
pluginC, err := runAuthPluginContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.pluginC = pluginC
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *MTLSSuite) TearDownSuite() {
if s.pluginC != nil {
s.pluginC.Terminate(s.ctx)
}
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
os.RemoveAll(s.certDir)
}
// generateCerts creates a self-signed CA, a server cert signed by the CA,
// and a client cert signed by the CA, writing PEM files to s.certDir.
func (s *MTLSSuite) generateCerts() {
// CA
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
s.Require().NoError(err)
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test CA"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
s.Require().NoError(err)
s.writeCertPEM(s.certDir+"/ca.pem", "CERTIFICATE", caDER)
s.writeKeyPEM(s.certDir+"/ca-key.pem", "EC PRIVATE KEY", caKey)
// Server
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
s.Require().NoError(err)
serverTmpl := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
serverDER, err := x509.CreateCertificate(rand.Reader, serverTmpl, caTmpl, &serverKey.PublicKey, caKey)
s.Require().NoError(err)
s.writeCertPEM(s.certDir+"/server.pem", "CERTIFICATE", serverDER)
s.writeKeyPEM(s.certDir+"/server-key.pem", "EC PRIVATE KEY", serverKey)
// Client
clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
s.Require().NoError(err)
clientTmpl := &x509.Certificate{
SerialNumber: big.NewInt(3),
Subject: pkix.Name{CommonName: "test-client"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
EmailAddresses: []string{"test@example.com"},
}
clientDER, err := x509.CreateCertificate(rand.Reader, clientTmpl, caTmpl, &clientKey.PublicKey, caKey)
s.Require().NoError(err)
s.writeCertPEM(s.certDir+"/client.pem", "CERTIFICATE", clientDER)
s.writeKeyPEM(s.certDir+"/client-key.pem", "EC PRIVATE KEY", clientKey)
}
// TestMTLSProxy verifies HTTP forward proxy works over an mTLS listener
// with a valid client certificate.
func (s *MTLSSuite) TestMTLSProxy() {
rendered, err := RenderConfig("testdata/mtls/server.yaml",
ConfigData{ServerAddr: "auth-plugin"})
s.Require().NoError(err)
defer os.Remove(rendered)
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, rendered,
[]testcontainers.ContainerFile{
{HostFilePath: s.certDir + "/ca.pem", ContainerFilePath: "/certs/ca.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/server.pem", ContainerFilePath: "/certs/server.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/server-key.pem", ContainerFilePath: "/certs/server-key.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/client.pem", ContainerFilePath: "/certs/client.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/client-key.pem", ContainerFilePath: "/certs/client-key.pem", FileMode: 0644},
},
"8443/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Verify proxy works with valid client cert. curl in HTTPS-proxy mode
// requires --proxy-* TLS options: --cacert/--cert verify the origin (not
// the proxy) and are ignored for the proxy TLS connection.
cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5",
"--proxy-cacert", "/certs/ca.pem",
"--proxy-cert", "/certs/client.pem",
"--proxy-key", "/certs/client-key.pem",
"-x", "https://127.0.0.1:8443",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := ExecOutput(s.ctx, gostC, cmd)
body, err2 := io.ReadAll(out)
if err != nil || err2 != nil || code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "mtls gost logs", gostC)
DumpLogs(s.T(), s.ctx, "mtls auth-plugin logs", s.pluginC)
}
s.Require().NoError(err)
s.Require().NoError(err2)
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestMTLSWithoutClientCert verifies that an mTLS listener rejects
// connections from clients that do not present a certificate.
func (s *MTLSSuite) TestMTLSWithoutClientCert() {
rendered, err := RenderConfig("testdata/mtls/server.yaml",
ConfigData{ServerAddr: "auth-plugin"})
s.Require().NoError(err)
defer os.Remove(rendered)
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, rendered,
[]testcontainers.ContainerFile{
{HostFilePath: s.certDir + "/ca.pem", ContainerFilePath: "/certs/ca.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/server.pem", ContainerFilePath: "/certs/server.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/server-key.pem", ContainerFilePath: "/certs/server-key.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/client.pem", ContainerFilePath: "/certs/client.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/client-key.pem", ContainerFilePath: "/certs/client-key.pem", FileMode: 0644},
},
"8443/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl without client cert should fail TLS handshake. Verify the proxy
// cert against the CA, but present no client cert so the server rejects.
cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5",
"--proxy-cacert", "/certs/ca.pem",
"-x", "https://127.0.0.1:8443",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, _, err := ExecOutput(s.ctx, gostC, cmd)
if err == nil && code == 0 {
DumpLogs(s.T(), s.ctx, "mtls gost logs", gostC)
}
// Expect failure (non-zero exit code from curl)
s.Require().NotEqual(0, code, "curl without client cert should fail with non-zero exit code")
}
// TestMTLSAuthPluginLogs verifies that the HTTP auth plugin receives
// the mTLS client certificate identity (client_cn, client_san,
// client_cert_fingerprint) when a request passes through the mTLS listener.
func (s *MTLSSuite) TestMTLSAuthPluginLogs() {
rendered, err := RenderConfig("testdata/mtls/server.yaml",
ConfigData{ServerAddr: "auth-plugin"})
s.Require().NoError(err)
defer os.Remove(rendered)
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, rendered,
[]testcontainers.ContainerFile{
{HostFilePath: s.certDir + "/ca.pem", ContainerFilePath: "/certs/ca.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/server.pem", ContainerFilePath: "/certs/server.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/server-key.pem", ContainerFilePath: "/certs/server-key.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/client.pem", ContainerFilePath: "/certs/client.pem", FileMode: 0644},
{HostFilePath: s.certDir + "/client-key.pem", ContainerFilePath: "/certs/client-key.pem", FileMode: 0644},
},
"8443/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Send a request with valid client cert through the mTLS proxy.
cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5",
"--proxy-cacert", "/certs/ca.pem",
"--proxy-cert", "/certs/client.pem",
"--proxy-key", "/certs/client-key.pem",
"-x", "https://127.0.0.1:8443",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := ExecOutput(s.ctx, gostC, cmd)
body, _ := io.ReadAll(out)
s.Require().NoError(err)
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
// Read auth plugin logs and verify PeerCert fields are present.
logs, err := s.pluginC.Logs(s.ctx)
s.Require().NoError(err)
logData, _ := io.ReadAll(logs)
logStr := string(logData)
s.T().Logf("auth plugin logs:\n%s", logStr)
s.Require().Contains(logStr, "clientCn", "auth plugin should receive client_cn")
s.Require().Contains(logStr, "test-client", "client_cn should be 'test-client'")
s.Require().Contains(logStr, "clientSan", "auth plugin should receive client_san")
s.Require().Contains(logStr, "clientCertFingerprint", "auth plugin should receive client_cert_fingerprint")
}
func TestMTLSSuite(t *testing.T) {
suite.Run(t, new(MTLSSuite))
}
// --- helpers ---
func runAuthPluginContainer(ctx context.Context, networkName string) (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: ".",
Dockerfile: "Dockerfile",
Repo: "gost-e2e",
Tag: "latest",
KeepImage: true,
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
opts.NetworkMode = "host"
},
},
Networks: []string{networkName},
NetworkAliases: map[string][]string{
networkName: {"auth-plugin"},
},
Files: []testcontainers.ContainerFile{
{HostFilePath: "scripts/auth_plugin.py", ContainerFilePath: "/scripts/auth_plugin.py", FileMode: 0644},
},
ExposedPorts: []string{"9000/tcp"},
Cmd: []string{"python3", "/scripts/auth_plugin.py", "9000"},
WaitingFor: wait.ForExposedPort(),
}
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
}
// writeCertPEM writes a DER-encoded certificate to a PEM file.
func (s *MTLSSuite) writeCertPEM(path, blockType string, der []byte) {
err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0644)
s.Require().NoError(err)
}
// writeKeyPEM marshals an ECDSA private key and writes it to a PEM file.
func (s *MTLSSuite) writeKeyPEM(path, blockType string, key *ecdsa.PrivateKey) {
b, err := x509.MarshalECPrivateKey(key)
s.Require().NoError(err)
err = os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: b}), 0600)
s.Require().NoError(err)
}
+157
View File
@@ -0,0 +1,157 @@
package e2e
import (
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type PHTSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *PHTSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *PHTSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// TestPHTTunnel verifies a basic PHT tunnel: a client HTTP proxy chains
// through a PHT dialer to reach a PHT server, which forwards the request
// to the echo server.
func (s *PHTSuite) TestPHTTunnel() {
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/pht/server.yaml",
[]string{"pht-server"}, []string{"8443/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
rendered, err := RenderConfig("testdata/pht/client_connector.yaml",
ConfigData{ServerAddr: "pht-server:8443"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "pht client logs", clientC)
DumpLogs(s.T(), s.ctx, "pht server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestPHTSTunnel verifies PHT over TLS. The PHTs listener auto-generates a
// self-signed cert; the PHTs dialer defaults to InsecureSkipVerify so the
// handshake succeeds without explicit cert configuration.
func (s *PHTSuite) TestPHTSTunnel() {
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/pht/server_phts.yaml",
[]string{"phts-server"}, []string{"8443/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
rendered, err := RenderConfig("testdata/pht/client_connector_phts.yaml",
ConfigData{ServerAddr: "phts-server:8443"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "phts client logs", clientC)
DumpLogs(s.T(), s.ctx, "phts server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestPHTHeartbeat verifies that the PHT tunnel stays alive across idle
// periods. Sends a request, waits >readTimeout (default 10s), and sends
// another request to confirm the heartbeat kept the connection open.
func (s *PHTSuite) TestPHTHeartbeat() {
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/pht/server.yaml",
[]string{"pht-server"}, []string{"8443/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
rendered, err := RenderConfig("testdata/pht/client_connector.yaml",
ConfigData{ServerAddr: "pht-server:8443"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
// First request: establish the tunnel.
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ := io.ReadAll(out)
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
// Wait longer than readTimeout to trigger heartbeat.
// The PHT server's default read timeout is 10s. We wait 12s.
cmd = []string{"sh", "-c",
fmt.Sprintf("sleep 12 && curl -s -x http://127.0.0.1:8080 http://%s:5678", s.echoIP)}
code, out, err = clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ = io.ReadAll(out)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "pht heartbeat client logs", clientC)
DumpLogs(s.T(), s.ctx, "pht heartbeat server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
func TestPHTSuite(t *testing.T) {
suite.Run(t, new(PHTSuite))
}
+149
View File
@@ -0,0 +1,149 @@
package e2e
import (
"context"
"fmt"
"io"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type ProbeSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *ProbeSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *ProbeSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
func (s *ProbeSuite) proxyRequest(gostC testcontainers.Container, port string) (int, string) {
cmd := []string{
"curl", "-s",
"-x", fmt.Sprintf("http://127.0.0.1:%s", port),
fmt.Sprintf("http://%s:5678", s.echoIP),
}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return code, string(body)
}
// TestTCPProbeFailover verifies that the TCP probe detects a dead node and
// marks it before any real traffic, so the FailFilter excludes it. With the
// dead node pre-marked, every request succeeds immediately.
func (s *ProbeSuite) TestTCPProbeFailover() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/probe/tcp.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The probe fires immediately at startup, so the dead node is already
// marked by the time we send requests.
for range 10 {
code, body := s.proxyRequest(gostC, "8080")
s.Require().Equal(0, code, "every request must succeed; dead node pre-marked by probe")
s.Require().Contains(body, "hello-gost")
}
}
// TestLowestLatencyProbe verifies that the lowestlatency strategy works with
// probed nodes. Both nodes are live; the strategy selects the one with the
// lowest measured latency.
func (s *ProbeSuite) TestLowestLatencyProbe() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/probe/lowestlatency.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Give the initial probe a moment to fire so both nodes show healthy.
time.Sleep(200 * time.Millisecond)
for range 10 {
code, body := s.proxyRequest(gostC, "8080")
s.Require().Equal(0, code, "all requests must succeed with lowestlatency strategy")
s.Require().Contains(body, "hello-gost")
}
}
// TestCmdProbeFailover verifies that the cmd probe detects a dead node via
// shell exit code and marks it before real traffic, so FailFilter excludes it.
func (s *ProbeSuite) TestCmdProbeFailover() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/probe/cmd.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The probe fires at startup; the dead node is already marked.
for range 10 {
code, body := s.proxyRequest(gostC, "8080")
s.Require().Equal(0, code, "all requests must succeed; dead cmd node pre-marked")
s.Require().Contains(body, "hello-gost")
}
}
// TestProbeRecovery verifies the dead→alive→dead transition that issue #837
// describes: a node that fails is excluded, and once it recovers (probe flips
// healthy) it resumes carrying traffic. Both relays stay up; only the cmd
// probe flag files in the container flip, so no restart is needed.
//
// Phase 1: node-a marked dead, node-b healthy → every request goes to node-b.
// Phase 2: recover node-a, kill node-b → every request must still succeed,
// which is only possible if node-a's probe recovered it (otherwise both nodes
// would be excluded and the request would fail).
func (s *ProbeSuite) TestProbeRecovery() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/probe/recovery.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The node probes fire immediately at startup, so let both settle healthy
// before we start flipping flag files.
time.Sleep(1 * time.Second)
// Phase 1: knock node-a down, leave node-b up.
_, _, err = gostC.Exec(s.ctx, []string{"touch", "/tmp/a_down"})
s.Require().NoError(err)
time.Sleep(2500 * time.Millisecond) // > probe interval
for range 10 {
code, body := s.proxyRequest(gostC, "8080")
s.Require().Equal(0, code, "phase1: dead node-a must be excluded, all traffic via node-b")
s.Require().Contains(body, "hello-gost")
}
// Phase 2: recover node-a, knock node-b down. If node-a's probe did not
// revive it, the request would fail (no healthy candidate).
_, _, err = gostC.Exec(s.ctx, []string{"rm", "-f", "/tmp/a_down", "/tmp/b_down"})
s.Require().NoError(err)
_, _, err = gostC.Exec(s.ctx, []string{"touch", "/tmp/b_down"})
s.Require().NoError(err)
time.Sleep(2500 * time.Millisecond)
for range 10 {
code, body := s.proxyRequest(gostC, "8080")
s.Require().Equal(0, code, "phase2: recovered node-a must resume carrying traffic")
s.Require().Contains(body, "hello-gost")
}
}
func TestProbeSuite(t *testing.T) {
suite.Run(t, new(ProbeSuite))
}
+127
View File
@@ -0,0 +1,127 @@
package e2e
import (
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/moby/moby/client"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// ProxyProtoSuite verifies go-gost/gost#677: a forward/rtcp handler with
// metadata.proxyProtocol set prepends a HAProxy PROXY protocol header on the
// outbound connection before forwarding. The backend (proxy-capture) reads the
// first bytes and reflects the header line back so the test can assert it.
//
// This exercises the exact send path the issue asks for (forward/remote's
// proxyproto.WrapClientConn). A single gost instance is used so the header's
// source address is the real connecting client — in a full reverse tunnel the
// source would be the tunnel peer (relay CONNECT propagates only the
// destination); that limitation is out of scope here.
type ProxyProtoSuite struct {
suite.Suite
ctx context.Context
backendC testcontainers.Container
gostV1C testcontainers.Container
gostV2C testcontainers.Container
}
func (s *ProxyProtoSuite) SetupSuite() {
s.ctx = context.Background()
backendC, err := s.runBackend()
s.Require().NoError(err)
s.backendC = backendC
gostV1C, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/proxyproto/v1.yaml", []string{"gost-v1"}, []string{"8080/tcp"})
s.Require().NoError(err)
s.gostV1C = gostV1C
gostV2C, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/proxyproto/v2.yaml", []string{"gost-v2"}, []string{"8080/tcp"})
s.Require().NoError(err)
s.gostV2C = gostV2C
}
func (s *ProxyProtoSuite) TearDownSuite() {
for _, c := range []testcontainers.Container{s.gostV1C, s.gostV2C, s.backendC} {
if c != nil {
c.Terminate(s.ctx)
}
}
}
// runBackend starts the raw-TCP PROXY-header-capturing backend, aliased
// "proxy-capture" on the shared network, listening on 5678. It is also used as
// the client host (it has python3) to connect through gost.
func (s *ProxyProtoSuite) runBackend() (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: ".",
Dockerfile: "Dockerfile",
Repo: "gost-e2e",
Tag: "latest",
KeepImage: true,
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
opts.NetworkMode = "host"
},
},
Networks: []string{SharedNetworkName},
NetworkAliases: map[string][]string{
SharedNetworkName: {"proxy-capture"},
},
Files: []testcontainers.ContainerFile{
{HostFilePath: "scripts/proxy_capture.py", ContainerFilePath: "/scripts/proxy_capture.py", FileMode: 0644},
},
ExposedPorts: []string{"5678/tcp"},
Cmd: []string{"python3", "/scripts/proxy_capture.py"},
WaitingFor: wait.ForExposedPort(),
}
return testcontainers.GenericContainer(s.ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
}
// request connects from the backend container to gost on :8080, sends a payload,
// and returns the reflected response (which carries the PROXY header line).
func (s *ProxyProtoSuite) request(gostHost string) string {
cmd := []string{
"sh", "-c",
fmt.Sprintf("python3 -c \"import socket,sys; s=socket.socket(); s.settimeout(5); s.connect(('%s',8080)); s.sendall(b'hello-gost'); sys.stdout.write(s.recv(4096).decode())\"", gostHost),
}
_, out, err := s.backendC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestV1HeaderSent asserts gost prepends a text PROXY protocol v1 header.
func (s *ProxyProtoSuite) TestV1HeaderSent() {
body := s.request("gost-v1")
if !strings.Contains(body, "PROXY-RECEIVED") || !strings.Contains(body, "PROXY TCP") {
DumpLogs(s.T(), s.ctx, "gost-v1 logs", s.gostV1C)
}
s.Require().Contains(body, "PROXY-RECEIVED")
s.Require().Contains(body, "PROXY TCP")
}
// TestV2HeaderSent asserts gost prepends a binary PROXY protocol v2 header.
func (s *ProxyProtoSuite) TestV2HeaderSent() {
body := s.request("gost-v2")
if !strings.Contains(body, "PROXY-V2-RECEIVED") {
DumpLogs(s.T(), s.ctx, "gost-v2 logs", s.gostV2C)
}
s.Require().Contains(body, "PROXY-V2-RECEIVED")
}
func TestProxyProtoSuite(t *testing.T) {
suite.Run(t, new(ProxyProtoSuite))
}
+135
View File
@@ -0,0 +1,135 @@
package e2e
import (
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// QUICSuite covers the QUIC listener/dialer pair (listener type: quic, dialer
// type: quic) with the cipherKey obfuscation enabled. It uses the canonical
// GOST chaining pattern: a client container exposes a plain HTTP proxy that
// tunnels through a quic chain to a quic server (HTTP over QUIC).
type QUICSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *QUICSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *QUICSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// startChain brings up a quic server (alias "quic-server", cipherKey enabled,
// http handler) and a client container that chains to it through a quic
// dialer. The client exposes port 8080 for curl.
func (s *QUICSuite) startChain() (testcontainers.Container, testcontainers.Container) {
s.T().Helper()
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/quic/server.yaml", []string{"quic-server"}, []string{"18843/udp"})
s.Require().NoError(err)
rendered, err := RenderConfig("testdata/quic/client.yaml", ConfigData{ServerAddr: "quic-server:18843"})
s.Require().NoError(err)
s.T().Cleanup(func() { os.Remove(rendered) })
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, rendered, "8080/tcp")
s.Require().NoError(err)
return serverC, clientC
}
// curlEcho runs curl through the client's local http proxy and returns the
// exit code plus the captured body.
func (s *QUICSuite) curlEcho(clientC testcontainers.Container) (int, string) {
s.T().Helper()
cmd := []string{"curl", "-s", "--max-time", "20", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678/", s.echoIP)}
code, out, _ := clientC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
return code, string(body)
}
// sendInvalidDatagram sends a single malformed (non-encrypted) UDP datagram to
// the quic server's listener, reproducing the issue's repro packet.
func (s *QUICSuite) sendInvalidDatagram(clientC testcontainers.Container) {
s.T().Helper()
cmd := []string{"python3", "-c",
"import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.sendto(b\"x\", (\"quic-server\", 18843))"}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
if code != 0 {
b, _ := io.ReadAll(out)
s.T().Fatalf("send invalid datagram failed (%d): %s", code, string(b))
}
}
// TestQUICForwardCipherKeySurvivesInvalidDatagram verifies that a malformed
// UDP datagram does not close the shared QUIC transport (go-gost/x#125).
//
// With the bug, cipherConn.ReadFrom returned the decrypt error straight to
// quic-go, which treats any packet-socket error as fatal and closes the shared
// transport: a single invalid datagram kills the listener and correct clients
// subsequently time out. With the fix the invalid datagram is discarded and
// the listener keeps serving.
//
// Sequence: baseline forward works -> malformed datagram -> forward still works.
func (s *QUICSuite) TestQUICForwardCipherKeySurvivesInvalidDatagram() {
serverC, clientC := s.startChain()
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
// Baseline: a valid QUIC forward must work before we poke the listener.
code, body := s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("quic-baseline logs", clientC, serverC)
}
s.Require().Equal(0, code, "baseline QUIC forward should work")
s.Require().Contains(body, "hello-gost")
// Send one malformed UDP datagram to the quic listener's cipher socket.
s.sendInvalidDatagram(clientC)
// The listener must still be alive: a fresh QUIC forward must succeed.
code, body = s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("quic-after-invalid logs", clientC, serverC)
}
s.Require().Equal(0, code,
"QUIC forward should still work after an invalid datagram (issue #125)")
s.Require().Contains(body, "hello-gost",
"listener must not close on an invalid datagram (issue #125)")
}
func (s *QUICSuite) dump(label string, cs ...testcontainers.Container) {
for _, c := range cs {
DumpLogs(s.T(), s.ctx, fmt.Sprintf("%s: %s", label, c.GetContainerID()), c)
}
}
func TestQUICSuite(t *testing.T) {
suite.Run(t, new(QUICSuite))
}
+139
View File
@@ -0,0 +1,139 @@
package e2e
import (
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/moby/moby/client"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// RedSniffingWhitelistSuite reproduces go-gost/gost#899: whitelist bypass +
// sniffing on a transparent (red) proxy.
//
// Inside a single privileged container, iptables redirects all outbound TCP to
// the gost red service, so the handler sees the original destination IP via
// SO_ORIGINAL_DST. The whitelist contains only a domain (the docker network
// alias "https-echo"); a bare destination IP is never in it. On a broken build
// (go-gost/x@fe394a8, x >= v0.13.12) the pre-sniffing bypass check on the
// destination IP rejects every connection before SNI sniffing runs, making the
// domain whitelist dead. The fix skips that check in whitelist mode and lets
// the sniffer match the SNI host. This suite also asserts the security
// property the skip must not weaken: a non-whitelisted SNI is still rejected.
type RedSniffingWhitelistSuite struct {
suite.Suite
ctx context.Context
httpsEchoC testcontainers.Container
gostC testcontainers.Container
}
// redirectRules are installed in the gost container before gost starts:
// loopback traffic (incl. Docker's embedded DNS) and gost's own marked sockets
// are exempt; every other TCP packet is redirected to the red service.
const redirectRules = `
iptables -t nat -A OUTPUT -m addrtype --dst-type LOCAL -j RETURN
iptables -t nat -A OUTPUT -m mark --mark 114514 -j RETURN
iptables -t nat -A OUTPUT -p tcp -j REDIRECT --to-ports 12345
`
func (s *RedSniffingWhitelistSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunHTTPSEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.httpsEchoC = echoC
req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: ".",
Dockerfile: "Dockerfile",
Repo: "gost-e2e",
Tag: "latest",
KeepImage: true,
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
opts.NetworkMode = "host"
},
},
Networks: []string{SharedNetworkName},
CapAdd: []string{"NET_ADMIN"},
Files: []testcontainers.ContainerFile{
{HostFilePath: GostBinPath, ContainerFilePath: "/bin/gost", FileMode: 0755},
{HostFilePath: "testdata/red_sniffing/whitelist.yaml", ContainerFilePath: "/config.yaml", FileMode: 0644},
},
Cmd: []string{"sh", "-c", redirectRules + "\nexec /bin/gost -C /config.yaml"},
WaitingFor: wait.ForLog("listening on"),
}
gostC, err := testcontainers.GenericContainer(s.ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
s.Require().NoError(err)
s.gostC = gostC
}
func (s *RedSniffingWhitelistSuite) TearDownSuite() {
if s.gostC != nil {
s.gostC.Terminate(s.ctx)
}
if s.httpsEchoC != nil {
s.httpsEchoC.Terminate(s.ctx)
}
}
// TestWhitelistedDomainViaSNI is the gost#899 golden path: curl to a domain in
// the whitelist should be forwarded because the sniffer matches its SNI. The
// kernel redirects the outbound TCP to the red service, which must not reject
// the bare destination IP before sniffing. Retries tolerate startup timing.
func (s *RedSniffingWhitelistSuite) TestWhitelistedDomainViaSNI() {
s.T().Helper()
cmd := []string{"sh", "-c", `
for i in $(seq 1 30); do
body=$(curl -ks --max-time 5 https://https-echo:8443/ 2>/dev/null)
echo "$body" | grep -q hello-gost && { echo "$body"; exit 0; }
sleep 1
done
echo "$body"`}
_, out, err := s.gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ := io.ReadAll(out)
if !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "gost-red logs", s.gostC)
}
s.Require().Contains(string(body), "hello-gost",
"whitelisted domain must be forwarded via SNI, not rejected on the bare IP")
}
// TestNonWhitelistedHostRejected verifies that skipping the pre-sniffing check
// in whitelist mode does not weaken the whitelist: a connection whose sniffed
// SNI is not allowlisted must still be rejected. curl connects to the same
// echo IP but with a different SNI, so only the SNI can drive the decision.
func (s *RedSniffingWhitelistSuite) TestNonWhitelistedHostRejected() {
s.T().Helper()
ip, err := s.httpsEchoC.ContainerIP(s.ctx)
s.Require().NoError(err)
cmd := []string{"sh", "-c", fmt.Sprintf(
"curl -ks --resolve other.test:8443:%s --max-time 5 https://other.test:8443/ 2>&1", ip)}
code, _, err := s.gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
s.Require().NotZero(code,
"curl to a non-whitelisted SNI should fail; got exit %d", code)
logs, err := s.gostC.Logs(s.ctx)
s.Require().NoError(err)
defer logs.Close()
logBody, _ := io.ReadAll(logs)
s.Require().Contains(string(logBody), "bypass:",
"gost should log a bypass decision for the non-whitelisted SNI")
}
func TestRedSniffingWhitelistSuite(t *testing.T) {
suite.Run(t, new(RedSniffingWhitelistSuite))
}
+91
View File
@@ -0,0 +1,91 @@
package e2e
import (
"context"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// ResolverSuite verifies that a configured resolver drives the proxy's
// outbound DNS resolution. A gost HTTP proxy uses a resolver whose only
// nameserver is a test responder: it answers echo.test with the real echo
// server IP and NXDOMAIN for everything else. A request to echo.test is
// resolved by the custom resolver and reaches the echo server; a request to an
// unmapped host fails to resolve.
type ResolverSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
dnsC testcontainers.Container
proxyC testcontainers.Container
}
func (s *ResolverSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
dnsC, err := RunResolverResponderContainer(s.ctx, SharedNetworkName, s.echoIP)
s.Require().NoError(err)
s.dnsC = dnsC
proxyC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/resolver/server.yaml", []string{"gost-proxy"}, []string{"8080/tcp"})
s.Require().NoError(err)
s.proxyC = proxyC
}
func (s *ResolverSuite) TearDownSuite() {
for _, c := range []testcontainers.Container{s.proxyC, s.dnsC, s.echoC} {
if c != nil {
c.Terminate(s.ctx)
}
}
}
// proxyRequest sends an HTTP GET to the given host through the gost proxy. When
// waitResolve is true it retries until the resolver/responder is ready (the
// resolved-host happy path); otherwise it makes a single attempt. Returns the
// response body.
func (s *ResolverSuite) proxyRequest(host string, waitResolve bool) string {
loop := "for i in $(seq 1 30); do "
if !waitResolve {
loop = "for i in 1; do "
}
cmd := []string{
"sh", "-c",
fmt.Sprintf("%sbody=$(curl -s -x http://gost-proxy:8080 http://%s:5678/); echo \"$body\" | grep -q hello-gost && { echo \"$body\"; exit 0; }; sleep 1; done; echo \"$body\"", loop, host),
}
_, out, err := s.echoC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestResolvedHostname verifies that the custom resolver resolves echo.test to
// the echo server IP, so the proxied request reaches the echo server.
func (s *ResolverSuite) TestResolvedHostname() {
s.Require().Contains(s.proxyRequest("echo.test", true), "hello-gost")
}
// TestUnresolvedHostname verifies that a host absent from the resolver gets
// NXDOMAIN and never reaches the echo server.
func (s *ResolverSuite) TestUnresolvedHostname() {
s.Require().NotContains(s.proxyRequest("nomapped.test", false), "hello-gost")
}
func TestResolverSuite(t *testing.T) {
suite.Run(t, new(ResolverSuite))
}
+81
View File
@@ -0,0 +1,81 @@
package e2e
import (
"context"
"io"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// RoutingSuite verifies node-level routing matchers. A forward proxy's only
// chain node relays to an upstream proxy, but the node carries a matcher
// Host(`tcp-echo`) so it is only eligible for requests whose Host matches. A
// request to tcp-echo:5678 is matched, relayed upstream, and reaches the echo
// server ("hello-gost"); a request to a non-matching host is excluded (no
// eligible node) and never reaches the echo server.
type RoutingSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
proxyC testcontainers.Container
upstreamC testcontainers.Container
}
func (s *RoutingSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
proxyC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/routing/server.yaml", []string{"gost-proxy"}, []string{"8080/tcp"})
s.Require().NoError(err)
s.proxyC = proxyC
upstreamC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/routing/upstream.yaml", []string{"gost-upstream"}, []string{"8081/tcp"})
s.Require().NoError(err)
s.upstreamC = upstreamC
}
func (s *RoutingSuite) TearDownSuite() {
for _, c := range []testcontainers.Container{s.proxyC, s.upstreamC, s.echoC} {
if c != nil {
c.Terminate(s.ctx)
}
}
}
// proxyRequest sends an HTTP GET to target through the matcher-gated proxy and
// returns the response body.
func (s *RoutingSuite) proxyRequest(target string) string {
cmd := []string{
"curl", "-s",
"-x", "http://gost-proxy:8080",
target,
}
_, out, err := s.echoC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestMatchedHost verifies that a request whose Host matches the node matcher is
// relayed upstream and reaches the echo server.
func (s *RoutingSuite) TestMatchedHost() {
s.Require().Contains(s.proxyRequest("http://tcp-echo:5678/"), "hello-gost")
}
// TestUnmatchedHost verifies that a request whose Host does not match the node
// matcher is excluded (no eligible node) and never reaches the echo server.
func (s *RoutingSuite) TestUnmatchedHost() {
s.Require().NotContains(s.proxyRequest("http://other.local:5678/"), "hello-gost")
}
func TestRoutingSuite(t *testing.T) {
suite.Run(t, new(RoutingSuite))
}
+85
View File
@@ -0,0 +1,85 @@
package e2e
import (
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// RTCPFilterSuite reproduces go-gost/gost#898: multiple TCP services sharing
// ONE tunnel ID, routed at the client (rtcp) side by forwarder.nodes[].filter.host.
//
// The server ingress maps two hostnames to the same tunnel endpoint. The client
// rtcp service has two forwarder nodes, both filtered by host, with NO fallback
// node. When the hostname is correctly propagated through the tunnel, a request
// for example.local is routed to the example node (tcp-echo:5678). When the
// hostname is lost (the regression), neither node matches and the connection
// fails with "node not available".
type RTCPFilterSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
serverC testcontainers.Container
clientC testcontainers.Container
}
func (s *RTCPFilterSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/rtcpfilter/server.yaml", []string{"gost-server"}, []string{"8420/tcp"})
s.Require().NoError(err)
s.serverC = serverC
clientC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/rtcpfilter/client.yaml", []string{"gost-client"}, []string{"8423/tcp"})
s.Require().NoError(err)
s.clientC = clientC
}
func (s *RTCPFilterSuite) TearDownSuite() {
for _, c := range []testcontainers.Container{s.clientC, s.serverC, s.echoC} {
if c != nil {
c.Terminate(s.ctx)
}
}
}
// request sends an HTTP GET to the server entrypoint with the given Host
// header, retrying until the reverse tunnel is bound. Returns the response body.
func (s *RTCPFilterSuite) request(host string) string {
cmd := []string{
"sh", "-c",
fmt.Sprintf("for i in $(seq 1 30); do body=$(curl -s -H 'Host: %s' http://gost-server:8420/); echo \"$body\" | grep -q hello-gost && { echo \"$body\"; exit 0; }; sleep 1; done; echo \"$body\"", host),
}
_, out, err := s.echoC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return string(body)
}
// TestMappedHostnameFiltered verifies that a Host matching a filter.host node is
// routed through the tunnel to that node's backend. This is the gost#898 failure:
// with the regression, the hostname is dropped and no node matches.
func (s *RTCPFilterSuite) TestMappedHostnameFiltered() {
body := s.request("example.local")
if !strings.Contains(body, "hello-gost") {
DumpLogs(s.T(), s.ctx, "rtcp client logs", s.clientC)
DumpLogs(s.T(), s.ctx, "tunnel server logs", s.serverC)
}
s.Require().Contains(body, "hello-gost")
}
func TestRTCPFilterSuite(t *testing.T) {
suite.Run(t, new(RTCPFilterSuite))
}
+147
View File
@@ -0,0 +1,147 @@
package e2e
import (
"context"
"io"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// RUDPBindSuite reproduces go-gost/gost#911: concurrent UDP source sockets
// sharing ONE reverse UDP tunnel over a relay server.
//
// Topology (all three gost processes plus a UDP echo server):
//
// UDP source sockets (N) -> client udp service -> relay server
// -> reverse-bound UDP port -> host rudp listener (ONE shared stream)
// -> udp-echo
//
// A datagram frame is written to that shared stream as three separate Write
// calls (RSV/FRAG, SOCKS5 address, payload). Before the fix, with N endpoints
// writing concurrently, frames interleaved between those calls: replies came
// back corrupted, and a torn frame left half a header in the stream, which the
// far end read as "unexpected EOF" and answered by tearing down and rebinding
// the whole reverse tunnel every second.
//
// The host is the side that binds the tunnel, so its log is where a reset
// shows up as "unexpected EOF, retrying in 1s" followed by a rebind.
type RUDPBindSuite struct {
suite.Suite
ctx context.Context
udpC testcontainers.Container
serverC testcontainers.Container
hostC testcontainers.Container
clientC testcontainers.Container
}
func (s *RUDPBindSuite) SetupSuite() {
s.ctx = context.Background()
udpC, err := RunUDPEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.udpC = udpC
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/rudpbind/server.yaml", []string{"gost-server"}, []string{"8430/tcp"})
s.Require().NoError(err)
s.serverC = serverC
// The host's rudp listener opens no local port, so it exposes a dummy TCP
// port (service-1) purely for the readiness wait.
hostC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/rudpbind/host.yaml", []string{"gost-host"}, []string{"8431/tcp"})
s.Require().NoError(err)
s.hostC = hostC
clientC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/rudpbind/client.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/udp_rudp_concurrent.py", ContainerFilePath: "/scripts/udp_rudp_concurrent.py", FileMode: 0644},
})
s.Require().NoError(err)
s.clientC = clientC
// The reverse tunnel binds remotely on first traffic, not at startup.
up := assert.Eventually(s.T(), func() bool {
out := s.sendConcurrent(1, 1)
if !strings.Contains(out, "PASS") {
s.T().Logf("waiting for tunnel, sender said:\n%s", out)
}
return out != "" && strings.Contains(out, "PASS")
}, 30*time.Second, time.Second, "reverse UDP tunnel never came up")
if !up {
DumpLogs(s.T(), s.ctx, "host logs", s.hostC)
DumpLogs(s.T(), s.ctx, "server logs", s.serverC)
DumpLogs(s.T(), s.ctx, "client logs", s.clientC)
s.FailNow("reverse UDP tunnel never came up")
}
}
func (s *RUDPBindSuite) TearDownSuite() {
for _, c := range []testcontainers.Container{s.clientC, s.hostC, s.serverC, s.udpC} {
if c != nil {
c.Terminate(s.ctx)
}
}
}
// sendConcurrent runs the concurrent sender inside the client container and
// returns its combined stdout+stderr.
func (s *RUDPBindSuite) sendConcurrent(endpoints, seconds int) string {
cmd := []string{
"python3", "/scripts/udp_rudp_concurrent.py",
// The client service listens in this same container, so loopback is the
// correct target — no network alias needed for the sender.
"127.0.0.1", "8432",
strconv.Itoa(endpoints), strconv.Itoa(seconds),
}
_, out, err := s.clientC.Exec(s.ctx, cmd)
if err != nil {
return ""
}
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := out.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
return sb.String()
}
// TestConcurrentEndpointsShareTunnel is the regression assertion: several UDP
// source sockets sharing one reverse tunnel must each get their own datagrams
// back unchanged, and the tunnel must not be rebuilt underneath them.
func (s *RUDPBindSuite) TestConcurrentEndpointsShareTunnel() {
out := s.sendConcurrent(4, 8)
s.T().Logf("concurrent send:\n%s", out)
if strings.Contains(out, "FAIL") {
DumpLogs(s.T(), s.ctx, "host logs", s.hostC)
DumpLogs(s.T(), s.ctx, "server logs", s.serverC)
}
s.Require().Contains(out, "PASS", "concurrent endpoints over one reverse tunnel failed:\n%s", out)
// Belt and braces: the host log is where the reset appears directly.
hostLogs, err := s.hostC.Logs(s.ctx)
if err == nil {
body, readErr := io.ReadAll(hostLogs)
hostLogs.Close()
if readErr == nil {
s.Require().NotContains(string(body), "unexpected EOF",
"reverse tunnel was torn down while endpoints were sending")
}
}
}
func TestRUDPBindSuite(t *testing.T) {
suite.Run(t, new(RUDPBindSuite))
}
+27
View File
@@ -0,0 +1,27 @@
import http.server
import json
import sys
class AuthHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
data = json.loads(body)
print(f"AUTH_REQUEST: {json.dumps(data)}", flush=True)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps(
{"ok": True, "id": data.get("username", "anonymous")}
).encode()
)
def log_message(self, format, *args):
pass # silence default logging, we use our own AUTH_REQUEST line
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9000
http.server.HTTPServer(("0.0.0.0", port), AuthHandler).serve_forever()
+162
View File
@@ -0,0 +1,162 @@
"""DNS query client for e2e tests.
Usage:
python3 dns_query.py udp host port qname qtype [expected_ip|empty]
python3 dns_query.py tcp host port qname qtype [expected_ip|empty]
Sends a DNS query and optionally checks the response:
- expected_ip: verify response contains this IP address
- "empty": verify response has zero answer records (NXDOMAIN or blocked)
- omitted: exit 0 on any valid DNS response
Exits 0 on success, 1 on failure.
"""
import struct
import socket
import sys
def encode_name(name):
parts = name.rstrip(".").split(".")
return b"".join(bytes([len(p)]) + p.encode() for p in parts) + b"\x00"
def skip_name(data, pos):
"""Skip one DNS name at pos, return position after it."""
while True:
length = data[pos]
if length == 0:
return pos + 1
if length & 0xC0:
return pos + 2
pos += length + 1
def build_query(qname, qtype):
tid = 0x1234
flags = 0x0100 # RD=1
qdcount = 1
header = struct.pack(">HHHHHH", tid, flags, qdcount, 0, 0, 0)
qname_enc = encode_name(qname)
question = qname_enc + struct.pack(">HH", qtype, 1) # QTYPE, QCLASS=IN
return header + question
def parse_response(data):
if len(data) < 12:
return [], -1
flags = struct.unpack(">H", data[2:4])[0]
rcode = flags & 0x0F
qdcount = struct.unpack(">H", data[4:6])[0]
ancount = struct.unpack(">H", data[6:8])[0]
# Skip question section
pos = 12
for _ in range(qdcount):
pos = skip_name(data, pos) + 4 # QTYPE + QCLASS
answers = []
for _ in range(ancount):
pos = skip_name(data, pos)
rtype, rclass, ttl, rdlength = struct.unpack(">HHIH", data[pos:pos + 10])
pos += 10
rdata = data[pos:pos + rdlength]
pos += rdlength
answers.append((rtype, rclass, ttl, rdata))
return answers, rcode
def query_udp(host, port, query):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
sock.sendto(query, (host, port))
data, _ = sock.recvfrom(4096)
sock.close()
return data
def query_tcp(host, port, query):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((host, port))
sock.sendall(struct.pack(">H", len(query)) + query)
raw = sock.recv(2)
if len(raw) < 2:
sock.close()
return b""
msglen = struct.unpack(">H", raw)[0]
data = b""
while len(data) < msglen:
chunk = sock.recv(msglen - len(data))
if not chunk:
break
data += chunk
sock.close()
return data
def format_ip(rtype, rdata):
if rtype == 1 and len(rdata) == 4:
return socket.inet_ntoa(rdata)
if rtype == 28 and len(rdata) == 16:
return socket.inet_ntop(socket.AF_INET6, rdata)
return repr(rdata)
def main():
if len(sys.argv) < 5:
print("Usage: dns_query.py <udp|tcp> <host> <port> <qname> <qtype> [expected_ip|empty]")
sys.exit(1)
mode = sys.argv[1]
host = sys.argv[2]
port = int(sys.argv[3])
qname = sys.argv[4]
qtype_str = sys.argv[5]
expected = sys.argv[6] if len(sys.argv) > 6 else None
qtype_map = {"A": 1, "AAAA": 28}
qtype = qtype_map.get(qtype_str, 1)
query = build_query(qname, qtype)
try:
if mode == "tcp":
data = query_tcp(host, port, query)
else:
data = query_udp(host, port, query)
except Exception as e:
print(f"ERROR: {e}")
sys.exit(1)
if not data:
print("ERROR: empty response")
sys.exit(1)
answers, rcode = parse_response(data)
print(f"Got {len(answers)} answer(s), rcode={rcode}")
for rtype, _, _, rdata in answers:
print(f" {format_ip(rtype, rdata)}")
if expected == "empty":
if len(answers) == 0:
print("MATCH: empty answer (expected)")
sys.exit(0)
print(f"NO MATCH: expected empty, got {len(answers)} answers")
sys.exit(1)
elif expected:
for rtype, _, _, rdata in answers:
ip = format_ip(rtype, rdata)
if ip == expected:
print(f"MATCH: expected {expected}")
sys.exit(0)
print(f"NO MATCH: expected {expected}")
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
"""DNS responder for resolver e2e tests.
Returns the target IP (argv[1]) for an A query of "echo.test" and
NXDOMAIN for every other query. This lets a gost proxy configured with
this server as its resolver resolve a made-up hostname to a reachable
echo server, proving the resolver drives outbound dialing.
Listens on UDP port argv[2] (default 5353).
"""
import socketserver
import struct
import socket
import sys
TARGET_IP = sys.argv[1] if len(sys.argv) > 1 else "10.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 5353
MATCH_NAME = "echo.test"
def decode_name(data, offset):
labels = []
while True:
length = data[offset]
if length == 0:
offset += 1
break
if length & 0xC0:
offset += 2
break
offset += 1
labels.append(data[offset:offset + length].decode())
offset += length
return '.'.join(labels), offset
class DNSResponder(socketserver.DatagramRequestHandler):
def handle(self):
data = self.rfile.read(512)
if len(data) < 12:
return
tid = struct.unpack(">H", data[:2])[0]
qdcount = struct.unpack(">H", data[4:6])[0]
if qdcount == 0:
return
qname, pos = decode_name(data, 12)
qtype = struct.unpack(">H", data[pos:pos + 2])[0]
qclass = struct.unpack(">H", data[pos + 2:pos + 4])[0]
if qname == MATCH_NAME and qtype == 1:
rcode, ancount = 0, 1
rtype, ttl, rdata = 1, 300, socket.inet_aton(TARGET_IP)
else:
rcode, ancount = 3, 0 # NXDOMAIN
rtype, ttl, rdata = 0, 0, b""
flags = 0x8000 | 0x0400 | rcode # QR=1, AA=1, rcode
header = struct.pack(">HHHHHH", tid, flags, qdcount, ancount, 0, 0)
question = data[12:pos + 4]
answer = b""
if ancount:
answer = (
struct.pack(">HH", 0xC00C, rtype) # NAME pointer + TYPE
+ struct.pack(">HI", qclass, ttl) # CLASS + TTL
+ struct.pack(">H", len(rdata)) + rdata # RDLENGTH + RDATA
)
self.wfile.write(header + question + answer)
if __name__ == "__main__":
socketserver.ThreadingUDPServer.allow_reuse_address = True
with socketserver.ThreadingUDPServer(("0.0.0.0", PORT), DNSResponder) as srv:
srv.serve_forever()
+88
View File
@@ -0,0 +1,88 @@
"""Simple authoritative DNS responder for e2e tests.
Listens on UDP port 5353 and responds with static records:
test.example.com. IN A 10.0.0.1
test2.example.com. IN A 10.0.0.2
example.com. IN AAAA ::1
All other queries receive NXDOMAIN.
"""
import socketserver
import struct
import socket
def decode_name(data, offset):
labels = []
while True:
length = data[offset]
if length == 0:
offset += 1
break
if length & 0xC0:
offset += 2
break
offset += 1
labels.append(data[offset:offset + length].decode())
offset += length
return '.'.join(labels), offset
def encode_name(name):
parts = name.rstrip(".").split(".")
return b"".join(bytes([len(p)]) + p.encode() for p in parts) + b"\x00"
RECORDS = {
("test.example.com", 1): (1, 300, socket.inet_aton("10.0.0.1")),
("test2.example.com", 1): (1, 300, socket.inet_aton("10.0.0.2")),
("example.com", 28): (28, 300, socket.inet_pton(socket.AF_INET6, "::1")),
}
class DNSResponder(socketserver.DatagramRequestHandler):
def handle(self):
data = self.rfile.read(512)
if len(data) < 12:
return
tid = struct.unpack(">H", data[:2])[0]
qdcount = struct.unpack(">H", data[4:6])[0]
if qdcount == 0:
return
qname, pos = decode_name(data, 12)
qtype = struct.unpack(">H", data[pos:pos + 2])[0]
qclass = struct.unpack(">H", data[pos + 2:pos + 4])[0]
key = (qname, qtype)
if key in RECORDS:
rcode = 0
ancount = 1
rtype, ttl, rdata = RECORDS[key]
else:
rcode = 3 # NXDOMAIN
ancount = 0
rtype, ttl, rdata = 0, 0, b""
flags = 0x8000 | 0x0400 | rcode # QR=1, AA=1, rcode
header = struct.pack(">HHHHHH", tid, flags, qdcount, ancount, 0, 0)
question = data[12:pos + 4]
answer = b""
if ancount:
answer = (
struct.pack(">HH", 0xC00C, rtype) # NAME pointer + TYPE
+ struct.pack(">HI", qclass, ttl) # CLASS + TTL
+ struct.pack(">H", len(rdata)) + rdata # RDLENGTH + RDATA
)
self.wfile.write(header + question + answer)
if __name__ == "__main__":
with socketserver.UDPServer(("0.0.0.0", 5353), DNSResponder) as srv:
srv.serve_forever()
+83
View File
@@ -0,0 +1,83 @@
"""Simple authoritative DNS responder for TCP mode e2e tests.
Listens on TCP port 5353 and responds with the same static records
as dns_responder.py, but over TCP (RFC 5966).
"""
import socketserver
import struct
import socket
def decode_name(data, offset):
labels = []
while True:
length = data[offset]
if length == 0:
offset += 1
break
if length & 0xC0:
offset += 2
break
offset += 1
labels.append(data[offset:offset + length].decode())
offset += length
return '.'.join(labels), offset
RECORDS = {
("test.example.com", 1): (1, 300, socket.inet_aton("10.0.0.1")),
("test2.example.com", 1): (1, 300, socket.inet_aton("10.0.0.2")),
("example.com", 28): (28, 300, socket.inet_pton(socket.AF_INET6, "::1")),
}
class DNSResponder(socketserver.StreamRequestHandler):
def handle(self):
# TCP DNS: 2-byte length prefix
raw = self.rfile.read(2)
if len(raw) < 2:
return
msglen = struct.unpack(">H", raw)[0]
data = self.rfile.read(msglen)
if len(data) < 12:
return
tid = struct.unpack(">H", data[:2])[0]
qdcount = struct.unpack(">H", data[4:6])[0]
if qdcount == 0:
return
qname, pos = decode_name(data, 12)
qtype = struct.unpack(">H", data[pos:pos + 2])[0]
qclass = struct.unpack(">H", data[pos + 2:pos + 4])[0]
key = (qname, qtype)
if key in RECORDS:
rcode = 0
ancount = 1
rtype, ttl, rdata = RECORDS[key]
else:
rcode = 3 # NXDOMAIN
ancount = 0
rtype, ttl, rdata = 0, 0, b""
flags = 0x8000 | 0x0400 | rcode
header = struct.pack(">HHHHHH", tid, flags, qdcount, ancount, 0, 0)
question = data[12:pos + 4]
answer = b""
if ancount:
answer = (
struct.pack(">HH", 0xC00C, rtype)
+ struct.pack(">HI", qclass, ttl)
+ struct.pack(">H", len(rdata)) + rdata
)
dnsmsg = header + question + answer
self.wfile.write(struct.pack(">H", len(dnsmsg)) + dnsmsg)
if __name__ == "__main__":
with socketserver.TCPServer(("0.0.0.0", 5353), DNSResponder) as srv:
srv.serve_forever()
+140
View File
@@ -0,0 +1,140 @@
"""DNS query client over TLS for e2e tests.
Connects to the DNS-over-TLS endpoint and performs a DNS query.
Usage:
python3 dns_tls_query.py host port qname qtype [expected_ip]
"""
import struct
import socket
import ssl
import sys
def encode_name(name):
parts = name.rstrip(".").split(".")
return b"".join(bytes([len(p)]) + p.encode() for p in parts) + b"\x00"
def build_query(tid, qname, qtype):
flags = 0x0100
qdcount = 1
header = struct.pack(">HHHHHH", tid, flags, qdcount, 0, 0, 0)
qname_enc = encode_name(qname)
question = qname_enc + struct.pack(">HH", qtype, 1)
return header + question
def parse_response(data):
if len(data) < 12:
return [], -1
flags = struct.unpack(">H", data[2:4])[0]
rcode = flags & 0x0F
qdcount = struct.unpack(">H", data[4:6])[0]
ancount = struct.unpack(">H", data[6:8])[0]
pos = 12
for _ in range(qdcount):
while data[pos] != 0:
if data[pos] & 0xC0:
pos += 2
break
pos += data[pos] + 1
else:
pos += 1
pos += 4
answers = []
for _ in range(ancount):
if data[pos] & 0xC0:
pos += 2
else:
while data[pos] != 0:
pos += data[pos] + 1
pos += 1
rtype, rclass, ttl, rdlength = struct.unpack(">HHIH", data[pos:pos + 10])
pos += 10
rdata = data[pos:pos + rdlength]
pos += rdlength
answers.append((rtype, rdata))
return answers, rcode
def main():
if len(sys.argv) < 5:
print(f"Usage: {sys.argv[0]} host port qname qtype [expected_ip]")
sys.exit(1)
host = sys.argv[1]
port = int(sys.argv[2])
qname = sys.argv[3]
qtype_str = sys.argv[4]
expected = sys.argv[5] if len(sys.argv) > 5 else None
qtype_map = {"A": 1, "AAAA": 28}
qtype = qtype_map.get(qtype_str, 1)
query = build_query(0x1234, qname, qtype)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with socket.create_connection((host, port), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls_sock:
# TCP DNS: 2-byte length prefix
tls_sock.sendall(struct.pack(">H", len(query)) + query)
raw = tls_sock.recv(2)
if len(raw) < 2:
print("ERROR: short response header")
sys.exit(1)
msglen = struct.unpack(">H", raw)[0]
data = b""
while len(data) < msglen:
chunk = tls_sock.recv(msglen - len(data))
if not chunk:
break
data += chunk
except Exception as e:
print(f"ERROR: {e}")
sys.exit(1)
if not data:
print("ERROR: empty response")
sys.exit(1)
answers, rcode = parse_response(data)
print(f"Got {len(answers)} answer(s), rcode={rcode}")
for rtype, rdata in answers:
if rtype == 1 and len(rdata) == 4:
ip = socket.inet_ntoa(rdata)
print(f" A {ip}")
elif rtype == 28 and len(rdata) == 16:
ip6 = socket.inet_ntop(socket.AF_INET6, rdata)
print(f" AAAA {ip6}")
else:
print(f" TYPE={rtype} RDATA={rdata.hex()}")
if expected:
for rtype, rdata in answers:
if rtype == 1 and len(rdata) == 4:
ip = socket.inet_ntoa(rdata)
if ip == expected:
print(f"MATCH: expected {expected}")
sys.exit(0)
elif rtype == 28 and len(rdata) == 16:
ip6 = socket.inet_ntop(socket.AF_INET6, rdata)
if ip6 == expected:
print(f"MATCH: expected {expected}")
sys.exit(0)
print(f"NO MATCH: expected {expected}")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
import socket, threading
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 15738))
s.listen(50)
def loop(c):
try:
while True:
d = c.recv(65536)
if not d:
break
c.sendall(d)
except OSError:
pass
finally:
c.close()
while True:
c, _ = s.accept()
threading.Thread(target=loop, args=(c,), daemon=True).start()
+20
View File
@@ -0,0 +1,20 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
_counter = 0
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
global _counter
_counter += 1
body = b"cache-test-%d" % _counter
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
return
HTTPServer(("0.0.0.0", 5677), Handler).serve_forever()
@@ -0,0 +1,46 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
import socket
import threading
_counter = 0
_http_server = None
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
global _counter
_counter += 1
body = b"cache-test-%d" % _counter
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
threading.Thread(target=stop_server, daemon=True).start()
def log_message(self, format, *args):
return
def stop_server():
global _http_server
if _http_server:
_http_server.shutdown()
_http_server.server_close()
def start_raw_acceptor():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", 5676))
sock.listen(5)
while True:
conn, addr = sock.accept()
conn.close()
_http_server = HTTPServer(("0.0.0.0", 5676), Handler)
_http_server.serve_forever()
_http_server.server_close()
start_raw_acceptor()
+68
View File
@@ -0,0 +1,68 @@
import socket
import sys
import time
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
idle_timeout = int(sys.argv[3]) if len(sys.argv) > 3 else 3
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(10)
s.connect((host, port))
# CONNECT to echo server
req = b"CONNECT tcp-echo:5678 HTTP/1.1\r\nHost: tcp-echo:5678\r\n\r\n"
s.sendall(req)
# Read 200 OK response
resp = b""
while b"\r\n\r\n" not in resp:
chunk = s.recv(4096)
if not chunk:
break
resp += chunk
if b"200" not in resp:
print(f"FAIL: expected 200, got {resp.decode(errors='replace')}")
sys.exit(1)
# Send an HTTP GET through the CONNECT tunnel. The tunnel target
# is an HTTP echo server (BaseHTTPRequestHandler), so we must speak
# HTTP or the connection closes.
req = b"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n"
s.sendall(req)
data = b""
while b"hello-gost" not in data:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
if b"hello-gost" not in data:
print(f"FAIL: expected hello-gost echo, got {data!r}")
sys.exit(1)
print(f"PASS: first request through tunnel succeeded")
# Wait longer than idleTimeout
wait = idle_timeout + 2
print(f"Waiting {wait}s for idle timeout...")
time.sleep(wait)
# Try to send more data — idle timeout should have closed the pipe
try:
s.sendall(b"ping-2\n")
data = s.recv(4096)
if not data:
print("PASS: connection closed after idle timeout (empty recv)")
sys.exit(0)
# Got data means the pipe is still alive
print(f"FAIL: connection still alive after idle timeout, got {data!r}")
sys.exit(1)
except (socket.timeout, ConnectionResetError, BrokenPipeError, OSError) as e:
print(f"PASS: connection closed after idle timeout: {e}")
sys.exit(0)
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
"""HTTP server that always returns a fixed status code."""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
STATUS = int(sys.argv[1]) if len(sys.argv) > 1 else 429
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 5680
BODY = f"status-{STATUS}".encode()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(STATUS)
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def do_POST(self):
self.send_response(STATUS)
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
# HTTP proxy CONNECT method
def do_CONNECT(self):
self.send_response(STATUS)
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, format, *args):
return
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+135
View File
@@ -0,0 +1,135 @@
import socket
import struct
import sys
UDP_ECHO_HOST = "udp-echo"
UDP_ECHO_PORT = 5679
def encode_socks5_addr(host, port):
"""Encode (host, port) as SOCKS5 ADDRESS + PORT.
Uses ATYP=3 (domain name) so Docker DNS resolves the host.
Returns (atyp, address_bytes, port_bytes).
"""
host_bytes = host.encode()
addr_bytes = struct.pack("!B", len(host_bytes)) + host_bytes
port_bytes = struct.pack("!H", port)
return 0x03, addr_bytes, port_bytes
def build_udp_frame(payload, host, port):
"""Build a SOCKS5 UDP relay frame over TCP.
gost HTTP UDP relay uses RSV=data-length and FRAG=0xff.
Frame: [RSV:2][FRAG:1][ATYP:1][DST.ADDR][DST.PORT:2][DATA]
"""
atyp, addr_bytes, port_bytes = encode_socks5_addr(host, port)
rsv = struct.pack("!H", len(payload))
frag = b"\xff"
atyp_byte = struct.pack("!B", atyp)
return rsv + frag + atyp_byte + addr_bytes + port_bytes + payload
def recvn(sock, n):
"""Receive exactly n bytes from socket."""
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("connection closed while reading")
buf += chunk
return buf
def read_socks5_frame(sock):
"""Read one SOCKS5 UDP relay frame from the TCP connection.
Returns (addr, port, data).
"""
# Read header: RSV(2) + FRAG(1) + ATYP(1)
header = recvn(sock, 4)
rsv = struct.unpack("!H", header[:2])[0]
frag = header[2]
atyp = header[3]
# Read address based on ATYP
if atyp == 1: # IPv4
addr_bytes = recvn(sock, 4)
addr = socket.inet_ntoa(addr_bytes)
elif atyp == 3: # Domain name
domain_len = recvn(sock, 1)
addr = recvn(sock, domain_len[0]).decode()
elif atyp == 4: # IPv6
addr_bytes = recvn(sock, 16)
addr = socket.inet_ntop(socket.AF_INET6, addr_bytes)
else:
raise ValueError(f"unknown ATYP: {atyp}")
port_bytes = recvn(sock, 2)
port = struct.unpack("!H", port_bytes)[0]
# Read data
if rsv > 0:
data = recvn(sock, rsv)
else:
# Standard SOCKS5 UDP: read remaining
data = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return addr, port, data
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(10)
s.connect((host, port))
# Send CONNECT with X-Gost-Protocol: udp
req = (
b"CONNECT 0.0.0.0:0 HTTP/1.1\r\n"
b"Host: 0.0.0.0:0\r\n"
b"X-Gost-Protocol: udp\r\n"
b"Proxy-Connection: keep-alive\r\n"
b"\r\n"
)
s.sendall(req)
# Read 200 OK
resp = b""
while b"\r\n\r\n" not in resp:
chunk = s.recv(4096)
if not chunk:
break
resp += chunk
if b"200" not in resp:
print(f"FAIL: expected 200, got {resp.decode(errors='replace')}")
sys.exit(1)
# Build and send UDP relay frame
payload = b"hello-gost"
frame = build_udp_frame(payload, UDP_ECHO_HOST, UDP_ECHO_PORT)
s.sendall(frame)
# Read response frame
addr, rport, data = read_socks5_frame(s)
if b"hello-gost" in data:
print(f"PASS: received expected data from {addr}:{rport}")
sys.exit(0)
else:
print(f"FAIL: expected hello-gost in response, got {data!r}")
sys.exit(1)
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
import os
import subprocess
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(os.environ.get("PORT", "8443"))
cert_dir = "/tmp/certs"
os.makedirs(cert_dir, exist_ok=True)
cert_file = os.path.join(cert_dir, "cert.pem")
key_file = os.path.join(cert_dir, "key.pem")
if not os.path.exists(cert_file) or not os.path.exists(key_file):
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", key_file, "-out", cert_file,
"-days", "365", "-nodes",
"-subj", "/CN=localhost",
],
check=True,
)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = b"hello-gost"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
return
server = HTTPServer(("0.0.0.0", PORT), Handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_file, key_file)
server.socket = ctx.wrap_socket(server.socket, server_side=True)
server.serve_forever()
+23
View File
@@ -0,0 +1,23 @@
# Raw-TCP backend for the PROXY-protocol e2e test (issue #677).
# Reads the first bytes of each connection and reflects whether a HAProxy
# PROXY protocol header was prepended by gost:
# - v1 ("PROXY TCP4 ...") -> "PROXY-RECEIVED: PROXY TCP4 ..."
# - v2 (12-byte signature) -> "PROXY-V2-RECEIVED"
# - neither -> "NO-PROXY"
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 5678))
s.listen(5)
while True:
c, _ = s.accept()
d = c.recv(4096)
if d[:12] == b"\r\n\r\n\x00\r\nQUIT\n":
c.sendall(b"PROXY-V2-RECEIVED\n")
elif d[:6] == b"PROXY ":
c.sendall(b"PROXY-RECEIVED: " + d.split(b"\r\n", 1)[0] + b"\n")
else:
c.sendall(b"NO-PROXY\n")
c.close()
+16
View File
@@ -0,0 +1,16 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = b"hello-gost"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
return
HTTPServer(("0.0.0.0", 5678), Handler).serve_forever()
+53
View File
@@ -0,0 +1,53 @@
import socket
import sys
import time
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8000
idle_timeout = int(sys.argv[3]) if len(sys.argv) > 3 else 3
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(10)
s.connect((host, port))
# Send an HTTP GET request — the forward handler pipes us to tcp-echo:5678
req = b"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n"
s.sendall(req)
# Read response — should contain "hello-gost" (echo server reply)
resp = b""
while b"hello-gost" not in resp:
chunk = s.recv(4096)
if not chunk:
break
resp += chunk
if b"hello-gost" not in resp:
print(f"FAIL: expected hello-gost in response, got {resp.decode(errors='replace')}")
sys.exit(1)
print(f"PASS: first request through forward pipe succeeded")
# Wait longer than idleTimeout
wait = idle_timeout + 2
print(f"Waiting {wait}s for idle timeout...")
time.sleep(wait)
# Try to send more data — idle timeout should have closed the pipe
try:
s.sendall(b"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n")
data = s.recv(4096)
if not data:
print("PASS: connection closed after idle timeout (empty recv)")
sys.exit(0)
# Got data means the pipe is still alive
print(f"FAIL: connection still alive after idle timeout, got {data!r}")
sys.exit(1)
except (socket.timeout, ConnectionResetError, BrokenPipeError, OSError) as e:
print(f"PASS: connection closed after idle timeout: {e}")
sys.exit(0)
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 5679))
while True:
data, addr = sock.recvfrom(2048)
sock.sendto(data, addr)
+25
View File
@@ -0,0 +1,25 @@
import socket
import sys
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 9000
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
payload = b"hello-gost-udp"
sock.sendto(payload, (host, port))
data, addr = sock.recvfrom(2048)
if data == payload:
print("PASS: received echo")
sys.exit(0)
else:
print(f"FAIL: expected {payload!r}, got {data!r}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,35 @@
import socket
import sys
import base64
# QUIC v1 Initial packet (captured from real traffic, SNI=cloudflare-quic.com).
# Used to verify that the GOST proxy correctly sniffs and forwards QUIC traffic.
QUIC_PKT_B64 = "wQAAAAEMbPz5zifdvbrMMCfeAABE6onIhQqNpzy3/idh5rftIZ4dn6chk10EX5zjUSCc9HmGCZ95QWw9MTxkPZODKiEHadVyfmCt/2fjoHGmQUplPcmvWinaEXA6ysN/MREQ76J2DDH4YPZj1aSF2vGotyNf64/Qga6nfM3Nb+3iOKul7fwEjmi7kLjgHZ5ryTb+PHsddTg+07jW/hcFOgiQ9P/vreI+zJuEzcv2xV1oYAY7PnnM9aRrtAdik2V0WlAvTN9kFeV813+fnzQCw9ztyG9UGD6S7aUrUbCx3N3PFGc5yMmHsSGz5ZsO1wq8zGE3G9TSJTe6GwQpFwWz2J5gEgC7WB2ulfZYinZRC3+JtJ8lKm8dWt7tT8ymOYxLjpWh+JOHkkQHwCHvCoS8EugDZRsRNP+uME6SdRjzR/zAkawOz8jQL7oUWIYAEggxP3MmPw7uHVcYZHi8mhgvxIm8u7p5y5wqqweRhbRKrd1ji9Rq2wfEXrkumnfjkboP3OOqR/os4DFIaCNh1KHqsmBpbvTIQjrNQ11LZJSZ/I0pq6A1pSo9N6l38Ty4p8Ji6my1TRFmnCejxfSutmN/ozc7N9wD4ZVXcEMmqTsTrvjgHK4Frmqmmzr9SnMfbKdZ9BGdOFWXQrYqP1g6+Dg+nacCH2PdWjMyleSTMMvMlG6RrwLC/2eC1pDkRuhTVJS3h0ZOX+17jqHmKabzWv17qjzWx4dLu2z/fwWwaIJTO4sEvk05cquuyBYPUOfjbh7cQhIVOLBVJaXVIgro+C96wmoIlxfEMEZHDmriXU2SM6PKq8xw4a8AgnYBkGDofNOXMfdQj+byiwQS6pIOkii+haHqSSP6Y9r17jT3Ykytv+jeT4UanXjtjepBb5N/ApISY2Xs71irWRRZr9LXHXnJNFFdMWN367JIT3i865UMeXzndIu3iZdOWpSAApBnU2JMyjloRCA5Li9ABYgS2qjyo0SbleFj1Qp4CMNeuigkGR4oq/BSQmYMZL2KYJFAyX4BEev3YYkkJ12HlXgY54QL8jgxlJ+3e+qklM/lGajujPLfocswLUeWklfBZhoOJvPgIV5N9zGg1bxZLUe+brHrPpHnQZBAsksyM3YZra+PscMwQTopDIPBd1A52rSrmcvgQs6QR07Bh/ttS3tn0I4Xtb6rrkVgkC3zckIS2fB6WPHUOruDhQRPPehOfTehvTIFASt+6IR+jlGQ4/ZT1PkwAO+sLCBNEU1FBtLFWUpJsjSva49BjN5slRAI5SSCXeiwsvXuCfV8ZSmFZRvMR1BBuaFVVFrdK7PqMrtdJ90BInTE1J9vYUmWYvnZYI20xUm6mj2iourEkki4d8Vjtg2JSICnql/jKcIAxyJUDx1ZoFxvAbz34jTvx681lepy97n1jyGa7DlnpM/O6x+GHFaANQSj+2iIKjrQ+sM3YtSGMR2ClSg73f+pEdWUV5tyfiTB5nLI+VOpmt0AVCguau4ihLEmxADfQ1PXe9i1T0xvpceePc06T5pG37MYP5w6lLOJBajAFJuu6lavTtE7EyreoBnxkvzPciKupNwTxWdmWT8HVgQgaUUbvDCtTQr6nJnNMaGk2zbsXm8kiXfXS4lgf1sqw7HCphffMSH73JCabipof896LXjj2I8hj8wA8UMladryva0hv+Px0DmzsbO2/Wh+IhnbD8EoYH2gUehLQwQ="
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 9000
pkt = base64.b64decode(QUIC_PKT_B64)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(10)
sock.sendto(pkt, (host, port))
data, addr = sock.recvfrom(4096)
if len(data) == len(pkt):
print(f"PASS: received {len(data)} bytes (echo matches)")
sys.exit(0)
elif len(data) > 0:
print(f"PARTIAL: sent {len(pkt)}, received {len(data)} bytes")
sys.exit(0)
else:
print("FAIL: no response")
sys.exit(1)
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
"""Concurrent UDP sender for the gost#911 reverse-tunnel regression test.
Each socket is an independent source endpoint sharing ONE reverse tunnel
stream. Every datagram is a run of its own endpoint id, so a reply that does
not match the id byte exactly is a frame corrupted by an interleaved writer
rather than plain loss.
Usage: udp_rudp_concurrent.py <host> <port> <count> <duration_seconds>
"""
import socket
import sys
import threading
import time
def main():
host = sys.argv[1]
port = int(sys.argv[2])
count = int(sys.argv[3])
duration = float(sys.argv[4])
payload_len = 1200
end = time.monotonic() + duration
results = {}
lock = threading.Lock()
def endpoint(endpoint_id):
payload = bytes([endpoint_id]) * payload_len
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 0))
sock.settimeout(0.2)
sent = received = damaged = 0
largest_gap = 0.0
last = time.monotonic()
while time.monotonic() < end:
try:
sock.sendto(payload, (host, port))
sent += 1
except OSError:
pass
while True:
try:
data, _ = sock.recvfrom(65535)
except socket.timeout:
break
except OSError:
break
received += 1
if data != payload:
damaged += 1
now = time.monotonic()
largest_gap = max(largest_gap, now - last)
last = now
largest_gap = max(largest_gap, time.monotonic() - last)
with lock:
results[endpoint_id] = (sent, received, damaged, round(largest_gap, 3))
sock.close()
threads = [
threading.Thread(target=endpoint, args=(i + 1,)) for i in range(count)
]
for t in threads:
t.start()
for t in threads:
t.join()
failures = []
for endpoint_id in sorted(results):
sent, received, damaged, gap = results[endpoint_id]
loss_pct = 100.0 * (1.0 - received / sent) if sent else 100.0
print(
f"endpoint {endpoint_id}: sent={sent} received={received} "
f"damaged={damaged} loss={loss_pct:.1f}% max_gap={gap}"
)
# Unsent datagrams are outside our control (the UDP socket's own
# buffer); anything that comes BACK must be byte-identical.
if damaged:
failures.append(
f"endpoint {endpoint_id}: {damaged} datagram(s) came back "
f"corrupted — frames interleaved on the shared tunnel stream"
)
# A torn frame makes the far end read io.ErrUnexpectedEOF, which
# rebuilds the whole reverse tunnel. That shows up as a receive gap of
# seconds, not milliseconds.
if gap >= 1.0:
failures.append(
f"endpoint {endpoint_id}: {gap}s receive gap — the reverse "
f"tunnel was torn down and rebound under active traffic"
)
if failures:
for f in failures:
print(f"FAIL: {f}", file=sys.stderr)
sys.exit(1)
print(f"PASS: {count} concurrent endpoints, no corruption, no tunnel reset")
sys.exit(0)
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
package e2e
import (
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type SelectorSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *SelectorSuite) SetupSuite() {
s.ctx = context.Background()
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
}
func (s *SelectorSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// proxyRequest sends one request through the gost proxy and returns the curl
// exit code and response body.
func (s *SelectorSuite) proxyRequest(gostC testcontainers.Container, port string) (int, string) {
cmd := []string{
"curl", "-s",
"-x", fmt.Sprintf("http://127.0.0.1:%s", port),
fmt.Sprintf("http://%s:5678", s.echoIP),
}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
return code, string(body)
}
// runFailover starts a gost container from cfg and verifies that, despite a
// dead node in the hop, requests converge to the live node after the selector
// marks the dead node and skips it. At most the initial marking attempt may
// fail; every request after must reach the echo server.
func (s *SelectorSuite) runFailover(cfg, port string) {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, port+"/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
const n = 12
var failures int
lastOK := false
for range n {
code, body := s.proxyRequest(gostC, port)
ok := code == 0 && strings.Contains(body, "hello-gost")
if !ok {
failures++
}
lastOK = ok
}
s.Require().LessOrEqual(failures, 1,
"selector did not filter the dead node; too many requests failed")
s.Require().True(lastOK, "requests did not converge to the live node")
}
// TestRoundRobinFailover exercises the round-robin strategy combined with the
// fail filter: the dead node is tried once, marked, and then skipped.
func (s *SelectorSuite) TestRoundRobinFailover() {
s.runFailover("testdata/selector/roundrobin.yaml", "8080")
}
// TestFIFOFailover exercises the fifo (sticky) strategy: it always picks the
// first node until it fails, then falls through to the secondary node.
func (s *SelectorSuite) TestFIFOFailover() {
s.runFailover("testdata/selector/fifo.yaml", "8080")
}
// TestBackupFailover exercises the backup filter: with the primary node dead,
// the selector falls back to the backup node.
func (s *SelectorSuite) TestBackupFailover() {
s.runFailover("testdata/selector/backup.yaml", "8080")
}
// TestParallelSelector exercises the parallel strategy: it dials all nodes
// concurrently and uses the first that connects, so a dead node never blocks
// the request.
func (s *SelectorSuite) TestParallelSelector() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/selector/parallel.yaml", "8080/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
code, body := s.proxyRequest(gostC, "8080")
s.Require().Equal(0, code)
s.Require().Contains(body, "hello-gost")
}
func TestSelectorSuite(t *testing.T) {
suite.Run(t, new(SelectorSuite))
}
+160
View File
@@ -0,0 +1,160 @@
package e2e
import (
"context"
"fmt"
"io"
"net"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type ShadowsocksSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
udpC testcontainers.Container
}
func (s *ShadowsocksSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.echoC = echoC
echoIP, err := echoC.ContainerIP(s.ctx)
s.Require().NoError(err)
s.echoIP = echoIP
s.T().Logf("start udp echo container...")
udpC, err := RunUDPEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.udpC = udpC
}
func (s *ShadowsocksSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
if s.udpC != nil {
s.udpC.Terminate(s.ctx)
}
}
func (s *ShadowsocksSuite) TestShadowsocksTCP() {
s.runTCPCase("aes256gcm", "testdata/shadowsocks/tcp_server_aes256gcm.yaml", "testdata/shadowsocks/tcp_client_aes256gcm.yaml")
s.runTCPCase("chacha20", "testdata/shadowsocks/tcp_server_chacha20.yaml", "testdata/shadowsocks/tcp_client_chacha20.yaml")
}
func (s *ShadowsocksSuite) TestShadowsocks2022TCP() {
s.runTCPCase("2022-aes128", "testdata/shadowsocks/tcp_server_2022_aes128.yaml", "testdata/shadowsocks/tcp_client_2022_aes128.yaml")
s.runTCPCase("2022-aes256", "testdata/shadowsocks/tcp_server_2022_aes256.yaml", "testdata/shadowsocks/tcp_client_2022_aes256.yaml")
}
func (s *ShadowsocksSuite) TestShadowsocks2022TCPMultiPSK() {
s.runTCPCase("2022-aes128-multipsk", "testdata/shadowsocks/tcp_server_2022_aes128_multipsk.yaml", "testdata/shadowsocks/tcp_client_2022_aes128_multipsk.yaml")
s.runTCPCase("2022-aes256-multipsk", "testdata/shadowsocks/tcp_server_2022_aes256_multipsk.yaml", "testdata/shadowsocks/tcp_client_2022_aes256_multipsk.yaml")
}
func (s *ShadowsocksSuite) TestShadowsocksUDP() {
s.runUDPCase("aes256gcm", "testdata/shadowsocks/udp_server_aes256gcm.yaml", "testdata/shadowsocks/udp_client_aes256gcm.yaml")
}
func (s *ShadowsocksSuite) TestShadowsocks2022UDP() {
s.runUDPCase("2022-aes128", "testdata/shadowsocks/udp_server_2022_aes128.yaml", "testdata/shadowsocks/udp_client_2022_aes128.yaml")
s.runUDPCase("2022-aes256", "testdata/shadowsocks/udp_server_2022_aes256.yaml", "testdata/shadowsocks/udp_client_2022_aes256.yaml")
}
func (s *ShadowsocksSuite) runUDPCase(name, serverConfig, clientConfig string) {
s.T().Run(name, func(t *testing.T) {
serverAlias := name + "-ssu-server"
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, serverConfig, []string{serverAlias}, []string{"8389/udp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
rendered, err := RenderConfig(clientConfig, ConfigData{ServerAddr: serverAlias + ":8389"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(
s.ctx,
SharedNetworkName,
rendered,
"9000/udp",
)
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
host, err := clientC.Host(s.ctx)
s.Require().NoError(err)
port, err := clientC.MappedPort(s.ctx, "9000/udp")
s.Require().NoError(err)
conn, err := net.DialTimeout("udp", net.JoinHostPort(host, port.Port()), 5*time.Second)
s.Require().NoError(err)
defer conn.Close()
payload := []byte("hello-gost-udp")
buf := make([]byte, 2048)
var n int
for i := range 5 {
_, err = conn.Write(payload)
s.Require().NoError(err)
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err = conn.Read(buf)
if err == nil {
break
}
s.T().Logf("udp read attempt %d failed: %v, retrying...", i+1, err)
time.Sleep(2 * time.Second)
}
if err != nil {
DumpLogs(s.T(), s.ctx, name+" udp client logs", clientC)
DumpLogs(s.T(), s.ctx, name+" udp server logs", serverC)
}
s.Require().NoError(err)
s.Require().Contains(string(buf[:n]), "hello-gost")
})
}
func TestShadowsocksSuite(t *testing.T) {
suite.Run(t, new(ShadowsocksSuite))
}
func (s *ShadowsocksSuite) runTCPCase(name, serverConfig, clientConfig string) {
s.T().Run(name, func(t *testing.T) {
serverAlias := name + "-ss-server"
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, serverConfig, []string{serverAlias}, []string{"8388/tcp"})
s.Require().NoError(err)
defer serverC.Terminate(s.ctx)
rendered, err := RenderConfig(clientConfig, ConfigData{ServerAddr: serverAlias + ":8388"})
s.Require().NoError(err)
defer os.Remove(rendered)
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, rendered, "8080/tcp")
s.Require().NoError(err)
defer clientC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080", fmt.Sprintf("http://%s:5678", s.echoIP)}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, name+" client logs", clientC)
DumpLogs(s.T(), s.ctx, name+" server logs", serverC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
})
}
+115
View File
@@ -0,0 +1,115 @@
package e2e
import (
"context"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// SniffingSuite covers protocol sniffing behavior.
type SniffingSuite struct {
suite.Suite
ctx context.Context
httpsEchoC testcontainers.Container
httpsIP string
}
func (s *SniffingSuite) SetupSuite() {
s.ctx = context.Background()
c, err := RunHTTPSEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.httpsEchoC = c
ip, err := c.ContainerIP(s.ctx)
s.Require().NoError(err)
s.httpsIP = ip
}
func (s *SniffingSuite) TearDownSuite() {
if s.httpsEchoC != nil {
s.httpsEchoC.Terminate(s.ctx)
}
}
// curlThroughSOCKS5 runs curl through the GOST SOCKS5 proxy on the given
// container address and port, connecting to the HTTPS echo server by IP.
// Connecting by IP makes curl omit the TLS SNI extension.
func (s *SniffingSuite) curlThroughSOCKS5(c testcontainers.Container, proxyPort string) string {
s.T().Helper()
// curl -k: don't verify the self-signed cert on the echo server
// curl -x socks5://...: route through GOST SOCKS5 proxy
// Using the container IP as the host makes curl skip SNI entirely.
cmd := []string{"curl", "-k", "-s",
"-x", "socks5://127.0.0.1:" + proxyPort,
"https://" + s.httpsIP + ":8443",
}
code, out, err := c.Exec(s.ctx, cmd)
s.Require().NoError(err)
s.Require().Zero(code, "curl should exit 0")
b, _ := io.ReadAll(out)
return string(b)
}
// TestSOCKS5NoSNI verifies that a SOCKS5 proxy with sniffing enabled can
// successfully forward TLS connections that lack an SNI extension — such as
// when a client connects to an HTTPS server by IP address.
func (s *SniffingSuite) TestSOCKS5NoSNI() {
gostC, err := RunGostContainer(s.ctx, SharedNetworkName,
"testdata/sniffing/no_sni.yaml",
)
s.Require().NoError(err)
defer func() {
DumpLogs(s.T(), s.ctx, "gost-no-sni", gostC)
gostC.Terminate(s.ctx)
}()
body := s.curlThroughSOCKS5(gostC, "8080")
s.Require().Contains(body, "hello-gost",
"HTTPS response should pass through when SNI is empty")
}
// TestSOCKS5NoSNI_Callback verifies the same behavior a second time, serving
// as a sanity check that state doesn't leak between connections.
func (s *SniffingSuite) TestSOCKS5NoSNI_Callback() {
s.TestSOCKS5NoSNI()
}
// TestSOCKS5NoSNI_LogsOnlyDebug verifies the proxy logs a debug message
// rather than an error when SNI is missing.
func (s *SniffingSuite) TestSOCKS5NoSNI_LogsOnlyDebug() {
gostC, err := RunGostContainer(s.ctx, SharedNetworkName,
"testdata/sniffing/no_sni.yaml",
)
s.Require().NoError(err)
defer func() {
// Do NOT dump logs before the grep — DumpLogs consumes the reader
gostC.Terminate(s.ctx)
}()
body := s.curlThroughSOCKS5(gostC, "8080")
s.Require().Contains(body, "hello-gost")
// Verify the debug log message exists and no error-level SNI message.
logs, err := gostC.Logs(s.ctx)
s.Require().NoError(err)
defer logs.Close()
logBody, _ := io.ReadAll(logs)
logStr := string(logBody)
s.Require().Contains(logStr, "no sni in clienthello",
"should log a debug message when SNI is empty")
s.Require().NotContains(logStr, "tls: sni is empty",
"must not error on empty SNI")
// The log level for "no sni" should be debug, not error.
s.Require().False(strings.Contains(logStr, `"level":"error"`),
"should not log any error-level message")
}
func TestSniffingSuite(t *testing.T) {
suite.Run(t, new(SniffingSuite))
}
+17
View File
@@ -0,0 +1,17 @@
# HTTP proxy gated by a blacklist admission rule: clients whose source address
# is 127.0.0.1 are denied; everything else is admitted. A loopback client (curl
# inside the gost container) is denied; an external client (a different
# container) is admitted and reaches the echo server.
services:
- name: proxy
addr: ":8080"
admission: admission-0
handler:
type: http
listener:
type: tcp
admissions:
- name: admission-0
matchers:
- 127.0.0.1
+18
View File
@@ -0,0 +1,18 @@
# HTTP proxy gated by a whitelist admission rule: only clients whose source
# address is 127.0.0.1 are admitted. A loopback client (curl inside the gost
# container) is admitted and reaches the echo server; an external client (a
# different container) is denied at accept time.
services:
- name: proxy
addr: ":8080"
admission: admission-0
handler:
type: http
listener:
type: tcp
admissions:
- name: admission-0
whitelist: true
matchers:
- 127.0.0.1
+18
View File
@@ -0,0 +1,18 @@
# HTTP proxy authenticated via a named auther holding multiple users. Any
# listed user/password pair is accepted; unlisted credentials are rejected.
services:
- name: proxy
addr: ":8080"
handler:
type: http
auther: auther-0
listener:
type: tcp
authers:
- name: auther-0
auths:
- username: alice
password: secret
- username: bob
password: hunter2
+13
View File
@@ -0,0 +1,13 @@
# HTTP proxy with inline single-user authentication. Clients must supply the
# proxy credentials user/pass; wrong or missing credentials get a 407 and never
# reach the echo server.
services:
- name: proxy
addr: ":8080"
handler:
type: http
auth:
username: user
password: pass
listener:
type: tcp
+29
View File
@@ -0,0 +1,29 @@
# Forward proxy whose only chain node is dead (127.0.0.1:18082). A blacklist
# bypass matching the destination skips that node and connects directly to the
# echo server; a non-matching destination is forced through the dead node.
services:
- name: proxy
addr: ":8080"
handler:
type: http
chain: my-chain
listener:
type: tcp
chains:
- name: my-chain
hops:
- name: hop-0
nodes:
- name: node-0
addr: 127.0.0.1:18082
bypass: bypass-0
connector:
type: http
dialer:
type: tcp
bypasses:
- name: bypass-0
matchers:
- "{{.ServerAddr}}"
+32
View File
@@ -0,0 +1,32 @@
# Forward proxy whose only chain node is dead (127.0.0.1:18082). A whitelist
# bypass inverts the logic: only destinations matching the rule are forced
# through the chain. The echo server's address is outside 10.0.0.0/8, so it is
# bypassed (connected directly); a 10.0.0.0/8 destination is forced through the
# dead node.
services:
- name: proxy
addr: ":8080"
handler:
type: http
chain: my-chain
listener:
type: tcp
chains:
- name: my-chain
hops:
- name: hop-0
nodes:
- name: node-0
addr: 127.0.0.1:18082
bypass: bypass-0
connector:
type: http
dialer:
type: tcp
bypasses:
- name: bypass-0
whitelist: true
matchers:
- "10.0.0.0/8"
+48
View File
@@ -0,0 +1,48 @@
services:
- name: proxy
addr: :8080
handler:
type: http
chainGroup:
chains:
- chain: chain-target
matcher:
rule: Host(`tcp-echo`)
- chain: chain-other
matcher:
rule: Host(`no-match`)
selector:
strategy: round
maxFails: 1
failTimeout: 10s
listener:
type: tcp
# Live relay: forwards to echo server.
- name: relay
addr: 127.0.0.1:18081
handler:
type: http
listener:
type: tcp
chains:
- name: chain-target
hops:
- name: hop-target
nodes:
- name: n-live
addr: 127.0.0.1:18081
connector:
type: http
# This chain has a dead relay — but the matcher Host("no-match") ensures it is
# never selected for requests targeting tcp-echo.
- name: chain-other
hops:
- name: hop-other
nodes:
- name: n-dead
addr: 127.0.0.1:18082
connector:
type: http
+52
View File
@@ -0,0 +1,52 @@
services:
- name: proxy
addr: :8080
handler:
type: http
chainGroup:
chains:
- chain: chain-live
probe:
type: tcp
addr: 127.0.0.1:18081
interval: 3s
- chain: chain-dead
probe:
type: tcp
addr: 127.0.0.1:18082
interval: 3s
selector:
strategy: round
maxFails: 1
failTimeout: 10s
listener:
type: tcp
# Live relay: forwards to echo server.
- name: relay
addr: 127.0.0.1:18081
handler:
type: http
listener:
type: tcp
chains:
- name: chain-live
hops:
- name: hop-live
nodes:
- name: n-live
addr: 127.0.0.1:18081
connector:
type: http
# Dead chain: relay never listens here. TCP probe marks the chain entry so
# FailFilter excludes it before real traffic reaches it.
- name: chain-dead
hops:
- name: hop-dead
nodes:
- name: n-dead
addr: 127.0.0.1:18082
connector:
type: http
+16
View File
@@ -0,0 +1,16 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
dns: udp://dns-server:5353
timeout: 5s
listener:
type: dns
bypass: block-example
bypasses:
- name: block-example
matchers:
- test.example.com
+10
View File
@@ -0,0 +1,10 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
dns: udp://127.0.0.1:1
timeout: 2s
listener:
type: dns
+22
View File
@@ -0,0 +1,22 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
# Unreachable upstream so the system-DNS fallback is bypassed.
# The host mapper resolves mapped names before the exchanger is
# reached; unmapped names must hit the exchanger and fail.
dns: udp://127.0.0.1:1
timeout: 2s
listener:
type: dns
hosts: my-hosts
hosts:
- name: my-hosts
mappings:
- ip: 10.0.0.100
hostname: mapped.example.com
- ip: 192.168.1.200
hostname: aaaa.mapped.example.com
+16
View File
@@ -0,0 +1,16 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
dns: udp://dns-server:5353
timeout: 5s
listener:
type: dns
rlimiter: limiter-0
rlimiters:
- name: limiter-0
limits:
- "$ 1000"
+12
View File
@@ -0,0 +1,12 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
dns: tcp://dns-server:5353
timeout: 5s
listener:
type: dns
metadata:
mode: tcp
+12
View File
@@ -0,0 +1,12 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
dns: udp://dns-server:5353
timeout: 5s
listener:
type: dns
metadata:
mode: tls
+10
View File
@@ -0,0 +1,10 @@
services:
- name: dns
addr: :1053
handler:
type: dns
metadata:
dns: udp://dns-server:5353
timeout: 5s
listener:
type: dns
+27
View File
@@ -0,0 +1,27 @@
services:
# Reverse proxy with sniffing: raw TCP listener inspects HTTP via the
# forwarder's sniffer. The hop has FIFO selector (always picks first node).
# node-429 is first in the list with failCodes=429 — it must be marked on
# the first 429 response and excluded by FailFilter (maxFails=1).
- name: reverse-proxy
addr: :8080
handler:
type: tcp
metadata:
sniffing: true
sniffing.timeout: 2s
listener:
type: tcp
forwarder:
selector:
strategy: fifo
maxFails: 1
nodes:
- name: node-429
addr: status-backend:5680
protocol: http
http:
failCodes: "429"
- name: node-good
addr: tcp-echo:5678
protocol: http
View File
+1
View File
@@ -0,0 +1 @@
<html><body>gost file index</body></html>
+9
View File
@@ -0,0 +1,9 @@
services:
- name: file-server
addr: :8080
handler:
type: file
metadata:
file.dir: /srv/files
listener:
type: tcp
+15
View File
@@ -0,0 +1,15 @@
services:
- name: file-server-auth
addr: :8080
handler:
type: file
auther: auther-0
metadata:
file.dir: /srv/files
listener:
type: tcp
authers:
- name: auther-0
auths:
- username: user
password: pass
+10
View File
@@ -0,0 +1,10 @@
services:
- name: file-server-put
addr: :8080
handler:
type: file
metadata:
file.dir: /srv/files
file.put: true
listener:
type: tcp
+11
View File
@@ -0,0 +1,11 @@
services:
- name: forward-tcp
addr: :8000
handler:
type: tcp
listener:
type: tcp
forwarder:
nodes:
- name: echo
addr: tcp-echo:5678
+20
View File
@@ -0,0 +1,20 @@
services:
- name: forward-tcp
addr: :8000
handler:
type: tcp
metadata:
sniffing: true
sniffing.timeout: 2s
listener:
type: tcp
bypass: block-all
forwarder:
nodes:
- name: echo
addr: tcp-echo:5678
bypasses:
- name: block-all
matchers:
- 0.0.0.0/0
+13
View File
@@ -0,0 +1,13 @@
services:
- name: forward-tcp
addr: :8000
handler:
type: tcp
metadata:
idleTimeout: 3s
listener:
type: tcp
forwarder:
nodes:
- name: echo
addr: tcp-echo:5678
+18
View File
@@ -0,0 +1,18 @@
services:
- name: forward-tcp
addr: :8000
handler:
type: tcp
metadata:
sniffing: true
sniffing.timeout: 2s
listener:
type: tcp
forwarder:
nodes:
- name: echo-http
addr: tcp-echo:5678
protocol: http
- name: echo-tls
addr: tcp-echo:1
protocol: tls
+14
View File
@@ -0,0 +1,14 @@
services:
- name: forward-tcp
addr: :8000
handler:
type: tcp
metadata:
sniffing: true
sniffing.timeout: 2s
listener:
type: tcp
forwarder:
nodes:
- name: echo
addr: tcp-echo:5678
+11
View File
@@ -0,0 +1,11 @@
services:
- name: udp-forward
addr: :9000
handler:
type: udp
listener:
type: udp
forwarder:
nodes:
- name: echo
addr: udp-echo:5679

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