mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c962805818 | ||
|
|
cd1aabd1cc | ||
|
|
88a68f51c8 | ||
|
|
22eb86d08e | ||
|
|
a44625fcb7 | ||
|
|
92272e0bb4 | ||
|
|
993419d845 | ||
|
|
8d253dcd88 | ||
|
|
49c998ee45 | ||
|
|
0954e57dcb | ||
|
|
1d0b6de411 | ||
|
|
4f143aa694 | ||
|
|
a654aee7af | ||
|
|
c2c78f3279 | ||
|
|
9e3ce7cb68 | ||
|
|
bb682a90c9 | ||
|
|
be86cfef74 | ||
|
|
640e239280 | ||
|
|
98f64ea13d | ||
|
|
295a2c2296 | ||
|
|
1d42c2ec66 | ||
|
|
3e533c61c7 | ||
|
|
89188536d3 | ||
|
|
239da867c3 | ||
|
|
17909c4f95 | ||
|
|
8f6a725e7e | ||
|
|
312187d3d8 | ||
|
|
bacaa9af89 | ||
|
|
a939758583 | ||
|
|
9ec8bdbfeb | ||
|
|
4bbf81c9f4 | ||
|
|
6911ecd304 | ||
|
|
429c6a6199 | ||
|
|
1e17b38512 | ||
|
|
d2678b8f5f | ||
|
|
3d49aef879 | ||
|
|
dc9c7444b9 | ||
|
|
f1cd7c945a | ||
|
|
bc137fbd83 | ||
|
|
c67b2ec1d2 | ||
|
|
8787aff108 | ||
|
|
6e84d375c1 | ||
|
|
e95b830670 | ||
|
|
64b14930b3 | ||
|
|
70b52694eb | ||
|
|
8683da6a65 | ||
|
|
2306f45bf9 | ||
|
|
85c257ad65 | ||
|
|
ec4800293d | ||
|
|
1fadb8fe38 | ||
|
|
660af7ce59 | ||
|
|
ce290f9143 | ||
|
|
1776e1a4ef | ||
|
|
a9cc544470 | ||
|
|
73baffc689 | ||
|
|
5b85e90db8 | ||
|
|
3627030098 | ||
|
|
34fbff3d3c | ||
|
|
1c42c638a6 | ||
|
|
06197bd987 | ||
|
|
a5605b935c | ||
|
|
8c999f1f5b | ||
|
|
7a4db061c9 | ||
|
|
a3e82c086c | ||
|
|
ad702b4cbe | ||
|
|
2cb1365d46 | ||
|
|
4f3261a9d9 | ||
|
|
988c9afe28 | ||
|
|
30f5a0b2c8 | ||
|
|
5bf06ef9ac | ||
|
|
8c255d8ec4 | ||
|
|
54ea0220a9 | ||
|
|
f70b114578 | ||
|
|
4cd6cd5c30 | ||
|
|
dcd02f8d52 | ||
|
|
d7f0b88086 | ||
|
|
71c1566b00 | ||
|
|
8127c99153 | ||
|
|
967053c690 | ||
|
|
fe9d9c9574 | ||
|
|
dadbbdf5c7 | ||
|
|
c210b74106 | ||
|
|
dac42ba39c | ||
|
|
53269265e1 | ||
|
|
cfccd22fc9 | ||
|
|
de301e8cfb | ||
|
|
e119a973f2 | ||
|
|
66960e7dd6 | ||
|
|
14fed91c62 | ||
|
|
2299c43bb7 | ||
|
|
dd65d46926 | ||
|
|
2e9befc39c | ||
|
|
93bd2ea760 | ||
|
|
9292e0af3e | ||
|
|
6ad7269644 | ||
|
|
4ea4efaba9 | ||
|
|
faeede7881 | ||
|
|
a38a701dc4 | ||
|
|
0132cefaec | ||
|
|
25cd60d42d | ||
|
|
f8ca4908f3 | ||
|
|
cee264e184 | ||
|
|
64ea5184f8 | ||
|
|
fc5fc052e4 | ||
|
|
e6aeba8701 | ||
|
|
cc02cff00c | ||
|
|
18df26e9bc | ||
|
|
a9be7e6dce | ||
|
|
0e0eab0c52 | ||
|
|
98e9651361 | ||
|
|
e4e36ca095 | ||
|
|
746811eea5 | ||
|
|
7f72a81864 | ||
|
|
2112532e0a | ||
|
|
b2d901e70f | ||
|
|
b106f764a6 | ||
|
|
edb0b5d651 | ||
|
|
e3e41abd66 | ||
|
|
221144fab3 | ||
|
|
6b1c6757f6 | ||
|
|
3157436140 | ||
|
|
0ab5363f60 | ||
|
|
88670baeae | ||
|
|
226420a622 | ||
|
|
3cd4fad4bf | ||
|
|
291fa48fae | ||
|
|
6e9ff675b5 | ||
|
|
506edffc93 | ||
|
|
6248d4c0f8 | ||
|
|
50f4a6b096 | ||
|
|
30ba6d64a5 | ||
|
|
72db96d4cd | ||
|
|
8edf82af9b | ||
|
|
839f2a280b | ||
|
|
c23a2f1e53 | ||
|
|
0ffc000d0d | ||
|
|
486a77665d | ||
|
|
db3c7a375c | ||
|
|
9aab47bcd2 | ||
|
|
3474501784 | ||
|
|
b92c1b130a | ||
|
|
431fe759dc | ||
|
|
4d15ca54af | ||
|
|
6096be44af | ||
|
|
bf6f7713d8 | ||
|
|
6a44b6f018 | ||
|
|
641536de3d | ||
|
|
edd6e5f3f2 | ||
|
|
88b03e0721 | ||
|
|
7a190938aa | ||
|
|
bc06c7f5da | ||
|
|
d0287998cf | ||
|
|
8602043e8d | ||
|
|
7952b4f55d | ||
|
|
7c44ba8125 | ||
|
|
58ab312950 | ||
|
|
f5127f010c | ||
|
|
2edc6d045a | ||
|
|
7b088f8f42 | ||
|
|
f59eac64d9 | ||
|
|
46fc054ad5 | ||
|
|
c91a414d68 | ||
|
|
84f83f6776 | ||
|
|
c16a2804e0 | ||
|
|
255f9ff6ec | ||
|
|
c5c8f0903c | ||
|
|
b8f7c71910 | ||
|
|
fe394a8420 | ||
|
|
d12663dfe3 | ||
|
|
108fd6a1f5 | ||
|
|
f9fb5f443d | ||
|
|
32ebe6550c | ||
|
|
5810404829 | ||
|
|
a2e2810669 | ||
|
|
ca767cc102 | ||
|
|
2baf376c87 | ||
|
|
19be0882db | ||
|
|
1e6c0851e6 | ||
|
|
0ca1e7621a | ||
|
|
4bc3886825 | ||
|
|
52b80b257f | ||
|
|
7b5a92817f | ||
|
|
a7041074a8 | ||
|
|
d024c7786e | ||
|
|
d8317d876f | ||
|
|
69c397ded9 | ||
|
|
7f36d10605 | ||
|
|
5b1ff8330b | ||
|
|
3193eebd6c | ||
|
|
c71b16490b | ||
|
|
ae73d1ba61 | ||
|
|
4c086685b1 | ||
|
|
56c72823cb | ||
|
|
2a9e059bc2 | ||
|
|
f63f36805b | ||
|
|
1b07475bf3 | ||
|
|
3b25e0317f | ||
|
|
fb0cf72446 | ||
|
|
ee7b1e9462 | ||
|
|
143438a30e | ||
|
|
cd97e5e746 | ||
|
|
c1d954d689 | ||
|
|
d4e9450bad | ||
|
|
b777bcb81a | ||
|
|
39580f04ca | ||
|
|
b01f5d065f | ||
|
|
8fd5200bf1 | ||
|
|
ec4791485c | ||
|
|
c06ba17916 | ||
|
|
a32ffd5608 | ||
|
|
ffa7ceaed7 | ||
|
|
ea3ca94e53 | ||
|
|
5d6b00c3a6 | ||
|
|
0637486025 | ||
|
|
99a0f7323d | ||
|
|
4057ac54b8 | ||
|
|
6a0ce8914e | ||
|
|
03f4b1291e | ||
|
|
2e16457467 | ||
|
|
a1d7da4fec | ||
|
|
0522ae8155 | ||
|
|
55a246869e | ||
|
|
3ec3362ac4 | ||
|
|
3f01456480 | ||
|
|
9ea10763e4 | ||
|
|
e814852ca1 | ||
|
|
a102f9c6cf | ||
|
|
9ccf402ed1 | ||
|
|
61eb3e2a3b | ||
|
|
92002fa35e | ||
|
|
a424148018 | ||
|
|
d4d823df45 | ||
|
|
c45d27113e | ||
|
|
9fab332772 | ||
|
|
37bc81576c | ||
|
|
504c56f3c3 | ||
|
|
ae92f5aee6 | ||
|
|
48f31513c7 | ||
|
|
548471717a | ||
|
|
e583f5b538 | ||
|
|
9c585e2de8 | ||
|
|
34549e0e98 | ||
|
|
684a0ddf65 | ||
|
|
9fcd7d6890 | ||
|
|
66f502b153 | ||
|
|
1d34d4543a | ||
|
|
296d87a597 | ||
|
|
c29517be4d | ||
|
|
b23553d37a | ||
|
|
b7d807b464 | ||
|
|
dc31d5c15f | ||
|
|
a86b5ab66b | ||
|
|
183252f6ae | ||
|
|
301dbc56d0 | ||
|
|
219dd51548 | ||
|
|
ca582a147f | ||
|
|
c5b62d41ae | ||
|
|
81dc9331b8 | ||
|
|
3b3eaa609a | ||
|
|
9b4a320a9f | ||
|
|
ba3e1cbe09 | ||
|
|
fd17c3e18d | ||
|
|
62265b424a | ||
|
|
18c1d6063d | ||
|
|
49ceda6cc2 | ||
|
|
bc44baba55 | ||
|
|
8b0806bad4 | ||
|
|
9f610fd163 | ||
|
|
eaeac2763e | ||
|
|
caa82f2861 | ||
|
|
078cdbcb81 | ||
|
|
28e5922fce | ||
|
|
3f671abfb6 | ||
|
|
e45d9a8cc8 | ||
|
|
5e3280d166 | ||
|
|
4d4690b535 | ||
|
|
f09a7a69b3 | ||
|
|
066f6813b2 | ||
|
|
c6d3238b15 | ||
|
|
6810e50c2c | ||
|
|
e45328d1bb | ||
|
|
e6c9952ad4 | ||
|
|
1a1851fe03 | ||
|
|
a4d45fa278 | ||
|
|
7e9d14bb41 | ||
|
|
ca83acc323 | ||
|
|
901131d0ed | ||
|
|
105f7e32d0 | ||
|
|
32056092f5 | ||
|
|
9db0f07721 | ||
|
|
b48ef7e363 | ||
|
|
5608b5fa67 | ||
|
|
ef83d839db | ||
|
|
31cf571a6a | ||
|
|
b4f39b4bcb | ||
|
|
69acf08560 | ||
|
|
a1582d9711 | ||
|
|
a1fddedcc3 | ||
|
|
d5fd62aa47 | ||
|
|
95874c53f5 | ||
|
|
785d52da31 | ||
|
|
29a5b16395 | ||
|
|
91920aa805 | ||
|
|
81db46b725 | ||
|
|
d432d28f81 | ||
|
|
31878a72ec | ||
|
|
e791ba47e2 | ||
|
|
382d47ea12 | ||
|
|
5a09a48b2d | ||
|
|
dd19e4387a | ||
|
|
7cf7284339 | ||
|
|
a9d3ffa496 | ||
|
|
cf89192211 | ||
|
|
62ce7e189e | ||
|
|
722dde5cfc | ||
|
|
b8d9c79f72 | ||
|
|
e543c0b0fd | ||
|
|
6ccaae0573 | ||
|
|
a33c6f4a92 | ||
|
|
f8ddb193cb | ||
|
|
32c3d33afd | ||
|
|
7723119d80 | ||
|
|
23b58ddd23 | ||
|
|
c246a1d4fa | ||
|
|
3246b39c93 | ||
|
|
f4be111420 | ||
|
|
776f045896 | ||
|
|
9eeb88e6a2 | ||
|
|
19aa429bea | ||
|
|
7e3183488b | ||
|
|
a2cef9639d | ||
|
|
4f526c2aee | ||
|
|
87b055a83b | ||
|
|
618cc7fd9c | ||
|
|
3646c23398 | ||
|
|
aba7e2a256 | ||
|
|
92564a0c8c | ||
|
|
fb0ee8ead4 | ||
|
|
79718ee19e | ||
|
|
dd76e9f685 | ||
|
|
b3424b769e | ||
|
|
1510f841c3 | ||
|
|
1c07cf6394 | ||
|
|
aabebd047b | ||
|
|
18f39f940b | ||
|
|
b7a4cb0251 | ||
|
|
4443b0b964 | ||
|
|
f20e151cfa | ||
|
|
039bcc9af9 | ||
|
|
28d3ffa310 | ||
|
|
acce86c0e4 | ||
|
|
b1b7500c2f | ||
|
|
64a16fafbc | ||
|
|
689ba36e92 | ||
|
|
7c95d40e05 | ||
|
|
bc3d12ec2c | ||
|
|
64f9a048f8 | ||
|
|
a9d94e7009 | ||
|
|
dc99f4731f | ||
|
|
e9372ea44a | ||
|
|
9bbd7da526 | ||
|
|
05fe690f67 | ||
|
|
fa708f4b5f | ||
|
|
0e96f602fa | ||
|
|
51455da96f | ||
|
|
b991baaf72 | ||
|
|
501051ce4d | ||
|
|
011759d888 | ||
|
|
419f4c4c73 | ||
|
|
4efb4b9974 | ||
|
|
effba3b690 | ||
|
|
2b1e4ca3a7 | ||
|
|
9febb23bc9 | ||
|
|
8390ccadab | ||
|
|
82e7e50120 | ||
|
|
5a838a7d47 | ||
|
|
982ffa8149 | ||
|
|
1f62219c1a | ||
|
|
ee57a383c5 | ||
|
|
c59566d96f | ||
|
|
c7f58a400b | ||
|
|
569572d6f7 | ||
|
|
d015378654 | ||
|
|
bf4af78ebf | ||
|
|
54ca9161f7 | ||
|
|
e99ef1c951 | ||
|
|
91cae5bdeb | ||
|
|
3f73c82d00 | ||
|
|
5433ca580c | ||
|
|
0c37224530 | ||
|
|
b9d74c1b8e | ||
|
|
174bc082d1 | ||
|
|
ed93d60004 | ||
|
|
3c8995027a | ||
|
|
70dee081a4 | ||
|
|
01a2bbaa11 | ||
|
|
66b53580af | ||
|
|
8435716166 | ||
|
|
3402418573 | ||
|
|
238a0b95b7 | ||
|
|
ef58909fb7 | ||
|
|
d189dc7967 | ||
|
|
ac99425002 | ||
|
|
cf70a0cbe1 | ||
|
|
290e9c37d5 | ||
|
|
07ca57055a | ||
|
|
2acc669976 | ||
|
|
6c32dda058 | ||
|
|
2a2643a6b7 | ||
|
|
802943bd28 | ||
|
|
f373f2f4b9 | ||
|
|
96f35bd7aa | ||
|
|
cd7bf9521f | ||
|
|
180145189f | ||
|
|
7625973ca1 | ||
|
|
b3b5986b63 | ||
|
|
254699e176 | ||
|
|
6389378610 | ||
|
|
c5f91232cb | ||
|
|
1752b29df9 | ||
|
|
77eda4ce00 | ||
|
|
a280193dac | ||
|
|
0c97db3c05 | ||
|
|
d2cc07d3ac | ||
|
|
a0c46cec6e | ||
|
|
eae322c7ba | ||
|
|
1c1f092a14 | ||
|
|
a69a759e17 | ||
|
|
007ff0bae9 | ||
|
|
e2072399dd | ||
|
|
c7d16962ec | ||
|
|
a12ed27dfa | ||
|
|
bf70f07419 | ||
|
|
a65b7a059a | ||
|
|
d5683e9138 | ||
|
|
52289bea6c | ||
|
|
d0ff5358df | ||
|
|
0e5c285eb7 | ||
|
|
a5f66b0615 | ||
|
|
f3d3d2231e | ||
|
|
4eb006f1b0 | ||
|
|
2b9f795972 | ||
|
|
589c185053 | ||
|
|
404aaae5f1 | ||
|
|
fd9dc6408a | ||
|
|
b597467858 | ||
|
|
ad5cf6fd61 | ||
|
|
f71351f5ef | ||
|
|
79203e407c | ||
|
|
db21de831a | ||
|
|
a5309eee97 | ||
|
|
b64e5901b6 | ||
|
|
87e454a6f1 | ||
|
|
3bbc10796c | ||
|
|
208d18125c | ||
|
|
35a049fb03 | ||
|
|
281295d02f | ||
|
|
acae52ab89 | ||
|
|
afcf4a5252 | ||
|
|
d9a2f44a78 | ||
|
|
97ed5080a6 | ||
|
|
c8d905dea5 | ||
|
|
fe096d6e74 | ||
|
|
3ec2f9d487 | ||
|
|
ce3c28baf5 | ||
|
|
11d982e062 |
+23
@@ -0,0 +1,23 @@
|
||||
# Build & test artifacts
|
||||
*.test
|
||||
coverage.out
|
||||
coverage*.out
|
||||
coverage*.tmp
|
||||
|
||||
# Generated local TLS cert
|
||||
cert.pem
|
||||
|
||||
# Stray Node tooling in a Go module
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# Index / IDE / agent state
|
||||
.codegraph/
|
||||
.claude/
|
||||
.remember/
|
||||
.memory/
|
||||
.build/
|
||||
__pycache__/
|
||||
|
||||
# Local test configs
|
||||
play/
|
||||
@@ -0,0 +1,27 @@
|
||||
# SDD ledger — plan: x/docs/superpowers/plans/2026-10-07-tun-device-probe.md
|
||||
|
||||
Ruling: sdd-workspace script absent in this skill install (only task-start/task-done exist) — workspace created manually at x/.superpowers/sdd/2026-10-07-tun-device-probe/. Cost if wrong: none, git-ignored scratch.
|
||||
|
||||
Pre-flight shared-interface rows:
|
||||
- T1→T2: md.probe/probeReport + runDeviceProbe signature. Plan pins report func(sentDelta, ackedDelta uint64); runDeviceProbe assumes non-nil (nil guard lives in probeDevice). No conflict.
|
||||
- T1+T2→T3: h.md.probe/probeReport, runDeviceProbe, handleClient iterCtx/conn/ips. handleClient shape to verify in code recon before wiring.
|
||||
- T3→T4: only files + green suite; no interface.
|
||||
|
||||
Task 1: complete (no commit per plan Global Constraints; tests: go test -count=1 ./handler/tun/ ./observer/stats/ → both ok, tun 14.356s).
|
||||
Ruling: metadata constructor alias is xmd (github.com/go-gost/x/metadata), not mdx as plan Step 1 guessed — plan told me to grep first, did. Cost if wrong: nil.
|
||||
Task 2: complete (no commit per plan Global Constraints; tests: go test -count=1 ./handler/tun/ → ok 14.987s).
|
||||
Ruling: probeInterval/probeRecvTimeout are vars not consts (plan pinned values 30s/5s kept; tests override). Pinned signature runDeviceProbe(ctx,dev,ip,report,log) unchanged. Cost if wrong: nil.
|
||||
Ruling: wrote runDeviceProbe impl before its loop test, caught it, deleted impl, re-did RED (undefined) → GREEN. Cost if wrong: unproven test.
|
||||
Ruling: loop tests use atomic counters + polling (net.Pipe Read returns before writer Write/report; plain vars would race). Plan sketch used plain vars. Cost if wrong: flaky test.
|
||||
Task 3: complete (no commit per plan Global Constraints; tests: go test -count=1 ./handler/tun/ ./observer/stats/ → both ok).
|
||||
Note: gofmt -l flags observer/stats/wrapper/io_test.go — pre-existing, untouched (not in git status). My files clean; go vet clean.
|
||||
Ruling: probeDevice takes no log param (uses h.options.Logger, same as keepalive); conn at the wire site IS the tun device (transportClient(ctx, tun=conn, ...) proves it). Cost if wrong: probe writes into the tunnel stream.
|
||||
Task 4: green to the release gate (build ./... + both suites PASS). STOPPING before tag+push: publish side effect, maintainer-manual per plan + repo no-push norm. Awaiting human: commit + tag v0.23.0.
|
||||
Final: fresh review via subagent. C1 (UDP length 16→must be 24, acked could never fire on real device) — confirmed real, re-graded Critical stands.
|
||||
Final: fixed C1 — TestBuildProbePacket UDP-length assertion RED→GREEN, suite handler/tun ok.
|
||||
Final: fixed I1 (Reset forgot probe counters) — TestStatsProbeReset RED→GREEN, suite observer/stats ok.
|
||||
Final: minor (deferred): M1 redial overlap — old probe writes ≤5s alongside new after iterCtx cancel; datagram-atomic, self-healing.
|
||||
Final: minor (deferred): M2 log assumed non-nil in probe paths.
|
||||
Final: minor (deferred): M3 gate goroutine spawn on probeReport != nil to avoid one throwaway goroutine per redial on gost path.
|
||||
Final: reviewer declined go toolchain — overruled, tests ran under /root/.local/go/bin (see memory dev-container-toolchain); kernel trim-vs-drop of len-16 packet no longer matters post-fix; water-driver atomicity stands on datagram semantics + keepalive precedent.
|
||||
Pre-release workspace check (GOWORK=/root/opencode/probework/go.work, x+wisper+gost): go build ./... all three OK; wisper ./tunnel/... PASS (14.6s/15.3s); x handler/tun + observer/stats PASS.
|
||||
@@ -0,0 +1,139 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this directory.
|
||||
|
||||
## Build & Verify
|
||||
|
||||
```bash
|
||||
cd x && go build ./... # verify compilation
|
||||
cd x && go vet ./... # static analysis
|
||||
cd x && go build ./... && go vet ./... # both
|
||||
```
|
||||
|
||||
There are no tests in this module. Build + vet is the verification path.
|
||||
|
||||
## Architecture
|
||||
|
||||
`x/` is the **implementation layer** for the GOST proxy framework. Every interface defined in `core/` is implemented here. The module ties the whole system together: it provides ~25 handler implementations, ~25 listener implementations, ~20 dialer implementations, ~15 connector implementations, plus config parsing, registry management, and all cross-cutting concerns.
|
||||
|
||||
### Component pattern (applies to every handler, listener, dialer, connector)
|
||||
|
||||
Every component follows this exact pattern:
|
||||
|
||||
```go
|
||||
// 1. init() self-registration via blank import
|
||||
func init() {
|
||||
registry.HandlerRegistry().Register("http", NewHandler)
|
||||
}
|
||||
|
||||
// 2. Private struct holding options + parsed metadata
|
||||
type httpHandler struct {
|
||||
md metadata
|
||||
options handler.Options
|
||||
}
|
||||
|
||||
// 3. Functional options constructor
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
options := handler.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
return &httpHandler{options: options}
|
||||
}
|
||||
|
||||
// 4. Init extracts typed values from generic metadata
|
||||
func (h *httpHandler) Init(md md.Metadata) error {
|
||||
return h.parseMetadata(md)
|
||||
}
|
||||
```
|
||||
|
||||
Each component package also defines a private `metadata` struct and a `parseMetadata(md)` method that uses `mdutil.GetBool/GetInt/GetString/GetDuration(md, keys...)` to extract configuration. Metadata keys are lowercased and multiple fallback keys are tried (e.g., `"observePeriod"`, `"observer.period"`, `"observer.observePeriod"`).
|
||||
|
||||
### Config parsing and service composition
|
||||
|
||||
`config/parsing/service/parse.go` is the critical wiring function. `ParseService(cfg)` does:
|
||||
|
||||
1. Defaults listener type to `"tcp"`, handler type to `"auto"`
|
||||
2. Sets up TLS config, network namespace, authentication, admission, bypass
|
||||
3. Looks up the listener and handler factories from their registries, calls them with composed options
|
||||
4. Creates a `service.Service` (which runs the accept loop calling `handler.Handle` per connection)
|
||||
5. Wraps the listener in order: proxyproto → metrics → stats → admission → traffic limiter → connection limiter
|
||||
|
||||
Each config sub-package (`config/parsing/{auth,bypass,admission,chain,hop,limiter,...}/`) has a `parse.go` that converts config structs into registry lookups and constructed objects.
|
||||
|
||||
### Metadata system (`metadata/`)
|
||||
|
||||
`metadata.NewMetadata(map[string]any)` wraps a raw config map. All keys are lowercased on lookup. The `mdutil` package (`metadata/util/`) provides typed accessors used everywhere:
|
||||
|
||||
- `mdutil.GetBool(md, keys...)`, `GetInt`, `GetFloat`, `GetString`, `GetStrings`
|
||||
- `mdutil.GetDuration(md, keys...)` — int values treated as seconds; strings parsed with `time.ParseDuration`
|
||||
- `mdutil.GetStringMap(md, keys...)`, `GetStringMapString`
|
||||
|
||||
Metadata key constants are defined in `config/parsing/parse.go` (e.g., `MDKeyProxyProtocol`, `MDKeySoMark`, `MDKeyInterface`, `MDKeyEnableStats`).
|
||||
|
||||
### Internal packages (`internal/`)
|
||||
|
||||
**Do not import from external modules.** Go's `internal/` restriction enforces this. Key internal packages:
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `internal/ctx/` | Context values for session state during requests |
|
||||
| `internal/io/` | Custom IO readers/writers |
|
||||
| `internal/loader/` | `Loader`, `Lister`, `Mapper` interfaces for hot-reloadable components |
|
||||
| `internal/matcher/` | Pattern matching utilities |
|
||||
| `internal/net/` | Network utilities (resolver, dialer, proxyproto, HTTP helpers, UDP listener) |
|
||||
| `internal/plugin/` | External plugin process management |
|
||||
| `internal/util/sniffing/` | Protocol sniffing (TLS, HTTP, WebSocket) |
|
||||
| `internal/util/stats/` | Connection stats tracking |
|
||||
| `internal/util/tls/` | TLS config loading, cert management |
|
||||
| `internal/util/ws/` | WebSocket utilities |
|
||||
|
||||
### Context propagation (`ctx/`)
|
||||
|
||||
`x/ctx/value.go` provides typed context key/value helpers used to pass per-request data through the handler chain:
|
||||
|
||||
- `ContextWithSid` / `SidFromContext` — Session ID
|
||||
- `ContextWithSrcAddr` / `SrcAddrFromContext` — Source address
|
||||
- `ContextWithDstAddr` / `DstAddrFromContext` — Destination address
|
||||
- `ContextWithClientID` / `ClientIDFromContext` — Client ID (for hash-based load balancing)
|
||||
- `ContextWithHash` / `HashFromContext` — Hash source for selector
|
||||
|
||||
### Hot-reloadable components
|
||||
|
||||
Auth, bypass, admission, hosts, ingress, limiter, recorder, router, and SD components all support periodic reload from file/redis/http sources. The pattern:
|
||||
|
||||
1. Options include `fileLoader`, `redisLoader`, `httpLoader` plus a `period` duration
|
||||
2. A background goroutine runs `periodReload(ctx)` calling `loader.Load()` on each tick
|
||||
3. The component swaps its internal data structure (e.g., `ipMatcher`, `hostMatcher`) atomically
|
||||
|
||||
### Listener wrapper ordering
|
||||
|
||||
Listeners wrap `net.Listener` in a fixed order. Getting this wrong breaks metrics/admission/limiting:
|
||||
```
|
||||
proxyproto → metrics → stats → admission → traffic_limiter → conn_limiter → (protocol-specific)
|
||||
```
|
||||
|
||||
### Registry
|
||||
|
||||
All registries are in `x/registry/registry.go` — a single file with ~25 typed global singletons backed by `sync.Map`. Each is accessed via an exported function (e.g., `registry.HandlerRegistry()`, `registry.ListenerRegistry()`). Registration returns `ErrDup` on name collision; empty names are silently ignored.
|
||||
|
||||
### Selectors (`selector/`)
|
||||
|
||||
Load-balancing strategies for hops and chain groups:
|
||||
|
||||
| Strategy | Behavior |
|
||||
|----------|----------|
|
||||
| `RoundRobinStrategy` | Atomic counter, modulo selection |
|
||||
| `RandomStrategy` | Weighted random using `RandomWeighted` |
|
||||
| `FIFOStrategy` | Always first element (stickiness) |
|
||||
| `HashStrategy` | CRC32 of client ID or hash context value |
|
||||
|
||||
Filters (`FailFilter`, `BackupFilter`) are applied before the strategy, reducing the candidate pool.
|
||||
|
||||
### API (`api/`)
|
||||
|
||||
Uses Gin framework. Registers CRUD endpoints under `/config` for all component types. Supports basic auth when an auther is configured. Swagger docs at `/docs`.
|
||||
|
||||
### Metrics (`metrics/`)
|
||||
|
||||
Prometheus-style metrics following `_total`, `_seconds` naming conventions. Constants for all metric names (e.g., `MetricServiceRequestsCounter`, `MetricNodeConnectDurationObserver`). `Enable(bool)` toggles globally. When disabled, `GetCounter/Gauge/Observer` return noop implementations.
|
||||
@@ -1 +1,33 @@
|
||||
# The extended (may be experimental) features outside the main gost tree.
|
||||
# x
|
||||
|
||||
The implementation layer for the GOST proxy framework. Every interface defined in [core/](../core/) is implemented here — handlers, listeners, dialers, connectors, plus config parsing, registries, and all cross-cutting concerns.
|
||||
|
||||
## Package layout
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `handler/` | Protocol handlers (http, socks4/5, ss, ssh, tunnel, tun, dns, redirect, etc.) |
|
||||
| `listener/` | Protocol listeners (tcp, tls, ws, http2/3, quic, kcp, icmp, tun, udp, etc.) |
|
||||
| `dialer/` | Outbound dialers (tcp, tls, ws, http2/3, quic, grpc, ssh, wg, kcp, icmp, etc.) |
|
||||
| `connector/` | Destination connectors (http, socks4/5, ss, ssh, relay, tunnel, direct, etc.) |
|
||||
| `config/` | Config struct, YAML/JSON parsing, and service construction |
|
||||
| `registry/` | Typed global registries for all component types |
|
||||
| `service/` | Accept loop wiring listener + handler |
|
||||
| `chain/` | Multi-hop forwarding chain and route implementation |
|
||||
| `router/` | Route table router (destination-based routing) |
|
||||
| `hop/` | Node group with load-balanced selection |
|
||||
| `selector/` | Load-balancing strategies (round-robin, random, weighted, hash) |
|
||||
| `auth/`, `bypass/`, `admission/` | Authentication, bypass rules, admission control |
|
||||
| `resolver/`, `hosts/` | DNS resolution and host mapping |
|
||||
| `limiter/` | Traffic, connection, and rate limiters |
|
||||
| `recorder/`, `observer/` | Traffic recording and observability |
|
||||
| `ingress/`, `sd/`, `routing/` | Ingress control, service discovery, routing rules |
|
||||
| `logger/`, `metrics/`, `api/` | Logging, Prometheus metrics, Web API |
|
||||
| `metadata/`, `ctx/` | Metadata key-value system and context propagation |
|
||||
| `internal/` | Shared internals — not importable externally |
|
||||
|
||||
## Component pattern
|
||||
|
||||
Every handler, listener, dialer, and connector follows the same pattern: `init()` registers a constructor into a global registry, the constructor takes functional options, and `Init(metadata.Metadata)` extracts typed configuration from the metadata key-value map.
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for detailed architecture and conventions.
|
||||
|
||||
+150
-33
@@ -1,3 +1,20 @@
|
||||
// Package admission implements access control for incoming connections.
|
||||
// Before a connection is handled, the admission controller checks whether
|
||||
// the client's address is allowed to use the service.
|
||||
//
|
||||
// The package provides:
|
||||
// - NewAdmission: a local admission controller backed by IP/CIDR matchers,
|
||||
// with optional periodic reload from file, Redis, or HTTP sources.
|
||||
// - AdmissionGroup: composes multiple admission controllers; all must
|
||||
// admit for the connection to proceed.
|
||||
// - Plugin-based admission (gRPC and HTTP) in the plugin sub-package.
|
||||
// - Connection wrappers in the wrapper sub-package that enforce
|
||||
// admission checks per-read (for TCP) and per-packet (for UDP).
|
||||
//
|
||||
// Matching modes:
|
||||
// - Whitelist (whitelist=true): only addresses matching a rule are admitted.
|
||||
// - Blacklist (whitelist=false): addresses matching a rule are denied;
|
||||
// everything else is admitted. This is the default.
|
||||
package admission
|
||||
|
||||
import (
|
||||
@@ -13,95 +30,160 @@ import (
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/matcher"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// options holds the configuration for a localAdmission instance.
|
||||
type options struct {
|
||||
whitelist bool
|
||||
matchers []string
|
||||
fileLoader loader.Loader
|
||||
// whitelist toggles between blacklist and whitelist mode.
|
||||
// When false (default): matching addresses are denied.
|
||||
// When true: only matching addresses are allowed.
|
||||
whitelist bool
|
||||
|
||||
// matchers holds static patterns provided at construction time
|
||||
// (e.g. from config file or command-line arguments).
|
||||
matchers []string
|
||||
|
||||
// fileLoader loads patterns from a file or directory.
|
||||
// Supports hot-reload via file watching if the loader implements Lister.
|
||||
fileLoader loader.Loader
|
||||
|
||||
// redisLoader loads patterns from a Redis set or key.
|
||||
// Supports hot-reload via periodic polling if the loader implements Lister.
|
||||
redisLoader loader.Loader
|
||||
httpLoader loader.Loader
|
||||
period time.Duration
|
||||
logger logger.Logger
|
||||
|
||||
// httpLoader loads patterns from an HTTP endpoint.
|
||||
// Supports hot-reload via periodic polling.
|
||||
httpLoader loader.Loader
|
||||
|
||||
// period controls the interval between automatic reloads.
|
||||
// Values less than 1 second are clamped to 1 second.
|
||||
// A value <= 0 disables periodic reload (load once at startup).
|
||||
period time.Duration
|
||||
|
||||
// logger is used for debug and warning messages.
|
||||
// Falls back to a no-op logger if nil.
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// Option is a functional option for configuring an admission controller.
|
||||
type Option func(opts *options)
|
||||
|
||||
// WhitelistOption sets whether the admission controller operates in
|
||||
// whitelist mode. In whitelist mode, only addresses matching one or more
|
||||
// rules are admitted; all others are denied.
|
||||
func WhitelistOption(whitelist bool) Option {
|
||||
return func(opts *options) {
|
||||
opts.whitelist = whitelist
|
||||
}
|
||||
}
|
||||
|
||||
// MatchersOption sets static match patterns that are combined with
|
||||
// dynamically loaded patterns on each reload. Patterns may be:
|
||||
// - Bare IP addresses: "192.168.1.1", "::1"
|
||||
// - CIDR networks: "10.0.0.0/8", "fd00::/8"
|
||||
// - Hostnames: resolved to IP addresses via DNS
|
||||
func MatchersOption(matchers []string) Option {
|
||||
return func(opts *options) {
|
||||
opts.matchers = matchers
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadPeriodOption sets the interval for automatically reloading
|
||||
// patterns from external loaders (file, Redis, HTTP). A value <= 0
|
||||
// disables periodic reloading.
|
||||
func ReloadPeriodOption(period time.Duration) Option {
|
||||
return func(opts *options) {
|
||||
opts.period = period
|
||||
}
|
||||
}
|
||||
|
||||
// FileLoaderOption sets the file-based pattern loader.
|
||||
func FileLoaderOption(fileLoader loader.Loader) Option {
|
||||
return func(opts *options) {
|
||||
opts.fileLoader = fileLoader
|
||||
}
|
||||
}
|
||||
|
||||
// RedisLoaderOption sets the Redis-based pattern loader.
|
||||
func RedisLoaderOption(redisLoader loader.Loader) Option {
|
||||
return func(opts *options) {
|
||||
opts.redisLoader = redisLoader
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPLoaderOption sets the HTTP-based pattern loader.
|
||||
func HTTPLoaderOption(httpLoader loader.Loader) Option {
|
||||
return func(opts *options) {
|
||||
opts.httpLoader = httpLoader
|
||||
}
|
||||
}
|
||||
|
||||
// LoggerOption sets the logger for the admission controller.
|
||||
func LoggerOption(logger logger.Logger) Option {
|
||||
return func(opts *options) {
|
||||
opts.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// localAdmission is the in-process admission controller. It maintains
|
||||
// two matchers — one for individual IP addresses and one for CIDR
|
||||
// networks — and checks incoming addresses against them.
|
||||
//
|
||||
// The matchers are rebuilt atomically on each reload: new patterns
|
||||
// are loaded, parsed into IPs and CIDRs, and the matchers are swapped
|
||||
// under a write lock.
|
||||
type localAdmission struct {
|
||||
ipMatcher matcher.Matcher
|
||||
cidrMatcher matcher.Matcher
|
||||
mu sync.RWMutex
|
||||
ipMatcher matcher.Matcher // matches individual IP addresses
|
||||
cidrMatcher matcher.Matcher // matches CIDR network ranges
|
||||
mu sync.RWMutex // protects ipMatcher and cidrMatcher
|
||||
cancelFunc context.CancelFunc
|
||||
options options
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewAdmission creates and initializes a new Admission using matcher patterns as its match rules.
|
||||
// The rules will be reversed if the reverse is true.
|
||||
// NewAdmission creates a local admission controller with the given options.
|
||||
// It starts a background goroutine that periodically reloads patterns
|
||||
// from configured external sources (file, Redis, HTTP).
|
||||
//
|
||||
// By default the controller operates in blacklist mode: any address
|
||||
// matching the loaded patterns is denied. Use WhitelistOption(true)
|
||||
// to invert this.
|
||||
func NewAdmission(opts ...Option) admission.Admission {
|
||||
var options options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p := &localAdmission{
|
||||
cancelFunc: cancel,
|
||||
options: options,
|
||||
ipMatcher: matcher.NopMatcher(),
|
||||
cidrMatcher: matcher.NopMatcher(),
|
||||
cancelFunc: cancel,
|
||||
options: options,
|
||||
logger: options.logger,
|
||||
}
|
||||
if p.logger == nil {
|
||||
p.logger = xlogger.Nop()
|
||||
}
|
||||
|
||||
if err := p.reload(ctx); err != nil {
|
||||
options.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
if p.options.period > 0 {
|
||||
go p.periodReload(ctx)
|
||||
}
|
||||
go p.periodReload(ctx)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *localAdmission) Admit(ctx context.Context, addr string, opts ...admission.Option) bool {
|
||||
// Admit decides whether the given network address is allowed.
|
||||
// It returns true if the address should be accepted, false otherwise.
|
||||
//
|
||||
// The address is first stripped of its port (if present), then matched
|
||||
// against the IP and CIDR matchers. The whitelist/blacklist mode
|
||||
// determines how the match result is interpreted:
|
||||
//
|
||||
// Blacklist (whitelist=false): admit if NOT matched
|
||||
// Whitelist (whitelist=true): admit only if matched
|
||||
//
|
||||
// An empty address or a nil receiver always returns true.
|
||||
func (p *localAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
if addr == "" || p == nil {
|
||||
return true
|
||||
}
|
||||
@@ -117,13 +199,22 @@ func (p *localAdmission) Admit(ctx context.Context, addr string, opts ...admissi
|
||||
p.options.whitelist && matched
|
||||
|
||||
if !b {
|
||||
p.options.logger.Debugf("%s is denied", addr)
|
||||
p.logger.Debugf("%s is denied", addr)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// periodReload triggers an immediate reload, then — if a positive period
|
||||
// is configured — reloads on each tick. It exits when ctx is cancelled.
|
||||
func (p *localAdmission) periodReload(ctx context.Context) error {
|
||||
if err := p.reload(ctx); err != nil {
|
||||
p.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
|
||||
period := p.options.period
|
||||
if period <= 0 {
|
||||
return nil
|
||||
}
|
||||
if period < time.Second {
|
||||
period = time.Second
|
||||
}
|
||||
@@ -134,8 +225,7 @@ func (p *localAdmission) periodReload(ctx context.Context) error {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := p.reload(ctx); err != nil {
|
||||
p.options.logger.Warnf("reload: %v", err)
|
||||
// return err
|
||||
p.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -143,6 +233,8 @@ func (p *localAdmission) periodReload(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// reload loads patterns from all configured sources, parses them into
|
||||
// IP addresses and CIDR networks, and atomically swaps the matchers.
|
||||
func (p *localAdmission) reload(ctx context.Context) error {
|
||||
v, err := p.load(ctx)
|
||||
if err != nil {
|
||||
@@ -161,8 +253,9 @@ func (p *localAdmission) reload(ctx context.Context) error {
|
||||
inets = append(inets, inet)
|
||||
continue
|
||||
}
|
||||
// Try DNS resolution as a last resort.
|
||||
if ipAddr, _ := net.ResolveIPAddr("ip", pattern); ipAddr != nil {
|
||||
p.options.logger.Debugf("resolve IP: %s -> %s", pattern, ipAddr)
|
||||
p.logger.Debugf("resolve IP: %s -> %s", pattern, ipAddr)
|
||||
ips = append(ips, ipAddr.IP)
|
||||
}
|
||||
}
|
||||
@@ -176,12 +269,15 @@ func (p *localAdmission) reload(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// load collects patterns from all configured external loaders
|
||||
// (file, Redis, HTTP). It prefers the Lister interface when available,
|
||||
// falling back to loading raw content via Load and parsing line-by-line.
|
||||
func (p *localAdmission) load(ctx context.Context) (patterns []string, err error) {
|
||||
if p.options.fileLoader != nil {
|
||||
if lister, ok := p.options.fileLoader.(loader.Lister); ok {
|
||||
list, er := lister.List(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("file loader: %v", er)
|
||||
p.logger.Warnf("file loader: %v", er)
|
||||
}
|
||||
for _, s := range list {
|
||||
if line := p.parseLine(s); line != "" {
|
||||
@@ -191,7 +287,7 @@ func (p *localAdmission) load(ctx context.Context) (patterns []string, err error
|
||||
} else {
|
||||
r, er := p.options.fileLoader.Load(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("file loader: %v", er)
|
||||
p.logger.Warnf("file loader: %v", er)
|
||||
}
|
||||
if v, _ := p.parsePatterns(r); v != nil {
|
||||
patterns = append(patterns, v...)
|
||||
@@ -202,13 +298,13 @@ func (p *localAdmission) load(ctx context.Context) (patterns []string, err error
|
||||
if lister, ok := p.options.redisLoader.(loader.Lister); ok {
|
||||
list, er := lister.List(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("redis loader: %v", er)
|
||||
p.logger.Warnf("redis loader: %v", er)
|
||||
}
|
||||
patterns = append(patterns, list...)
|
||||
} else {
|
||||
r, er := p.options.redisLoader.Load(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("redis loader: %v", er)
|
||||
p.logger.Warnf("redis loader: %v", er)
|
||||
}
|
||||
if v, _ := p.parsePatterns(r); v != nil {
|
||||
patterns = append(patterns, v...)
|
||||
@@ -219,17 +315,19 @@ func (p *localAdmission) load(ctx context.Context) (patterns []string, err error
|
||||
if p.options.httpLoader != nil {
|
||||
r, er := p.options.httpLoader.Load(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("http loader: %v", er)
|
||||
p.logger.Warnf("http loader: %v", er)
|
||||
}
|
||||
if v, _ := p.parsePatterns(r); v != nil {
|
||||
patterns = append(patterns, v...)
|
||||
}
|
||||
}
|
||||
|
||||
p.options.logger.Debugf("load items %d", len(patterns))
|
||||
p.logger.Debugf("load items %d", len(patterns))
|
||||
return
|
||||
}
|
||||
|
||||
// parsePatterns reads a newline-delimited stream and returns a list of
|
||||
// non-empty, trimmed lines after stripping comments.
|
||||
func (p *localAdmission) parsePatterns(r io.Reader) (patterns []string, err error) {
|
||||
if r == nil {
|
||||
return
|
||||
@@ -246,6 +344,9 @@ func (p *localAdmission) parsePatterns(r io.Reader) (patterns []string, err erro
|
||||
return
|
||||
}
|
||||
|
||||
// parseLine strips inline comments (everything after the first '#')
|
||||
// and trims surrounding whitespace. It returns an empty string if
|
||||
// the line was a comment-only or blank line.
|
||||
func (p *localAdmission) parseLine(s string) string {
|
||||
if n := strings.IndexByte(s, '#'); n >= 0 {
|
||||
s = s[:n]
|
||||
@@ -253,6 +354,8 @@ func (p *localAdmission) parseLine(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// matched returns true if addr is matched by either the IP or CIDR
|
||||
// matcher. Caller must hold at least a read lock.
|
||||
func (p *localAdmission) matched(addr string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
@@ -261,6 +364,9 @@ func (p *localAdmission) matched(addr string) bool {
|
||||
p.cidrMatcher.Match(addr)
|
||||
}
|
||||
|
||||
// Close stops the background reload goroutine and closes all external
|
||||
// loaders. It is safe to call after the admission controller is no
|
||||
// longer needed.
|
||||
func (p *localAdmission) Close() error {
|
||||
p.cancelFunc()
|
||||
if p.options.fileLoader != nil {
|
||||
@@ -269,22 +375,33 @@ func (p *localAdmission) Close() error {
|
||||
if p.options.redisLoader != nil {
|
||||
p.options.redisLoader.Close()
|
||||
}
|
||||
if p.options.httpLoader != nil {
|
||||
p.options.httpLoader.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// admissionGroup composes multiple admission controllers into one.
|
||||
// All controllers in the group must admit for the overall result to
|
||||
// be true (logical AND).
|
||||
type admissionGroup struct {
|
||||
admissions []admission.Admission
|
||||
}
|
||||
|
||||
// AdmissionGroup creates a composite admission controller that requires
|
||||
// every member to admit the address. If any member denies, the address
|
||||
// is denied immediately (short-circuit evaluation).
|
||||
//
|
||||
// Nil members are skipped.
|
||||
func AdmissionGroup(admissions ...admission.Admission) admission.Admission {
|
||||
return &admissionGroup{
|
||||
admissions: admissions,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *admissionGroup) Admit(ctx context.Context, addr string, opts ...admission.Option) bool {
|
||||
func (p *admissionGroup) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
for _, admission := range p.admissions {
|
||||
if admission != nil && !admission.Admit(ctx, addr, opts...) {
|
||||
if admission != nil && !admission.Admit(ctx, network, addr, opts...) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Option tests ---
|
||||
|
||||
func TestWhitelistOption(t *testing.T) {
|
||||
var opts options
|
||||
WhitelistOption(true)(&opts)
|
||||
assert.True(t, opts.whitelist)
|
||||
|
||||
WhitelistOption(false)(&opts)
|
||||
assert.False(t, opts.whitelist)
|
||||
}
|
||||
|
||||
func TestMatchersOption(t *testing.T) {
|
||||
var opts options
|
||||
MatchersOption([]string{"192.168.1.1", "10.0.0.0/8"})(&opts)
|
||||
assert.Equal(t, []string{"192.168.1.1", "10.0.0.0/8"}, opts.matchers)
|
||||
}
|
||||
|
||||
func TestReloadPeriodOption(t *testing.T) {
|
||||
var opts options
|
||||
ReloadPeriodOption(5 * time.Second)(&opts)
|
||||
assert.Equal(t, 5*time.Second, opts.period)
|
||||
}
|
||||
|
||||
func TestFileLoaderOption(t *testing.T) {
|
||||
var opts options
|
||||
fl := &mockLoader{}
|
||||
FileLoaderOption(fl)(&opts)
|
||||
assert.Equal(t, fl, opts.fileLoader)
|
||||
}
|
||||
|
||||
func TestRedisLoaderOption(t *testing.T) {
|
||||
var opts options
|
||||
rl := &mockLoader{}
|
||||
RedisLoaderOption(rl)(&opts)
|
||||
assert.Equal(t, rl, opts.redisLoader)
|
||||
}
|
||||
|
||||
func TestHTTPLoaderOption(t *testing.T) {
|
||||
var opts options
|
||||
hl := &mockLoader{}
|
||||
HTTPLoaderOption(hl)(&opts)
|
||||
assert.Equal(t, hl, opts.httpLoader)
|
||||
}
|
||||
|
||||
func TestLoggerOption(t *testing.T) {
|
||||
var opts options
|
||||
l := xlogger.Nop()
|
||||
LoggerOption(l)(&opts)
|
||||
assert.Equal(t, l, opts.logger)
|
||||
}
|
||||
|
||||
// newSyncedAdmission creates an admission controller and blocks until the initial
|
||||
// background reload completes, so matchers are ready for immediate assertions.
|
||||
func newSyncedAdmission(opts ...Option) *localAdmission {
|
||||
a := NewAdmission(opts...)
|
||||
la := a.(*localAdmission)
|
||||
// Force a synchronous reload so tests don't race with the background goroutine.
|
||||
la.reload(context.Background())
|
||||
return la
|
||||
}
|
||||
|
||||
// --- NewAdmission tests ---
|
||||
|
||||
func TestNewAdmission_Defaults(t *testing.T) {
|
||||
adm := newSyncedAdmission()
|
||||
require.NotNil(t, adm)
|
||||
defer adm.Close()
|
||||
|
||||
// Should admit by default (blacklist mode with no rules)
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestNewAdmission_WithLogger(t *testing.T) {
|
||||
adm := newSyncedAdmission(LoggerOption(xlogger.Nop()))
|
||||
require.NotNil(t, adm)
|
||||
defer adm.Close()
|
||||
}
|
||||
|
||||
// --- Admit tests ---
|
||||
|
||||
func TestAdmit_EmptyAddr(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", ""))
|
||||
}
|
||||
|
||||
func TestAdmit_NilReceiver(t *testing.T) {
|
||||
var p *localAdmission
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmit_Blacklist_Matched(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
// In blacklist mode, matching IP is denied
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmit_Blacklist_NotMatched(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
// In blacklist mode, non-matching IP is admitted
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
|
||||
}
|
||||
|
||||
func TestAdmit_Whitelist_Matched(t *testing.T) {
|
||||
adm := newSyncedAdmission(
|
||||
WhitelistOption(true),
|
||||
MatchersOption([]string{"192.168.1.1"}),
|
||||
)
|
||||
defer adm.Close()
|
||||
|
||||
// In whitelist mode, matching IP is admitted
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmit_Whitelist_NotMatched(t *testing.T) {
|
||||
adm := newSyncedAdmission(
|
||||
WhitelistOption(true),
|
||||
MatchersOption([]string{"192.168.1.1"}),
|
||||
)
|
||||
defer adm.Close()
|
||||
|
||||
// In whitelist mode, non-matching IP is denied
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
|
||||
}
|
||||
|
||||
func TestAdmit_StripsPort(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
// Address with port should have port stripped before matching
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1:8080"))
|
||||
}
|
||||
|
||||
func TestAdmit_CIDRMatch(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"10.0.0.0/8"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmit_InvalidAddr(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
// Invalid IP should not match and be admitted in blacklist mode
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "invalid"))
|
||||
}
|
||||
|
||||
// --- parseLine tests ---
|
||||
|
||||
func TestParseLine_Normal(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
assert.Equal(t, "192.168.1.1", adm.parseLine("192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestParseLine_WithComment(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
assert.Equal(t, "192.168.1.1", adm.parseLine("192.168.1.1 # a comment"))
|
||||
}
|
||||
|
||||
func TestParseLine_CommentOnly(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
assert.Equal(t, "", adm.parseLine("# comment only"))
|
||||
}
|
||||
|
||||
func TestParseLine_Empty(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
assert.Equal(t, "", adm.parseLine(""))
|
||||
}
|
||||
|
||||
func TestParseLine_Whitespace(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
assert.Equal(t, "192.168.1.1", adm.parseLine(" 192.168.1.1 "))
|
||||
}
|
||||
|
||||
// --- parsePatterns tests ---
|
||||
|
||||
func TestParsePatterns_NilReader(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
patterns, err := adm.parsePatterns(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestParsePatterns_Normal(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
r := strings.NewReader("192.168.1.1\n10.0.0.1\n# comment\n\n fd00::1 \n")
|
||||
patterns, err := adm.parsePatterns(r)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"192.168.1.1", "10.0.0.1", "fd00::1"}, patterns)
|
||||
}
|
||||
|
||||
// --- matched tests ---
|
||||
|
||||
func TestMatched_IP(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.True(t, adm.matched("192.168.1.1"))
|
||||
assert.False(t, adm.matched("10.0.0.1"))
|
||||
}
|
||||
|
||||
func TestMatched_CIDR(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"10.0.0.0/8"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.True(t, adm.matched("10.0.0.1"))
|
||||
assert.False(t, adm.matched("192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestMatched_BothIPAndCIDR(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1", "10.0.0.0/8"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.True(t, adm.matched("192.168.1.1"))
|
||||
assert.True(t, adm.matched("10.0.0.1"))
|
||||
assert.False(t, adm.matched("172.16.0.1"))
|
||||
}
|
||||
|
||||
// --- reload tests ---
|
||||
|
||||
func TestReload_WithHostnameResolution(t *testing.T) {
|
||||
adm := newSyncedAdmission(
|
||||
MatchersOption([]string{"localhost"}),
|
||||
LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
defer adm.Close()
|
||||
|
||||
// After reload, localhost should be resolved to 127.0.0.1 or ::1
|
||||
err := adm.reload(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- load tests ---
|
||||
|
||||
func TestLoad_AllNilLoaders(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_FileLoaderWithList(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
fileLoader: &mockListerLoader{list: []string{"192.168.1.1", "# comment", "10.0.0.1"}},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"192.168.1.1", "10.0.0.1"}, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_FileLoaderWithLoad(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
fileLoader: &mockLoader{data: "192.168.1.1\n10.0.0.1\n"},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"192.168.1.1", "10.0.0.1"}, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_RedisLoaderWithList(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
redisLoader: &mockListerLoader{list: []string{"redis-host-1", "redis-host-2"}},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"redis-host-1", "redis-host-2"}, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_RedisLoaderWithLoad(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
redisLoader: &mockLoader{data: "192.168.2.1\n"},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"192.168.2.1"}, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_HTTPLoader(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
httpLoader: &mockLoader{data: "10.10.10.1\n10.10.10.2\n"},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"10.10.10.1", "10.10.10.2"}, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_FileLoaderWithListError(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
fileLoader: &mockListerLoader{listErr: io.ErrUnexpectedEOF},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_FileLoaderWithLoadError(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
fileLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_RedisLoaderWithListError(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
redisLoader: &mockListerLoader{listErr: io.ErrUnexpectedEOF},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_RedisLoaderWithLoadError(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
redisLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_HTTPLoaderError(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
httpLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, patterns)
|
||||
}
|
||||
|
||||
func TestLoad_FileLoaderWithListParsing(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
fileLoader: &mockListerLoader{list: []string{" # comment", "", " 192.168.1.1 "}},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
patterns, err := adm.load(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"192.168.1.1"}, patterns)
|
||||
}
|
||||
|
||||
// --- periodReload tests ---
|
||||
|
||||
func TestPeriodReload_ZeroPeriod(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
logger: xlogger.Nop(),
|
||||
options: options{period: 0},
|
||||
}
|
||||
err := adm.periodReload(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPeriodReload_NegativePeriod(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
logger: xlogger.Nop(),
|
||||
options: options{period: -1},
|
||||
}
|
||||
err := adm.periodReload(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPeriodReload_WithContextCancel(t *testing.T) {
|
||||
// 500ms < 1s triggers the clamping branch (period = time.Second),
|
||||
// then sleep > 1s to let at least one ticker cycle fire before cancel.
|
||||
adm := &localAdmission{
|
||||
logger: xlogger.Nop(),
|
||||
options: options{period: 500 * time.Millisecond},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- adm.periodReload(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("periodReload did not return after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Close tests ---
|
||||
|
||||
func TestClose_NoLoaders(t *testing.T) {
|
||||
adm := newSyncedAdmission()
|
||||
err := adm.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClose_WithAllLoaders(t *testing.T) {
|
||||
fl := &mockLoader{}
|
||||
rl := &mockLoader{}
|
||||
hl := &mockLoader{}
|
||||
|
||||
adm := newSyncedAdmission(
|
||||
FileLoaderOption(fl),
|
||||
RedisLoaderOption(rl),
|
||||
HTTPLoaderOption(hl),
|
||||
)
|
||||
err := adm.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.True(t, fl.closed)
|
||||
assert.True(t, rl.closed)
|
||||
assert.True(t, hl.closed)
|
||||
}
|
||||
|
||||
// --- DNS resolution in reload ---
|
||||
|
||||
func TestReload_DNSResolution(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
ipMatcher: nil,
|
||||
cidrMatcher: nil,
|
||||
options: options{
|
||||
matchers: []string{"localhost"},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
err := adm.reload(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// localhost should resolve to 127.0.0.1 (or ::1)
|
||||
assert.True(t, adm.matched("127.0.0.1") || adm.matched("::1"))
|
||||
}
|
||||
|
||||
// --- AdmissionGroup tests ---
|
||||
|
||||
func TestAdmissionGroup_Empty(t *testing.T) {
|
||||
g := AdmissionGroup()
|
||||
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmissionGroup_AllAdmit(t *testing.T) {
|
||||
g := AdmissionGroup(
|
||||
alwaysAdmit{},
|
||||
alwaysAdmit{},
|
||||
)
|
||||
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmissionGroup_OneDenies(t *testing.T) {
|
||||
g := AdmissionGroup(
|
||||
alwaysAdmit{},
|
||||
alwaysDeny{},
|
||||
)
|
||||
assert.False(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmissionGroup_NilMember(t *testing.T) {
|
||||
g := AdmissionGroup(
|
||||
nil,
|
||||
alwaysAdmit{},
|
||||
nil,
|
||||
)
|
||||
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestAdmissionGroup_ShortCircuit(t *testing.T) {
|
||||
// The first deny should short-circuit, never calling the second.
|
||||
callCount := 0
|
||||
tracking := &trackingAdmission{admit: false, callCount: &callCount}
|
||||
g := AdmissionGroup(tracking, alwaysAdmit{})
|
||||
assert.False(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.Equal(t, 1, callCount)
|
||||
}
|
||||
|
||||
// --- Mock types ---
|
||||
|
||||
type mockLoader struct {
|
||||
data string
|
||||
loadErr error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (m *mockLoader) Load(ctx context.Context) (io.Reader, error) {
|
||||
if m.loadErr != nil {
|
||||
return nil, m.loadErr
|
||||
}
|
||||
return strings.NewReader(m.data), nil
|
||||
}
|
||||
|
||||
func (m *mockLoader) Close() error {
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockListerLoader struct {
|
||||
list []string
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (m *mockListerLoader) Load(ctx context.Context) (io.Reader, error) {
|
||||
return strings.NewReader(strings.Join(m.list, "\n")), nil
|
||||
}
|
||||
|
||||
func (m *mockListerLoader) List(ctx context.Context) ([]string, error) {
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
return m.list, nil
|
||||
}
|
||||
|
||||
func (m *mockListerLoader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type alwaysAdmit struct{}
|
||||
|
||||
func (alwaysAdmit) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type alwaysDeny struct{}
|
||||
|
||||
func (alwaysDeny) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type trackingAdmission struct {
|
||||
admit bool
|
||||
callCount *int
|
||||
}
|
||||
|
||||
func (t *trackingAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
*t.callCount++
|
||||
return t.admit
|
||||
}
|
||||
|
||||
// Ensure mock types implement the interfaces.
|
||||
var _ loader.Loader = (*mockLoader)(nil)
|
||||
var _ loader.Lister = (*mockListerLoader)(nil)
|
||||
var _ loader.Loader = (*mockListerLoader)(nil)
|
||||
var _ admission.Admission = alwaysAdmit{}
|
||||
var _ admission.Admission = alwaysDeny{}
|
||||
|
||||
// TestAdmit_IPv6 tests that IPv6 addresses are handled correctly.
|
||||
func TestAdmit_IPv6(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"fd00::1"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "fd00::1"))
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "fd00::2"))
|
||||
}
|
||||
|
||||
// TestAdmit_IPv6WithPort tests that IPv6 addresses with port brackets are handled.
|
||||
func TestAdmit_IPv6WithPort(t *testing.T) {
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"::1"}))
|
||||
defer adm.Close()
|
||||
|
||||
// net.SplitHostPort handles [::1]:8080 format
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "[::1]:8080"))
|
||||
}
|
||||
|
||||
// TestNewAdmission_ReloadErrorInBackground verifies that the initial reload
|
||||
// in the background goroutine doesn't panic, even if it fails.
|
||||
func TestNewAdmission_ReloadError(t *testing.T) {
|
||||
fl := &mockLoader{loadErr: io.ErrUnexpectedEOF}
|
||||
adm := newSyncedAdmission(
|
||||
FileLoaderOption(fl),
|
||||
LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
defer adm.Close()
|
||||
|
||||
// Should still work, just logged a warning
|
||||
assert.True(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
// TestAdmissionGroup_NilMemberFirst tests nil as first member.
|
||||
func TestAdmissionGroup_NilMemberFirst(t *testing.T) {
|
||||
g := AdmissionGroup(nil, alwaysDeny{})
|
||||
assert.False(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
// TestAdmissionGroup_AllNil tests all nil members.
|
||||
func TestAdmissionGroup_AllNil(t *testing.T) {
|
||||
g := AdmissionGroup(nil, nil)
|
||||
assert.True(t, g.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
// TestLoggerDefault tests that default logger is set when nil is passed.
|
||||
func TestLoggerOption_Nil(t *testing.T) {
|
||||
var opts options
|
||||
LoggerOption(nil)(&opts)
|
||||
assert.Nil(t, opts.logger)
|
||||
}
|
||||
|
||||
// TestAdmit_IpMatcher test case where only IP matcher is valid.
|
||||
func TestReload_OnlyIPs(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
matchers: []string{"1.1.1.1", "8.8.8.8"},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
err := adm.reload(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, adm.matched("1.1.1.1"))
|
||||
assert.True(t, adm.matched("8.8.8.8"))
|
||||
assert.False(t, adm.matched("9.9.9.9"))
|
||||
}
|
||||
|
||||
// TestReload_OnlyCIDRs tests reload with only CIDR patterns.
|
||||
func TestReload_OnlyCIDRs(t *testing.T) {
|
||||
adm := &localAdmission{
|
||||
options: options{
|
||||
matchers: []string{"192.168.0.0/16", "10.0.0.0/8"},
|
||||
},
|
||||
logger: xlogger.Nop(),
|
||||
}
|
||||
err := adm.reload(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, adm.matched("192.168.1.1"))
|
||||
assert.True(t, adm.matched("10.0.0.1"))
|
||||
assert.False(t, adm.matched("172.16.0.1"))
|
||||
}
|
||||
|
||||
// TestAdmit_LoggerDebugOutput tests that debug logging happens on deny.
|
||||
func TestAdmit_DenyLogs(t *testing.T) {
|
||||
adm := newSyncedAdmission(
|
||||
MatchersOption([]string{"192.168.1.1"}),
|
||||
LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
defer adm.Close()
|
||||
|
||||
// Should not panic; debug log is written
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
// TestAdmit_WhitelistDenyLogs tests logging for whitelist deny.
|
||||
func TestAdmit_WhitelistDenyLogs(t *testing.T) {
|
||||
adm := newSyncedAdmission(
|
||||
WhitelistOption(true),
|
||||
MatchersOption([]string{"192.168.1.1"}),
|
||||
LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
defer adm.Close()
|
||||
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "10.0.0.1"))
|
||||
}
|
||||
|
||||
// TestParsePatterns_WithCommentsAndEmptyLines tests edge cases.
|
||||
func TestParsePatterns_EdgeCases(t *testing.T) {
|
||||
adm := &localAdmission{}
|
||||
r := strings.NewReader("# full comment\n\n # indented comment\n192.168.1.1 # inline")
|
||||
patterns, err := adm.parsePatterns(r)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"192.168.1.1"}, patterns)
|
||||
}
|
||||
|
||||
func TestAdmit_NetworkIgnoredForMatching(t *testing.T) {
|
||||
// Network parameter doesn't affect matching behavior
|
||||
adm := newSyncedAdmission(MatchersOption([]string{"192.168.1.1"}))
|
||||
defer adm.Close()
|
||||
|
||||
assert.False(t, adm.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.False(t, adm.Admit(context.Background(), "udp", "192.168.1.1"))
|
||||
assert.False(t, adm.Admit(context.Background(), "ip4", "192.168.1.1"))
|
||||
}
|
||||
|
||||
// Compile-time interface check
|
||||
var _ logger.Logger = xlogger.Nop()
|
||||
|
||||
// Ensure admission.Admission is implemented
|
||||
var _ admission.Admission = (*localAdmission)(nil)
|
||||
@@ -1,3 +1,9 @@
|
||||
// Package admission implements gRPC-based admission plugins.
|
||||
// An admission plugin delegates the admit/deny decision to an external
|
||||
// service via gRPC. This allows admission logic to be implemented in
|
||||
// a separate process using any language supported by protobuf.
|
||||
//
|
||||
// The gRPC service definition is in plugin/admission/proto.
|
||||
package admission
|
||||
|
||||
import (
|
||||
@@ -11,13 +17,27 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// grpcPlugin is an admission controller that delegates to an external
|
||||
// gRPC admission service.
|
||||
//
|
||||
// If the gRPC connection cannot be established at construction time,
|
||||
// the client field remains nil and Admit returns true (fail-open),
|
||||
// allowing all traffic through rather than blocking everything.
|
||||
type grpcPlugin struct {
|
||||
conn grpc.ClientConnInterface
|
||||
client proto.AdmissionClient
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
// NewGRPCPlugin creates an Admission plugin based on gRPC.
|
||||
// NewGRPCPlugin creates an admission controller that communicates with
|
||||
// an external gRPC admission service at the given address.
|
||||
//
|
||||
// The name is used only for log identification. The addr should be a
|
||||
// gRPC target string (e.g. "127.0.0.1:9000"). Additional plugin options
|
||||
// (TLS, token, retry) can be passed via opts.
|
||||
//
|
||||
// If the connection fails, the plugin logs the error and operates in
|
||||
// fail-open mode: all admission requests return true.
|
||||
func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) admission.Admission {
|
||||
var options plugin.Options
|
||||
for _, opt := range opts {
|
||||
@@ -43,14 +63,28 @@ func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) admission.Ad
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *grpcPlugin) Admit(ctx context.Context, addr string, opts ...admission.Option) bool {
|
||||
// Admit sends an admission request to the external gRPC service.
|
||||
// It passes the network, address, and service name from the client
|
||||
// connection.
|
||||
//
|
||||
// If the gRPC client is nil (connection failed at startup), it returns
|
||||
// true (fail-open). If the RPC returns an error, it logs the error and
|
||||
// returns false.
|
||||
func (p *grpcPlugin) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
if p.client == nil {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
var options admission.Options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
r, err := p.client.Admit(ctx,
|
||||
&proto.AdmissionRequest{
|
||||
Addr: addr,
|
||||
Service: options.Service,
|
||||
Network: network,
|
||||
Addr: addr,
|
||||
})
|
||||
if err != nil {
|
||||
p.log.Error(err)
|
||||
@@ -59,6 +93,7 @@ func (p *grpcPlugin) Admit(ctx context.Context, addr string, opts ...admission.O
|
||||
return r.Ok
|
||||
}
|
||||
|
||||
// Close closes the underlying gRPC connection if it implements io.Closer.
|
||||
func (p *grpcPlugin) Close() error {
|
||||
if closer, ok := p.conn.(io.Closer); ok {
|
||||
return closer.Close()
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/plugin/admission/proto"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
corelogger.SetDefault(xlogger.Nop())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// helper to set up a real gRPC server on a random port, returning the client conn and server stop func.
|
||||
func newTestGRPCConn(t *testing.T, srv proto.AdmissionServer) (*grpc.ClientConn, func()) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
gsrv := grpc.NewServer()
|
||||
proto.RegisterAdmissionServer(gsrv, srv)
|
||||
go gsrv.Serve(lis)
|
||||
|
||||
conn, err := grpc.NewClient(lis.Addr().String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return conn, func() {
|
||||
conn.Close()
|
||||
gsrv.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_FailOpen(t *testing.T) {
|
||||
// Test fail-open: create directly with nil client to ensure nil path is covered.
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Admit_Success(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testAdmissionServer{admit: true})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAdmissionClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Admit_Deny(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testAdmissionServer{admit: false})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAdmissionClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Admit_WithService(t *testing.T) {
|
||||
server := &testAdmissionServer{admit: true}
|
||||
conn, cleanup := newTestGRPCConn(t, server)
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAdmissionClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1", admission.WithService("myservice")))
|
||||
assert.Equal(t, "myservice", server.lastService)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Admit_NilClient(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
}
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_NilConn(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: &nonCloserConn{},
|
||||
client: nil,
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_WithRealConn(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testAdmissionServer{admit: true})
|
||||
defer cleanup()
|
||||
|
||||
// conn is *grpc.ClientConn which implements io.Closer
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: nil,
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestNewGRPCPlugin_RealConn(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
gsrv := grpc.NewServer()
|
||||
proto.RegisterAdmissionServer(gsrv, &testAdmissionServer{admit: true})
|
||||
go gsrv.Serve(lis)
|
||||
defer gsrv.Stop()
|
||||
|
||||
// Call the real NewGRPCPlugin function.
|
||||
p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500))
|
||||
require.NotNil(t, p)
|
||||
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.NoError(t, p.(io.Closer).Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Admit_ServerError(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &errorAdmissionServer{})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAdmissionClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
// Server returns an error; Admit should return false.
|
||||
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
type testAdmissionServer struct {
|
||||
proto.UnimplementedAdmissionServer
|
||||
admit bool
|
||||
lastService string
|
||||
}
|
||||
|
||||
func (s *testAdmissionServer) Admit(ctx context.Context, req *proto.AdmissionRequest) (*proto.AdmissionReply, error) {
|
||||
s.lastService = req.Service
|
||||
return &proto.AdmissionReply{Ok: s.admit}, nil
|
||||
}
|
||||
|
||||
type errorAdmissionServer struct {
|
||||
proto.UnimplementedAdmissionServer
|
||||
}
|
||||
|
||||
func (s *errorAdmissionServer) Admit(ctx context.Context, req *proto.AdmissionRequest) (*proto.AdmissionReply, error) {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
type nonCloserConn struct{}
|
||||
|
||||
func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ grpc.ClientConnInterface = (*nonCloserConn)(nil)
|
||||
var _ io.Closer = (*grpcPlugin)(nil)
|
||||
@@ -11,14 +11,24 @@ import (
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
)
|
||||
|
||||
// httpPluginRequest is the JSON payload sent to the HTTP admission service.
|
||||
type httpPluginRequest struct {
|
||||
Addr string `json:"addr"`
|
||||
Service string `json:"service"`
|
||||
Network string `json:"network"`
|
||||
Addr string `json:"addr"`
|
||||
}
|
||||
|
||||
// httpPluginResponse is the JSON response expected from the HTTP
|
||||
// admission service. OK indicates whether the address is admitted.
|
||||
type httpPluginResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// httpPlugin is an admission controller that delegates to an external
|
||||
// HTTP admission service via JSON POST requests.
|
||||
//
|
||||
// The HTTP client is created via plugin.NewHTTPClient, which configures
|
||||
// timeouts, TLS, and token-based authentication from plugin options.
|
||||
type httpPlugin struct {
|
||||
url string
|
||||
client *http.Client
|
||||
@@ -26,7 +36,14 @@ type httpPlugin struct {
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
// NewHTTPPlugin creates an Admission plugin based on HTTP.
|
||||
// NewHTTPPlugin creates an admission controller that communicates with
|
||||
// an external HTTP admission service at the given URL.
|
||||
//
|
||||
// The name is used only for log identification. On each Admit call,
|
||||
// a JSON-encoded httpPluginRequest is POSTed to the URL. A 200 response
|
||||
// with {"ok": true} admits the address; any other response denies it.
|
||||
//
|
||||
// If the HTTP client is nil, all admission requests return false.
|
||||
func NewHTTPPlugin(name string, url string, opts ...plugin.Option) admission.Admission {
|
||||
var options plugin.Options
|
||||
for _, opt := range opts {
|
||||
@@ -44,13 +61,30 @@ func NewHTTPPlugin(name string, url string, opts ...plugin.Option) admission.Adm
|
||||
}
|
||||
}
|
||||
|
||||
func (p *httpPlugin) Admit(ctx context.Context, addr string, opts ...admission.Option) (ok bool) {
|
||||
// Admit sends a JSON POST request to the configured HTTP endpoint.
|
||||
// The request includes the service name, network, and client address.
|
||||
//
|
||||
// The address is admitted only if ALL of the following are true:
|
||||
// - The HTTP client is non-nil
|
||||
// - The request is successfully sent
|
||||
// - The response has status 200 OK
|
||||
// - The response body decodes as JSON with "ok": true
|
||||
//
|
||||
// Any error, non-200 status, or decode failure results in denial.
|
||||
func (p *httpPlugin) Admit(ctx context.Context, network, addr string, opts ...admission.Option) (ok bool) {
|
||||
if p.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var options admission.Options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
rb := httpPluginRequest{
|
||||
Addr: addr,
|
||||
Service: options.Service,
|
||||
Network: network,
|
||||
Addr: addr,
|
||||
}
|
||||
v, err := json.Marshal(&rb)
|
||||
if err != nil {
|
||||
@@ -82,3 +116,14 @@ func (p *httpPlugin) Admit(ctx context.Context, addr string, opts ...admission.O
|
||||
}
|
||||
return res.OK
|
||||
}
|
||||
|
||||
// Close closes idle connections in the HTTP client's connection pool.
|
||||
// It does not shut down the client itself.
|
||||
func (p *httpPlugin) Close() error {
|
||||
if p.client != nil {
|
||||
if tr := plugin.HTTPClientTransport(p.client); tr != nil {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPPlugin(t *testing.T) {
|
||||
p := NewHTTPPlugin("test", "http://localhost:9999")
|
||||
require.NotNil(t, p)
|
||||
|
||||
hp, ok := p.(*httpPlugin)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "http://localhost:9999", hp.url)
|
||||
assert.NotNil(t, hp.client)
|
||||
assert.NotNil(t, hp.log)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_Deny(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_Non200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_InvalidJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`not json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_ConnectionRefused(t *testing.T) {
|
||||
p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0))
|
||||
assert.False(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_NilClient(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
url: "http://localhost",
|
||||
client: nil,
|
||||
}
|
||||
assert.False(t, hp.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_WithServiceAndHeader(t *testing.T) {
|
||||
var receivedService string
|
||||
var receivedHeader string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedService = r.URL.Query().Get("check_service")
|
||||
receivedHeader = r.Header.Get("X-Custom")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{
|
||||
"X-Custom": []string{"test-value"},
|
||||
}))
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1", admission.WithService("myservice")))
|
||||
_ = receivedService
|
||||
_ = receivedHeader
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_WithNoHeader(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Create plugin without custom headers
|
||||
hp := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
header: nil,
|
||||
}
|
||||
assert.True(t, hp.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Close_NilClient(t *testing.T) {
|
||||
hp := &httpPlugin{client: nil}
|
||||
err := hp.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Close_WithClient(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
err := hp.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_WithAllOptions(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer mytoken", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Test with various plugin options
|
||||
p := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
header: http.Header{"Authorization": []string{"Bearer mytoken"}},
|
||||
}
|
||||
assert.True(t, p.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Admit_BadURL(t *testing.T) {
|
||||
// A URL that causes http.NewRequestWithContext to fail.
|
||||
hp := &httpPlugin{
|
||||
url: "http://invalid hostname", // space causes NewRequest to fail
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
assert.False(t, hp.Admit(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
var _ io.Closer = (*httpPlugin)(nil)
|
||||
+116
-17
@@ -8,7 +8,8 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/x/ctx"
|
||||
xio "github.com/go-gost/x/internal/io"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/net/udp"
|
||||
)
|
||||
@@ -17,11 +18,22 @@ var (
|
||||
errUnsupport = errors.New("unsupported operation")
|
||||
)
|
||||
|
||||
// serverConn wraps a TCP connection (net.Conn) with per-read admission
|
||||
// checking. Unlike the listener wrapper — which rejects at accept time —
|
||||
// this wrapper re-validates the remote address on each Read call.
|
||||
// This is useful when the admission rules may change during the lifetime
|
||||
// of a connection, or when the connection was established before
|
||||
// admission wrappers were applied.
|
||||
type serverConn struct {
|
||||
net.Conn
|
||||
admission admission.Admission
|
||||
}
|
||||
|
||||
// WrapConn wraps a net.Conn with per-read admission control.
|
||||
// If admission is nil, the original connection is returned unchanged.
|
||||
//
|
||||
// When admission is denied on a Read, io.EOF is returned so the caller
|
||||
// sees a clean end-of-stream rather than a hard error.
|
||||
func WrapConn(admission admission.Admission, c net.Conn) net.Conn {
|
||||
if admission == nil {
|
||||
return c
|
||||
@@ -32,15 +44,27 @@ func WrapConn(admission admission.Admission, c net.Conn) net.Conn {
|
||||
}
|
||||
}
|
||||
|
||||
// UnwrapConn returns the underlying connection, allowing type assertions
|
||||
// through wrapper layers.
|
||||
func (c *serverConn) UnwrapConn() net.Conn {
|
||||
return c.Conn
|
||||
}
|
||||
|
||||
// Read checks the remote address against the admission controller
|
||||
// before delegating to the underlying connection. If denied, it returns
|
||||
// io.EOF to signal end-of-stream.
|
||||
func (c *serverConn) Read(b []byte) (n int, err error) {
|
||||
if c.admission != nil &&
|
||||
!c.admission.Admit(context.Background(), c.RemoteAddr().String()) {
|
||||
!c.admission.Admit(context.Background(), c.RemoteAddr().Network(), c.RemoteAddr().String()) {
|
||||
err = io.EOF
|
||||
return
|
||||
}
|
||||
return c.Conn.Read(b)
|
||||
}
|
||||
|
||||
// SyscallConn exposes the underlying raw connection for socket-level
|
||||
// operations (e.g. setting SO_MARK, SO_REUSEPORT). Returns
|
||||
// errUnsupport if the inner connection does not support this.
|
||||
func (c *serverConn) SyscallConn() (rc syscall.RawConn, err error) {
|
||||
if sc, ok := c.Conn.(syscall.Conn); ok {
|
||||
rc, err = sc.SyscallConn()
|
||||
@@ -50,18 +74,44 @@ func (c *serverConn) SyscallConn() (rc syscall.RawConn, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (c *serverConn) Metadata() metadata.Metadata {
|
||||
if md, ok := c.Conn.(metadata.Metadatable); ok {
|
||||
return md.Metadata()
|
||||
// CloseRead shuts down the read side of the connection if supported.
|
||||
func (c *serverConn) CloseRead() error {
|
||||
if sc, ok := c.Conn.(xio.CloseRead); ok {
|
||||
return sc.CloseRead()
|
||||
}
|
||||
return xio.ErrUnsupported
|
||||
}
|
||||
|
||||
// CloseWrite shuts down the write side of the connection if supported.
|
||||
func (c *serverConn) CloseWrite() error {
|
||||
if sc, ok := c.Conn.(xio.CloseWrite); ok {
|
||||
return sc.CloseWrite()
|
||||
}
|
||||
return xio.ErrUnsupported
|
||||
}
|
||||
|
||||
// Context returns the context carried by the underlying connection,
|
||||
// or nil if the connection does not carry context.
|
||||
func (c *serverConn) Context() context.Context {
|
||||
if innerCtx, ok := c.Conn.(ctx.Context); ok {
|
||||
return innerCtx.Context()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packetConn wraps a net.PacketConn with per-packet admission checking.
|
||||
// Packets from denied addresses are silently dropped and the read
|
||||
// loop continues to the next packet.
|
||||
type packetConn struct {
|
||||
net.PacketConn
|
||||
admission admission.Admission
|
||||
}
|
||||
|
||||
// WrapPacketConn wraps a net.PacketConn with per-packet admission control.
|
||||
// If admission is nil, the original connection is returned unchanged.
|
||||
//
|
||||
// Denied packets are silently discarded; ReadFrom loops until it receives
|
||||
// a packet from an allowed address or encounters an error.
|
||||
func WrapPacketConn(admission admission.Admission, pc net.PacketConn) net.PacketConn {
|
||||
if admission == nil {
|
||||
return pc
|
||||
@@ -72,6 +122,8 @@ func WrapPacketConn(admission admission.Admission, pc net.PacketConn) net.Packet
|
||||
}
|
||||
}
|
||||
|
||||
// ReadFrom reads the next packet from an admitted address. Packets from
|
||||
// denied addresses are discarded and the method retries automatically.
|
||||
func (c *packetConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
|
||||
for {
|
||||
n, addr, err = c.PacketConn.ReadFrom(p)
|
||||
@@ -80,7 +132,7 @@ func (c *packetConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
|
||||
}
|
||||
|
||||
if c.admission != nil &&
|
||||
!c.admission.Admit(context.Background(), addr.String()) {
|
||||
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -88,25 +140,49 @@ func (c *packetConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *packetConn) Metadata() metadata.Metadata {
|
||||
if md, ok := c.PacketConn.(metadata.Metadatable); ok {
|
||||
return md.Metadata()
|
||||
// Context returns the context carried by the underlying packet connection.
|
||||
func (c *packetConn) Context() context.Context {
|
||||
if innerCtx, ok := c.PacketConn.(ctx.Context); ok {
|
||||
return innerCtx.Context()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// udpConn wraps a packet connection as a UDP-specific connection.
|
||||
// It provides the same admission filtering as packetConn, plus UDP-
|
||||
// specific operations (ReadFromUDP, ReadMsgUDP, WriteToUDP, WriteMsgUDP,
|
||||
// SetDSCP, etc.).
|
||||
//
|
||||
// The wrapper delegates to optional interfaces on the underlying
|
||||
// connection, returning errUnsupport if the capability is not available.
|
||||
type udpConn struct {
|
||||
net.PacketConn
|
||||
admission admission.Admission
|
||||
}
|
||||
|
||||
func WrapUDPConn(admission admission.Admission, pc net.PacketConn) udp.Conn {
|
||||
// WrapUDPConn wraps a net.PacketConn as a udp.Conn with admission control.
|
||||
// This is used in UDP forwarding paths where the connection is treated
|
||||
// as a connected UDP socket.
|
||||
// If pc is nil, nil is returned. If admission is nil, the original connection
|
||||
// is returned unchanged (no-op).
|
||||
func WrapUDPConn(adm admission.Admission, pc net.PacketConn) udp.Conn {
|
||||
if pc == nil {
|
||||
return nil
|
||||
}
|
||||
if adm == nil {
|
||||
if uc, ok := pc.(udp.Conn); ok {
|
||||
return uc
|
||||
}
|
||||
}
|
||||
return &udpConn{
|
||||
PacketConn: pc,
|
||||
admission: admission,
|
||||
admission: adm,
|
||||
}
|
||||
}
|
||||
|
||||
// RemoteAddr returns the remote address from the underlying connection
|
||||
// if it implements the RemoteAddr interface. This is used for "connected"
|
||||
// UDP sockets that have a fixed peer.
|
||||
func (c *udpConn) RemoteAddr() net.Addr {
|
||||
if nc, ok := c.PacketConn.(xnet.RemoteAddr); ok {
|
||||
return nc.RemoteAddr()
|
||||
@@ -114,6 +190,7 @@ func (c *udpConn) RemoteAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReadBuffer sets the socket receive buffer size.
|
||||
func (c *udpConn) SetReadBuffer(n int) error {
|
||||
if nc, ok := c.PacketConn.(xnet.SetBuffer); ok {
|
||||
return nc.SetReadBuffer(n)
|
||||
@@ -121,6 +198,7 @@ func (c *udpConn) SetReadBuffer(n int) error {
|
||||
return errUnsupport
|
||||
}
|
||||
|
||||
// SetWriteBuffer sets the socket send buffer size.
|
||||
func (c *udpConn) SetWriteBuffer(n int) error {
|
||||
if nc, ok := c.PacketConn.(xnet.SetBuffer); ok {
|
||||
return nc.SetWriteBuffer(n)
|
||||
@@ -128,6 +206,8 @@ func (c *udpConn) SetWriteBuffer(n int) error {
|
||||
return errUnsupport
|
||||
}
|
||||
|
||||
// Read delegates to the underlying connection's Read method if available
|
||||
// (for connected UDP sockets that support stream-like reads).
|
||||
func (c *udpConn) Read(b []byte) (n int, err error) {
|
||||
if nc, ok := c.PacketConn.(io.Reader); ok {
|
||||
n, err = nc.Read(b)
|
||||
@@ -137,6 +217,8 @@ func (c *udpConn) Read(b []byte) (n int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// ReadFrom reads the next packet from an admitted address, discarding
|
||||
// packets from denied addresses.
|
||||
func (c *udpConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
|
||||
for {
|
||||
n, addr, err = c.PacketConn.ReadFrom(p)
|
||||
@@ -144,13 +226,15 @@ func (c *udpConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
|
||||
return
|
||||
}
|
||||
if c.admission != nil &&
|
||||
!c.admission.Admit(context.Background(), addr.String()) {
|
||||
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ReadFromUDP reads the next UDP packet from an admitted address.
|
||||
// Packets from denied addresses are discarded transparently.
|
||||
func (c *udpConn) ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) {
|
||||
if nc, ok := c.PacketConn.(udp.ReadUDP); ok {
|
||||
for {
|
||||
@@ -159,7 +243,7 @@ func (c *udpConn) ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) {
|
||||
return
|
||||
}
|
||||
if c.admission != nil &&
|
||||
!c.admission.Admit(context.Background(), addr.String()) {
|
||||
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
@@ -169,6 +253,8 @@ func (c *udpConn) ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// ReadMsgUDP reads a UDP packet with out-of-band data from an admitted
|
||||
// address. Packets from denied addresses are silently skipped.
|
||||
func (c *udpConn) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAddr, err error) {
|
||||
if nc, ok := c.PacketConn.(udp.ReadUDP); ok {
|
||||
for {
|
||||
@@ -177,7 +263,7 @@ func (c *udpConn) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAd
|
||||
return
|
||||
}
|
||||
if c.admission != nil &&
|
||||
!c.admission.Admit(context.Background(), addr.String()) {
|
||||
!c.admission.Admit(context.Background(), addr.Network(), addr.String()) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
@@ -187,6 +273,8 @@ func (c *udpConn) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAd
|
||||
return
|
||||
}
|
||||
|
||||
// Write delegates to the underlying connection's Write method if available
|
||||
// (for connected UDP sockets).
|
||||
func (c *udpConn) Write(b []byte) (n int, err error) {
|
||||
if nc, ok := c.PacketConn.(io.Writer); ok {
|
||||
n, err = nc.Write(b)
|
||||
@@ -196,11 +284,15 @@ func (c *udpConn) Write(b []byte) (n int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// WriteTo sends a packet to the specified address via the underlying
|
||||
// packet connection. No admission check is performed on writes.
|
||||
func (c *udpConn) WriteTo(p []byte, addr net.Addr) (n int, err error) {
|
||||
n, err = c.PacketConn.WriteTo(p, addr)
|
||||
return
|
||||
}
|
||||
|
||||
// WriteToUDP sends a UDP packet to the specified address if the
|
||||
// underlying connection supports UDP-specific writes.
|
||||
func (c *udpConn) WriteToUDP(b []byte, addr *net.UDPAddr) (n int, err error) {
|
||||
if nc, ok := c.PacketConn.(udp.WriteUDP); ok {
|
||||
n, err = nc.WriteToUDP(b, addr)
|
||||
@@ -210,6 +302,7 @@ func (c *udpConn) WriteToUDP(b []byte, addr *net.UDPAddr) (n int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// WriteMsgUDP sends a UDP packet with out-of-band data if supported.
|
||||
func (c *udpConn) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (n, oobn int, err error) {
|
||||
if nc, ok := c.PacketConn.(udp.WriteUDP); ok {
|
||||
n, oobn, err = nc.WriteMsgUDP(b, oob, addr)
|
||||
@@ -219,6 +312,8 @@ func (c *udpConn) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (n, oobn int, er
|
||||
return
|
||||
}
|
||||
|
||||
// SyscallConn exposes the underlying raw connection for socket-level
|
||||
// operations if supported.
|
||||
func (c *udpConn) SyscallConn() (rc syscall.RawConn, err error) {
|
||||
if nc, ok := c.PacketConn.(xnet.SyscallConn); ok {
|
||||
return nc.SyscallConn()
|
||||
@@ -227,6 +322,9 @@ func (c *udpConn) SyscallConn() (rc syscall.RawConn, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// SetDSCP sets the Differentiated Services Code Point on the socket
|
||||
// for traffic classification/QoS. Silently succeeds if not supported
|
||||
// (best-effort).
|
||||
func (c *udpConn) SetDSCP(n int) error {
|
||||
if nc, ok := c.PacketConn.(xnet.SetDSCP); ok {
|
||||
return nc.SetDSCP(n)
|
||||
@@ -234,9 +332,10 @@ func (c *udpConn) SetDSCP(n int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *udpConn) Metadata() metadata.Metadata {
|
||||
if md, ok := c.PacketConn.(metadata.Metadatable); ok {
|
||||
return md.Metadata()
|
||||
// Context returns the context carried by the underlying packet connection.
|
||||
func (c *udpConn) Context() context.Context {
|
||||
if innerCtx, ok := c.PacketConn.(ctx.Context); ok {
|
||||
return innerCtx.Context()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,14 @@
|
||||
// Package wrapper provides net.Listener and net.Conn wrappers that
|
||||
// enforce admission control at the network level.
|
||||
//
|
||||
// The listener wrapper checks each accepted connection against the
|
||||
// admission controller before returning it to the caller. Denied
|
||||
// connections are closed immediately and the listener continues
|
||||
// accepting (transparent rejection).
|
||||
//
|
||||
// The connection wrappers (TCP, UDP, generic packet) check admission
|
||||
// on each read operation, effectively dropping traffic from denied
|
||||
// addresses without closing the underlying transport.
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
@@ -6,32 +17,70 @@ import (
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/logger"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
)
|
||||
|
||||
// listener wraps a net.Listener and performs admission checks on each
|
||||
// accepted connection. Connections from denied addresses are silently
|
||||
// closed and the accept loop continues.
|
||||
type listener struct {
|
||||
service string
|
||||
net.Listener
|
||||
admission admission.Admission
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func WrapListener(admission admission.Admission, ln net.Listener) net.Listener {
|
||||
// WrapListener wraps a net.Listener with admission control. If admission
|
||||
// is nil, the original listener is returned unchanged (no-op).
|
||||
//
|
||||
// The service name is passed to the admission controller via
|
||||
// admission.WithService so that the controller can distinguish
|
||||
// between different services sharing the same admission rules.
|
||||
func WrapListener(service string, admission admission.Admission, ln net.Listener) net.Listener {
|
||||
if admission == nil {
|
||||
return ln
|
||||
}
|
||||
return &listener{
|
||||
service: service,
|
||||
Listener: ln,
|
||||
admission: admission,
|
||||
}
|
||||
}
|
||||
|
||||
// Accept blocks until a connection is accepted from the underlying
|
||||
// listener, then checks it against the admission controller. Denied
|
||||
// connections are closed and the loop retries. The caller never sees
|
||||
// a rejected connection.
|
||||
//
|
||||
// The client address used for the admission check is determined by:
|
||||
// 1. The srcAddr value from the connection's context (if available),
|
||||
// which is the original client address before any proxy protocol
|
||||
// or NAT rewriting.
|
||||
// 2. Falling back to the raw RemoteAddr of the accepted connection.
|
||||
func (ln *listener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
c, err := ln.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract the context from the connection if it carries one.
|
||||
ctx := context.Background()
|
||||
if cc, ok := c.(xctx.Context); ok {
|
||||
if cv := cc.Context(); cv != nil {
|
||||
ctx = cv
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the original source address from context (e.g. set by
|
||||
// proxy protocol handling), falling back to the raw remote address.
|
||||
clientAddr := c.RemoteAddr()
|
||||
if addr := xctx.SrcAddrFromContext(ctx); addr != nil {
|
||||
clientAddr = addr
|
||||
}
|
||||
|
||||
if ln.admission != nil &&
|
||||
!ln.admission.Admit(context.Background(), c.RemoteAddr().String()) {
|
||||
!ln.admission.Admit(ctx, clientAddr.Network(), clientAddr.String(), admission.WithService(ln.service)) {
|
||||
c.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- WrapListener tests ---
|
||||
|
||||
func TestWrapListener_NilAdmission(t *testing.T) {
|
||||
ln := &fakeListener{}
|
||||
result := WrapListener("myservice", nil, ln)
|
||||
assert.Equal(t, ln, result) // should return the original listener
|
||||
}
|
||||
|
||||
func TestWrapListener_WithAdmission(t *testing.T) {
|
||||
ln := &fakeListener{}
|
||||
result := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
|
||||
assert.IsType(t, &listener{}, result)
|
||||
}
|
||||
|
||||
// --- listener.Accept tests ---
|
||||
|
||||
func TestListener_Accept_Admit(t *testing.T) {
|
||||
c := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}}
|
||||
ln := &fakeListener{conns: []net.Conn{c}}
|
||||
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
|
||||
|
||||
conn, err := wrapped.Accept()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
assert.False(t, c.closed)
|
||||
}
|
||||
|
||||
func TestListener_Accept_Deny(t *testing.T) {
|
||||
c := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}}
|
||||
denied := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5678}}
|
||||
ln := &fakeListener{conns: []net.Conn{denied, c}}
|
||||
wrapped := WrapListener("myservice", &denyFirstConnAdmission{}, ln)
|
||||
|
||||
// First connection is denied, second is admitted
|
||||
conn, err := wrapped.Accept()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c, conn)
|
||||
assert.True(t, denied.closed)
|
||||
}
|
||||
|
||||
func TestListener_Accept_Error(t *testing.T) {
|
||||
ln := &fakeListener{acceptErr: errors.New("accept failed")}
|
||||
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
|
||||
|
||||
_, err := wrapped.Accept()
|
||||
assert.EqualError(t, err, "accept failed")
|
||||
}
|
||||
|
||||
func TestListener_Accept_WithContextSrcAddr(t *testing.T) {
|
||||
srcAddr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}
|
||||
c := &fakeConn{
|
||||
raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234},
|
||||
ctx: xctx.ContextWithSrcAddr(context.Background(), srcAddr),
|
||||
}
|
||||
ln := &fakeListener{conns: []net.Conn{c}}
|
||||
|
||||
// Use an admission that records the address it receives
|
||||
recorder := &recordingAdmission{admit: true}
|
||||
wrapped := WrapListener("myservice", recorder, ln)
|
||||
|
||||
conn, err := wrapped.Accept()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
// Should have used the src addr from context (String() includes port)
|
||||
assert.Equal(t, "1.2.3.4:1234", recorder.lastAddr)
|
||||
}
|
||||
|
||||
func TestListener_Accept_WithContextNilContext(t *testing.T) {
|
||||
c := &fakeConn{
|
||||
raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234},
|
||||
ctx: nil, // returns nil context
|
||||
}
|
||||
ln := &fakeListener{conns: []net.Conn{c}}
|
||||
|
||||
recorder := &recordingAdmission{admit: true}
|
||||
wrapped := WrapListener("myservice", recorder, ln)
|
||||
|
||||
conn, err := wrapped.Accept()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
// Should fall back to RemoteAddr (String() includes port)
|
||||
assert.Equal(t, "192.168.1.1:1234", recorder.lastAddr)
|
||||
}
|
||||
|
||||
func TestListener_Accept_NoContextInterface(t *testing.T) {
|
||||
// A plain net.Conn without xctx.Context support
|
||||
c := &plainNetConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 9999}}
|
||||
ln := &fakeListener{conns: []net.Conn{c}}
|
||||
|
||||
recorder := &recordingAdmission{admit: true}
|
||||
wrapped := WrapListener("myservice", recorder, ln)
|
||||
|
||||
conn, err := wrapped.Accept()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
// Should use RemoteAddr (String() includes port)
|
||||
assert.Equal(t, "10.0.0.1:9999", recorder.lastAddr)
|
||||
}
|
||||
|
||||
func TestListener_Accept_MultipleDeniesThenAdmit(t *testing.T) {
|
||||
denied1 := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1}}
|
||||
denied2 := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.2"), Port: 2}}
|
||||
admitted := &fakeConn{raddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 3}}
|
||||
ln := &fakeListener{conns: []net.Conn{denied1, denied2, admitted}}
|
||||
|
||||
// Deny the first two, admit the third
|
||||
count := 0
|
||||
conditional := &conditionalAdmission{
|
||||
fn: func(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
count++
|
||||
return count > 2
|
||||
},
|
||||
}
|
||||
wrapped := WrapListener("myservice", conditional, ln)
|
||||
|
||||
conn, err := wrapped.Accept()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, admitted, conn)
|
||||
assert.True(t, denied1.closed)
|
||||
assert.True(t, denied2.closed)
|
||||
assert.False(t, admitted.closed)
|
||||
}
|
||||
|
||||
// Test that Close is forwarded to the underlying listener
|
||||
func TestListener_Close(t *testing.T) {
|
||||
ln := &fakeListener{}
|
||||
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
|
||||
err := wrapped.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ln.closed)
|
||||
}
|
||||
|
||||
// Test that Addr is forwarded to the underlying listener
|
||||
func TestListener_Addr(t *testing.T) {
|
||||
ln := &fakeListener{}
|
||||
wrapped := WrapListener("myservice", alwaysAdmitAdmission{}, ln)
|
||||
addr := wrapped.Addr()
|
||||
assert.NotNil(t, addr)
|
||||
}
|
||||
|
||||
// --- fake types for listener tests ---
|
||||
|
||||
type fakeListener struct {
|
||||
conns []net.Conn
|
||||
pos int
|
||||
acceptErr error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ln *fakeListener) Accept() (net.Conn, error) {
|
||||
if ln.acceptErr != nil {
|
||||
return nil, ln.acceptErr
|
||||
}
|
||||
if ln.pos >= len(ln.conns) {
|
||||
return nil, errors.New("no more connections")
|
||||
}
|
||||
c := ln.conns[ln.pos]
|
||||
ln.pos++
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (ln *fakeListener) Close() error {
|
||||
ln.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ln *fakeListener) Addr() net.Addr {
|
||||
return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8080}
|
||||
}
|
||||
|
||||
// --- admission helpers for listener tests ---
|
||||
|
||||
type recordingAdmission struct {
|
||||
admit bool
|
||||
lastAddr string
|
||||
}
|
||||
|
||||
func (r *recordingAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
r.lastAddr = addr
|
||||
return r.admit
|
||||
}
|
||||
|
||||
type denyFirstConnAdmission struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (d *denyFirstConnAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
d.count++
|
||||
return d.count > 1 // deny first, admit rest
|
||||
}
|
||||
|
||||
type conditionalAdmission struct {
|
||||
fn func(ctx context.Context, network, addr string, opts ...admission.Option) bool
|
||||
}
|
||||
|
||||
func (c *conditionalAdmission) Admit(ctx context.Context, network, addr string, opts ...admission.Option) bool {
|
||||
return c.fn(ctx, network, addr, opts...)
|
||||
}
|
||||
|
||||
// Compile-time checks
|
||||
var (
|
||||
_ net.Listener = (*fakeListener)(nil)
|
||||
_ net.Conn = (*fakeConn)(nil)
|
||||
_ xctx.Context = (*fakeConn)(nil)
|
||||
_ net.Conn = (*plainNetConn)(nil)
|
||||
)
|
||||
|
||||
// Test that plainNetConn does NOT implement xctx.Context
|
||||
func TestPlainNetConn_NoContext(t *testing.T) {
|
||||
c := &plainNetConn{raddr: &net.TCPAddr{}}
|
||||
_, ok := any(c).(xctx.Context)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// Test that fakeConn DOES implement xctx.Context
|
||||
func TestFakeConn_HasContext(t *testing.T) {
|
||||
c := &fakeConn{raddr: &net.TCPAddr{}}
|
||||
_, ok := any(c).(xctx.Context)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
+50
-2
@@ -1,3 +1,5 @@
|
||||
// swagger generate spec -o swagger.yaml
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -15,8 +17,9 @@ var (
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -58,67 +61,112 @@ func Register(r *gin.Engine, opts *Options) {
|
||||
|
||||
config.POST("/reload", reloadConfig)
|
||||
|
||||
config.GET("/services", getServiceList)
|
||||
config.GET("/services/:service", getService)
|
||||
config.POST("/services", createService)
|
||||
config.PUT("/services/:service", updateService)
|
||||
config.DELETE("/services/:service", deleteService)
|
||||
|
||||
config.GET("/chains", getChainList)
|
||||
config.GET("/chains/:chain", getChain)
|
||||
config.POST("/chains", createChain)
|
||||
config.PUT("/chains/:chain", updateChain)
|
||||
config.DELETE("/chains/:chain", deleteChain)
|
||||
|
||||
config.GET("/hops", getHopList)
|
||||
config.GET("/hops/:hop", getHop)
|
||||
config.POST("/hops", createHop)
|
||||
config.PUT("/hops/:hop", updateHop)
|
||||
config.DELETE("/hops/:hop", deleteHop)
|
||||
|
||||
config.GET("/authers", getAutherList)
|
||||
config.GET("/authers/:auther", getAuther)
|
||||
config.POST("/authers", createAuther)
|
||||
config.PUT("/authers/:auther", updateAuther)
|
||||
config.DELETE("/authers/:auther", deleteAuther)
|
||||
|
||||
config.GET("/admissions", getAdmissionList)
|
||||
config.GET("/admissions/:admission", getAdmission)
|
||||
config.POST("/admissions", createAdmission)
|
||||
config.PUT("/admissions/:admission", updateAdmission)
|
||||
config.DELETE("/admissions/:admission", deleteAdmission)
|
||||
|
||||
config.GET("/bypasses", getBypassList)
|
||||
config.GET("/bypasses/:bypass", getBypass)
|
||||
config.POST("/bypasses", createBypass)
|
||||
config.PUT("/bypasses/:bypass", updateBypass)
|
||||
config.DELETE("/bypasses/:bypass", deleteBypass)
|
||||
|
||||
config.GET("/resolvers", getResolverList)
|
||||
config.GET("/resolvers/:resolver", getResolver)
|
||||
config.POST("/resolvers", createResolver)
|
||||
config.PUT("/resolvers/:resolver", updateResolver)
|
||||
config.DELETE("/resolvers/:resolver", deleteResolver)
|
||||
|
||||
config.GET("/hosts", getHostsList)
|
||||
config.GET("/hosts/:hosts", getHosts)
|
||||
config.POST("/hosts", createHosts)
|
||||
config.PUT("/hosts/:hosts", updateHosts)
|
||||
config.DELETE("/hosts/:hosts", deleteHosts)
|
||||
|
||||
config.GET("/ingresses", getIngressList)
|
||||
config.GET("/ingresses/:ingress", getIngress)
|
||||
config.POST("/ingresses", createIngress)
|
||||
config.PUT("/ingresses/:ingress", updateIngress)
|
||||
config.DELETE("/ingresses/:ingress", deleteIngress)
|
||||
|
||||
config.GET("/routers", getRouterList)
|
||||
config.GET("/routers/:router", getRouter)
|
||||
config.POST("/routers", createRouter)
|
||||
config.PUT("/routers/:router", updateRouter)
|
||||
config.DELETE("/routers/:router", deleteRouter)
|
||||
|
||||
config.GET("/observers", getObserverList)
|
||||
config.GET("/observers/:observer", getObserver)
|
||||
config.POST("/observers", createObserver)
|
||||
config.PUT("/observers/:observer", updateObserver)
|
||||
config.DELETE("/observers/:observer", deleteObserver)
|
||||
|
||||
config.GET("/recorders", getRecorderList)
|
||||
config.GET("/recorders/:recorder", getRecorder)
|
||||
config.POST("/recorders", createRecorder)
|
||||
config.PUT("/recorders/:recorder", updateRecorder)
|
||||
config.DELETE("/recorders/:recorder", deleteRecorder)
|
||||
|
||||
config.GET("/sds", getSDList)
|
||||
config.GET("/sds/:sd", getSD)
|
||||
config.POST("/sds", createSD)
|
||||
config.PUT("/sds/:sd", updateSD)
|
||||
config.DELETE("/sds/:sd", deleteSD)
|
||||
|
||||
config.GET("/limiters", getLimiterList)
|
||||
config.GET("/limiters/:limiter", getLimiter)
|
||||
config.POST("/limiters", createLimiter)
|
||||
config.PUT("/limiters/:limiter", updateLimiter)
|
||||
config.DELETE("/limiters/:limiter", deleteLimiter)
|
||||
|
||||
config.GET("/quotas", getQuotaList)
|
||||
config.GET("/quotas/:quota", getQuota)
|
||||
config.POST("/quotas", createQuota)
|
||||
config.PUT("/quotas/:quota", updateQuota)
|
||||
config.DELETE("/quotas/:quota", deleteQuota)
|
||||
config.POST("/quotas/:quota/reset", resetQuota)
|
||||
|
||||
config.GET("/climiters", getConnLimiterList)
|
||||
config.GET("/climiters/:limiter", getConnLimiter)
|
||||
config.POST("/climiters", createConnLimiter)
|
||||
config.PUT("/climiters/:limiter", updateConnLimiter)
|
||||
config.DELETE("/climiters/:limiter", deleteConnLimiter)
|
||||
|
||||
config.GET("/rlimiters", getRateLimiterList)
|
||||
config.GET("/rlimiters/:limiter", getRateLimiter)
|
||||
config.POST("/rlimiters", createRateLimiter)
|
||||
config.PUT("/rlimiters/:limiter", updateRateLimiter)
|
||||
config.DELETE("/rlimiters/:limiter", deleteRateLimiter)
|
||||
|
||||
config.GET("/caches", getCacheList)
|
||||
config.GET("/caches/:cache", getCache)
|
||||
config.POST("/caches", createCache)
|
||||
config.PUT("/caches/:cache", updateCache)
|
||||
config.DELETE("/caches/:cache", deleteCache)
|
||||
}
|
||||
|
||||
+82
-37
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
@@ -17,6 +19,58 @@ type serviceStatus interface {
|
||||
Status() *service.Status
|
||||
}
|
||||
|
||||
func fillServiceStatus(svc *config.ServiceConfig) {
|
||||
s := registry.ServiceRegistry().Get(svc.Name)
|
||||
ss, ok := s.(serviceStatus)
|
||||
if ok && ss != nil {
|
||||
status := ss.Status()
|
||||
svc.Status = &config.ServiceStatus{
|
||||
CreateTime: status.CreateTime().Unix(),
|
||||
State: string(status.State()),
|
||||
}
|
||||
if st := status.Stats(); st != nil {
|
||||
svc.Status.Stats = &config.ServiceStats{
|
||||
TotalConns: st.Get(stats.KindTotalConns),
|
||||
CurrentConns: st.Get(stats.KindCurrentConns),
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
InputBytes: st.Get(stats.KindInputBytes),
|
||||
OutputBytes: st.Get(stats.KindOutputBytes),
|
||||
}
|
||||
}
|
||||
var stopped bool
|
||||
for _, qname := range svc.Quotas {
|
||||
lim := registry.QuotaLimiterRegistry().Get(qname)
|
||||
if lim == nil {
|
||||
continue
|
||||
}
|
||||
qs := lim.Snapshot()
|
||||
if qs.Blocked {
|
||||
stopped = true
|
||||
}
|
||||
svc.Status.Quotas = append(svc.Status.Quotas, config.ServiceQuota{
|
||||
Name: qname,
|
||||
Used: qs.Used,
|
||||
Limit: qs.Limit,
|
||||
StartsAt: qs.StartsAtUnix,
|
||||
ExpiresAt: qs.ExpiresAtUnix,
|
||||
Active: qs.Active,
|
||||
Expired: qs.Expired,
|
||||
Blocked: qs.Blocked,
|
||||
Direction: qs.Direction,
|
||||
})
|
||||
}
|
||||
svc.Status.StoppedByLimit = stopped
|
||||
for _, ev := range status.Events() {
|
||||
if !ev.Time.IsZero() {
|
||||
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
|
||||
Time: ev.Time.Unix(),
|
||||
Msg: ev.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters getConfigRequest
|
||||
type getConfigRequest struct {
|
||||
// output format, one of yaml|json, default is json.
|
||||
@@ -49,32 +103,7 @@ func getConfig(ctx *gin.Context) {
|
||||
if svc == nil {
|
||||
continue
|
||||
}
|
||||
s := registry.ServiceRegistry().Get(svc.Name)
|
||||
ss, ok := s.(serviceStatus)
|
||||
if ok && ss != nil {
|
||||
status := ss.Status()
|
||||
svc.Status = &config.ServiceStatus{
|
||||
CreateTime: status.CreateTime().Unix(),
|
||||
State: string(status.State()),
|
||||
}
|
||||
if st := status.Stats(); st != nil {
|
||||
svc.Status.Stats = &config.ServiceStats{
|
||||
TotalConns: st.Get(stats.KindTotalConns),
|
||||
CurrentConns: st.Get(stats.KindCurrentConns),
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
InputBytes: st.Get(stats.KindInputBytes),
|
||||
OutputBytes: st.Get(stats.KindOutputBytes),
|
||||
}
|
||||
}
|
||||
for _, ev := range status.Events() {
|
||||
if !ev.Time.IsZero() {
|
||||
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
|
||||
Time: ev.Time.Unix(),
|
||||
Msg: ev.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
fillServiceStatus(svc)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -100,10 +129,11 @@ func getConfig(ctx *gin.Context) {
|
||||
|
||||
// swagger:parameters saveConfigRequest
|
||||
type saveConfigRequest struct {
|
||||
// output format, one of yaml|json, default is yaml.
|
||||
// output format, one of yaml|json, default is derived from the file extension.
|
||||
// in: query
|
||||
Format string `form:"format" json:"format"`
|
||||
// file path, default is gost.yaml|gost.json in current working directory.
|
||||
// file path, default is the loaded config file, or gost.yaml|gost.json in
|
||||
// the current working directory when no config file was loaded.
|
||||
// in: query
|
||||
Path string `form:"path" json:"path"`
|
||||
}
|
||||
@@ -128,16 +158,31 @@ func saveConfig(ctx *gin.Context) {
|
||||
var req saveConfigRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
file := "gost.yaml"
|
||||
switch req.Format {
|
||||
case "json":
|
||||
file = "gost.json"
|
||||
default:
|
||||
req.Format = "yaml"
|
||||
file := req.Path
|
||||
if file == "" {
|
||||
// Default to the config file that was loaded (via -C), falling back to
|
||||
// the current working directory when the config came from elsewhere.
|
||||
file = config.ConfigFile()
|
||||
}
|
||||
|
||||
if req.Path != "" {
|
||||
file = req.Path
|
||||
if file == "" {
|
||||
file = "gost.yaml"
|
||||
if req.Format == "json" {
|
||||
file = "gost.json"
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(req.Format) {
|
||||
case "json":
|
||||
req.Format = "json"
|
||||
case "yaml":
|
||||
req.Format = "yaml"
|
||||
default:
|
||||
// Derive the format from the file extension so a loaded JSON config
|
||||
// is saved back as JSON (ReadFile forces format from extension).
|
||||
if strings.ToLower(filepath.Ext(file)) == ".json" {
|
||||
req.Format = "json"
|
||||
} else {
|
||||
req.Format = "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(file)
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getAdmissionListRequest
|
||||
type getAdmissionListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getAdmissionListResponse
|
||||
type getAdmissionListResponse struct {
|
||||
// in: body
|
||||
Data admissionList
|
||||
}
|
||||
|
||||
type admissionList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.AdmissionConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getAdmissionList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/admissions Admission getAdmissionListRequest
|
||||
//
|
||||
// Get admission list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getAdmissionListResponse
|
||||
|
||||
var req getAdmissionListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Admissions
|
||||
|
||||
var resp getAdmissionListResponse
|
||||
resp.Data = admissionList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getAdmissionRequest
|
||||
type getAdmissionRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Admission string `uri:"admission" json:"admission"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getAdmissionResponse
|
||||
type getAdmissionResponse struct {
|
||||
// in: body
|
||||
Data *config.AdmissionConfig
|
||||
}
|
||||
|
||||
func getAdmission(ctx *gin.Context) {
|
||||
// swagger:route GET /config/admissions/{admission} Admission getAdmissionRequest
|
||||
//
|
||||
// Get admission.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getAdmissionResponse
|
||||
|
||||
var req getAdmissionRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getAdmissionResponse
|
||||
|
||||
for _, admission := range config.Global().Admissions {
|
||||
if admission == nil {
|
||||
continue
|
||||
}
|
||||
if admission.Name == req.Admission {
|
||||
resp.Data = admission
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createAdmissionRequest
|
||||
type createAdmissionRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getAutherListRequest
|
||||
type getAutherListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getAutherListResponse
|
||||
type getAutherListResponse struct {
|
||||
// in: body
|
||||
Data autherList
|
||||
}
|
||||
|
||||
type autherList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.AutherConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getAutherList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/authers Auther getAutherListRequest
|
||||
//
|
||||
// Get auther list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getAutherListResponse
|
||||
|
||||
var req getAutherListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Authers
|
||||
|
||||
var resp getAutherListResponse
|
||||
resp.Data = autherList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getAutherRequest
|
||||
type getAutherRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Auther string `uri:"auther" json:"auther"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getAutherResponse
|
||||
type getAutherResponse struct {
|
||||
// in: body
|
||||
Data *config.AutherConfig
|
||||
}
|
||||
|
||||
func getAuther(ctx *gin.Context) {
|
||||
// swagger:route GET /config/authers/{auther} Auther getAutherRequest
|
||||
//
|
||||
// Get auther.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getAutherResponse
|
||||
|
||||
var req getAutherRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getAutherResponse
|
||||
|
||||
for _, auther := range config.Global().Authers {
|
||||
if auther == nil {
|
||||
continue
|
||||
}
|
||||
if auther.Name == req.Auther {
|
||||
resp.Data = auther
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createAutherRequest
|
||||
type createAutherRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getBypassListRequest
|
||||
type getBypassListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getBypassListResponse
|
||||
type getBypassListResponse struct {
|
||||
// in: body
|
||||
Data bypassList
|
||||
}
|
||||
|
||||
type bypassList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.BypassConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getBypassList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/bypasses Bypass getBypassListRequest
|
||||
//
|
||||
// Get bypass list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getBypassListResponse
|
||||
|
||||
var req getBypassListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Bypasses
|
||||
|
||||
var resp getBypassListResponse
|
||||
resp.Data = bypassList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getBypassRequest
|
||||
type getBypassRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Bypass string `uri:"bypass" json:"bypass"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getBypassResponse
|
||||
type getBypassResponse struct {
|
||||
// in: body
|
||||
Data *config.BypassConfig
|
||||
}
|
||||
|
||||
func getBypass(ctx *gin.Context) {
|
||||
// swagger:route GET /config/bypasses/{bypass} Bypass getBypassRequest
|
||||
//
|
||||
// Get bypass.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getBypassResponse
|
||||
|
||||
var req getBypassRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getBypassResponse
|
||||
|
||||
for _, bypass := range config.Global().Bypasses {
|
||||
if bypass == nil {
|
||||
continue
|
||||
}
|
||||
if bypass.Name == req.Bypass {
|
||||
resp.Data = bypass
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createBypassRequest
|
||||
type createBypassRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/x/config"
|
||||
parser "github.com/go-gost/x/config/parsing/cache"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getCacheListRequest
|
||||
type getCacheListRequest struct{}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getCacheListResponse
|
||||
type getCacheListResponse struct {
|
||||
// in: body
|
||||
Data cacheList
|
||||
}
|
||||
|
||||
type cacheList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.CacheConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getCacheList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/caches Cache getCacheListRequest
|
||||
//
|
||||
// Get cache list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getCacheListResponse
|
||||
|
||||
var req getCacheListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Caches
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: cacheList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getCacheRequest
|
||||
type getCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getCacheResponse
|
||||
type getCacheResponse struct {
|
||||
// in: body
|
||||
Data *config.CacheConfig
|
||||
}
|
||||
|
||||
func getCache(ctx *gin.Context) {
|
||||
// swagger:route GET /config/caches/{cache} Cache getCacheRequest
|
||||
//
|
||||
// Get cache.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getCacheResponse
|
||||
|
||||
var req getCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getCacheResponse
|
||||
for _, c := range config.Global().Caches {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
if c.Name == req.Cache {
|
||||
resp.Data = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createCacheRequest
|
||||
type createCacheRequest struct {
|
||||
// in: body
|
||||
Data config.CacheConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response createCacheResponse
|
||||
type createCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func createCache(ctx *gin.Context) {
|
||||
// swagger:route POST /config/caches Cache createCacheRequest
|
||||
//
|
||||
// Create a new cache, the name of the cache must be unique in cache list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: createCacheResponse
|
||||
|
||||
var req createCacheRequest
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache name is required"))
|
||||
return
|
||||
}
|
||||
req.Data.Name = name
|
||||
|
||||
if registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
v := parser.ParseCache(&req.Data)
|
||||
if v == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := registry.CacheRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
c.Caches = append(c.Caches, &req.Data)
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters updateCacheRequest
|
||||
type updateCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
// in: body
|
||||
Data config.CacheConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response updateCacheResponse
|
||||
type updateCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func updateCache(ctx *gin.Context) {
|
||||
// swagger:route PUT /config/caches/{cache} Cache updateCacheRequest
|
||||
//
|
||||
// Update cache by name, the cache must already exist.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: updateCacheResponse
|
||||
|
||||
var req updateCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Cache)
|
||||
|
||||
if !registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
|
||||
v := parser.ParseCache(&req.Data)
|
||||
if v == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
|
||||
return
|
||||
}
|
||||
|
||||
registry.CacheRegistry().Unregister(name)
|
||||
|
||||
if err := registry.CacheRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
for i := range c.Caches {
|
||||
if c.Caches[i].Name == name {
|
||||
c.Caches[i] = &req.Data
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters deleteCacheRequest
|
||||
type deleteCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response deleteCacheResponse
|
||||
type deleteCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func deleteCache(ctx *gin.Context) {
|
||||
// swagger:route DELETE /config/caches/{cache} Cache deleteCacheRequest
|
||||
//
|
||||
// Delete cache by name.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: deleteCacheResponse
|
||||
|
||||
var req deleteCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
name := strings.TrimSpace(req.Cache)
|
||||
|
||||
if !registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
|
||||
return
|
||||
}
|
||||
registry.CacheRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
caches := c.Caches
|
||||
c.Caches = nil
|
||||
for _, s := range caches {
|
||||
if s.Name == name {
|
||||
continue
|
||||
}
|
||||
c.Caches = append(c.Caches, s)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getChainListRequest
|
||||
type getChainListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getChainListResponse
|
||||
type getChainListResponse struct {
|
||||
// in: body
|
||||
Data chainList
|
||||
}
|
||||
|
||||
type chainList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.ChainConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getChainList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/chains Chain getChainListRequest
|
||||
//
|
||||
// Get chain list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getChainListResponse
|
||||
|
||||
var req getChainListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Chains
|
||||
|
||||
var resp getChainListResponse
|
||||
resp.Data = chainList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getChainRequest
|
||||
type getChainRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Chain string `uri:"chain" json:"chain"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getChainResponse
|
||||
type getChainResponse struct {
|
||||
// in: body
|
||||
Data *config.ChainConfig
|
||||
}
|
||||
|
||||
func getChain(ctx *gin.Context) {
|
||||
// swagger:route GET /config/chains/{chain} Chain getChainRequest
|
||||
//
|
||||
// Get chain.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getChainResponse
|
||||
|
||||
var req getChainRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getChainResponse
|
||||
|
||||
for _, chain := range config.Global().Chains {
|
||||
if chain == nil {
|
||||
continue
|
||||
}
|
||||
if chain.Name == req.Chain {
|
||||
resp.Data = chain
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createChainRequest
|
||||
type createChainRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getConnLimiterListRequest
|
||||
type getConnLimiterListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getConnLimiterListResponse
|
||||
type getConnLimiterListResponse struct {
|
||||
// in: body
|
||||
Data connLimiterList
|
||||
}
|
||||
|
||||
type connLimiterList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.LimiterConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getConnLimiterList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/climiters Limiter getConnLimiterListRequest
|
||||
//
|
||||
// Get conn limiter list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getConnLimiterListResponse
|
||||
|
||||
var req getConnLimiterListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().CLimiters
|
||||
|
||||
var resp getConnLimiterListResponse
|
||||
resp.Data = connLimiterList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getConnLimiterRequest
|
||||
type getConnLimiterRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Limiter string `uri:"limiter" json:"limiter"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getConnLimiterResponse
|
||||
type getConnLimiterResponse struct {
|
||||
// in: body
|
||||
Data *config.LimiterConfig
|
||||
}
|
||||
|
||||
func getConnLimiter(ctx *gin.Context) {
|
||||
// swagger:route GET /config/climiters/{limiter} Limiter getConnLimiterRequest
|
||||
//
|
||||
// Get conn limiter.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getConnLimiterResponse
|
||||
|
||||
var req getConnLimiterRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getConnLimiterResponse
|
||||
|
||||
for _, limiter := range config.Global().CLimiters{
|
||||
if limiter == nil {
|
||||
continue
|
||||
}
|
||||
if limiter.Name == req.Limiter {
|
||||
resp.Data = limiter
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createConnLimiterRequest
|
||||
type createConnLimiterRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -12,6 +12,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getHopListRequest
|
||||
type getHopListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getHopListResponse
|
||||
type getHopListResponse struct {
|
||||
// in: body
|
||||
Data hopList
|
||||
}
|
||||
|
||||
type hopList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.HopConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getHopList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/hops Hop getHopListRequest
|
||||
//
|
||||
// Get hop list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getHopListResponse
|
||||
|
||||
var req getHopListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Hops
|
||||
|
||||
var resp getHopListResponse
|
||||
resp.Data = hopList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getHopRequest
|
||||
type getHopRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Hop string `uri:"hop" json:"hop"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getHopResponse
|
||||
type getHopResponse struct {
|
||||
// in: body
|
||||
Data *config.HopConfig
|
||||
}
|
||||
|
||||
func getHop(ctx *gin.Context) {
|
||||
// swagger:route GET /config/hops/{hop} Hop getHopRequest
|
||||
//
|
||||
// Get hop.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getHopResponse
|
||||
|
||||
var req getHopRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getHopResponse
|
||||
|
||||
for _, hop := range config.Global().Hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
if hop.Name == req.Hop {
|
||||
resp.Data = hop
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createHopRequest
|
||||
type createHopRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getHostsListRequest
|
||||
type getHostsListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getHostsListResponse
|
||||
type getHostsListResponse struct {
|
||||
// in: body
|
||||
Data hostsList
|
||||
}
|
||||
|
||||
type hostsList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.HostsConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getHostsList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/hosts Hosts getHostsListRequest
|
||||
//
|
||||
// Get hosts list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getHostsListResponse
|
||||
|
||||
var req getHostsListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Hosts
|
||||
|
||||
var resp getHostsListResponse
|
||||
resp.Data = hostsList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getHostsRequest
|
||||
type getHostsRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Hosts string `uri:"hosts" json:"hosts"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getHostsResponse
|
||||
type getHostsResponse struct {
|
||||
// in: body
|
||||
Data *config.HostsConfig
|
||||
}
|
||||
|
||||
func getHosts(ctx *gin.Context) {
|
||||
// swagger:route GET /config/hosts/{hosts} Hosts getHostsRequest
|
||||
//
|
||||
// Get hosts.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getHostsResponse
|
||||
|
||||
var req getHostsRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getHostsResponse
|
||||
|
||||
for _, hosts := range config.Global().Hosts {
|
||||
if hosts == nil {
|
||||
continue
|
||||
}
|
||||
if hosts.Name == req.Hosts {
|
||||
resp.Data = hosts
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createHostsRequest
|
||||
type createHostsRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getIngressListRequest
|
||||
type getIngressListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getIngressListResponse
|
||||
type getIngressListResponse struct {
|
||||
// in: body
|
||||
Data ingressList
|
||||
}
|
||||
|
||||
type ingressList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.IngressConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getIngressList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/ingresses Ingress getIngressListRequest
|
||||
//
|
||||
// Get ingress list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getIngressListResponse
|
||||
|
||||
var req getIngressListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Ingresses
|
||||
|
||||
var resp getIngressListResponse
|
||||
resp.Data = ingressList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getIngressRequest
|
||||
type getIngressRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Ingress string `uri:"ingress" json:"ingress"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getIngressResponse
|
||||
type getIngressResponse struct {
|
||||
// in: body
|
||||
Data *config.IngressConfig
|
||||
}
|
||||
|
||||
func getIngress(ctx *gin.Context) {
|
||||
// swagger:route GET /config/ingresses/{ingress} Ingress getIngressRequest
|
||||
//
|
||||
// Get ingress.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getIngressResponse
|
||||
|
||||
var req getIngressRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getIngressResponse
|
||||
|
||||
for _, ingress := range config.Global().Ingresses {
|
||||
if ingress == nil {
|
||||
continue
|
||||
}
|
||||
if ingress.Name == req.Ingress {
|
||||
resp.Data = ingress
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createIngressRequest
|
||||
type createIngressRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getLimiterListRequest
|
||||
type getLimiterListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getLimiterListResponse
|
||||
type getLimiterListResponse struct {
|
||||
// in: body
|
||||
Data limiterList
|
||||
}
|
||||
|
||||
type limiterList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.LimiterConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getLimiterList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/limiters Limiter getLimiterListRequest
|
||||
//
|
||||
// Get limiter list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getLimiterListResponse
|
||||
|
||||
var req getLimiterListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Limiters
|
||||
|
||||
var resp getLimiterListResponse
|
||||
resp.Data = limiterList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getLimiterRequest
|
||||
type getLimiterRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Limiter string `uri:"limiter" json:"limiter"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getLimiterResponse
|
||||
type getLimiterResponse struct {
|
||||
// in: body
|
||||
Data *config.LimiterConfig
|
||||
}
|
||||
|
||||
func getLimiter(ctx *gin.Context) {
|
||||
// swagger:route GET /config/limiters/{limiter} Limiter getLimiterRequest
|
||||
//
|
||||
// Get limiter.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getLimiterResponse
|
||||
|
||||
var req getLimiterRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getLimiterResponse
|
||||
|
||||
for _, limiter := range config.Global().Limiters {
|
||||
if limiter == nil {
|
||||
continue
|
||||
}
|
||||
if limiter.Name == req.Limiter {
|
||||
resp.Data = limiter
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createLimiterRequest
|
||||
type createLimiterRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getObserverListRequest
|
||||
type getObserverListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getObserverListResponse
|
||||
type getObserverListResponse struct {
|
||||
// in: body
|
||||
Data observerList
|
||||
}
|
||||
|
||||
type observerList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.ObserverConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getObserverList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/observers Observer getObserverListRequest
|
||||
//
|
||||
// Get observer list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getObserverListResponse
|
||||
|
||||
var req getObserverListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Observers
|
||||
|
||||
var resp getObserverListResponse
|
||||
resp.Data = observerList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getObserverRequest
|
||||
type getObserverRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Observer string `uri:"observer" json:"observer"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getObserverResponse
|
||||
type getObserverResponse struct {
|
||||
// in: body
|
||||
Data *config.ObserverConfig
|
||||
}
|
||||
|
||||
func getObserver(ctx *gin.Context) {
|
||||
// swagger:route GET /config/observers/{observer} Observer getObserverRequest
|
||||
//
|
||||
// Get observer.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getObserverResponse
|
||||
|
||||
var req getObserverRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getObserverResponse
|
||||
|
||||
for _, observer := range config.Global().Observers {
|
||||
if observer == nil {
|
||||
continue
|
||||
}
|
||||
if observer.Name == req.Observer {
|
||||
resp.Data = observer
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createObserverRequest
|
||||
type createObserverRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/x/config"
|
||||
parser "github.com/go-gost/x/config/parsing/quota"
|
||||
"github.com/go-gost/x/limiter/quota"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func fillQuotaStatus(q *config.QuotaConfig) {
|
||||
lim := registry.QuotaLimiterRegistry().Get(q.Name)
|
||||
if lim == nil {
|
||||
return
|
||||
}
|
||||
s := lim.Snapshot()
|
||||
q.Status = &config.QuotaStatus{
|
||||
Used: s.Used,
|
||||
Limit: s.Limit,
|
||||
StartsAt: s.StartsAtUnix,
|
||||
ExpiresAt: s.ExpiresAtUnix,
|
||||
Active: s.Active,
|
||||
Expired: s.Expired,
|
||||
Blocked: s.Blocked,
|
||||
Direction: s.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters getQuotaListRequest
|
||||
type getQuotaListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getQuotaListResponse
|
||||
type getQuotaListResponse struct {
|
||||
// in: body
|
||||
Data quotaList
|
||||
}
|
||||
|
||||
type quotaList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.QuotaConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getQuotaList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/quotas Quota getQuotaListRequest
|
||||
//
|
||||
// Get quota list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getQuotaListResponse
|
||||
|
||||
var req getQuotaListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Quotas
|
||||
for _, q := range list {
|
||||
if q == nil {
|
||||
continue
|
||||
}
|
||||
fillQuotaStatus(q)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: quotaList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getQuotaRequest
|
||||
type getQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getQuotaResponse
|
||||
type getQuotaResponse struct {
|
||||
// in: body
|
||||
Data *config.QuotaConfig
|
||||
}
|
||||
|
||||
func getQuota(ctx *gin.Context) {
|
||||
// swagger:route GET /config/quotas/{quota} Quota getQuotaRequest
|
||||
//
|
||||
// Get quota.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getQuotaResponse
|
||||
|
||||
var req getQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getQuotaResponse
|
||||
|
||||
for _, q := range config.Global().Quotas {
|
||||
if q == nil {
|
||||
continue
|
||||
}
|
||||
if q.Name == req.Quota {
|
||||
fillQuotaStatus(q)
|
||||
resp.Data = q
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createQuotaRequest
|
||||
type createQuotaRequest struct {
|
||||
// in: body
|
||||
Data config.QuotaConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response createQuotaResponse
|
||||
type createQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func createQuota(ctx *gin.Context) {
|
||||
// swagger:route POST /config/quotas Quota createQuotaRequest
|
||||
//
|
||||
// Create a new quota, the name of the quota must be unique in quota list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: createQuotaResponse
|
||||
|
||||
var req createQuotaRequest
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "quota name is required"))
|
||||
return
|
||||
}
|
||||
req.Data.Name = name
|
||||
req.Data.Status = nil
|
||||
|
||||
if registry.QuotaLimiterRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
v := parser.ParseQuotaLimiter(&req.Data)
|
||||
|
||||
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
c.Quotas = append(c.Quotas, &req.Data)
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters updateQuotaRequest
|
||||
type updateQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
// in: body
|
||||
Data config.QuotaConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response updateQuotaResponse
|
||||
type updateQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func updateQuota(ctx *gin.Context) {
|
||||
// swagger:route PUT /config/quotas/{quota} Quota updateQuotaRequest
|
||||
//
|
||||
// Update quota by name, the quota must already exist. The cumulative counter
|
||||
// is preserved across the update as long as the window is unchanged.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: updateQuotaResponse
|
||||
|
||||
var req updateQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Quota)
|
||||
|
||||
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
req.Data.Status = nil
|
||||
|
||||
v := parser.ParseQuotaLimiter(&req.Data)
|
||||
|
||||
registry.QuotaLimiterRegistry().Unregister(name)
|
||||
|
||||
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
for i := range c.Quotas {
|
||||
if c.Quotas[i].Name == name {
|
||||
c.Quotas[i] = &req.Data
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters deleteQuotaRequest
|
||||
type deleteQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response deleteQuotaResponse
|
||||
type deleteQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func deleteQuota(ctx *gin.Context) {
|
||||
// swagger:route DELETE /config/quotas/{quota} Quota deleteQuotaRequest
|
||||
//
|
||||
// Delete quota by name. Services referencing it stop being limited (fail-open).
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: deleteQuotaResponse
|
||||
|
||||
var req deleteQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
name := strings.TrimSpace(req.Quota)
|
||||
|
||||
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
|
||||
return
|
||||
}
|
||||
registry.QuotaLimiterRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
quotas := c.Quotas
|
||||
c.Quotas = nil
|
||||
for _, q := range quotas {
|
||||
if q.Name == name {
|
||||
continue
|
||||
}
|
||||
c.Quotas = append(c.Quotas, q)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters resetQuotaRequest
|
||||
type resetQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
// in: body
|
||||
Data resetQuotaData `json:"data"`
|
||||
}
|
||||
|
||||
type resetQuotaData struct {
|
||||
Used *uint64 `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response resetQuotaResponse
|
||||
type resetQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func resetQuota(ctx *gin.Context) {
|
||||
// swagger:route POST /config/quotas/{quota}/reset Quota resetQuotaRequest
|
||||
//
|
||||
// Overwrite the cumulative counter of a quota (defaults to 0). The new value
|
||||
// is applied immediately and persisted; if the quota was blocking, services
|
||||
// resume once the value is below the limit.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: resetQuotaResponse
|
||||
|
||||
var req resetQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Quota)
|
||||
|
||||
lim := registry.QuotaLimiterRegistry().Get(name)
|
||||
if lim == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
used := uint64(0)
|
||||
if req.Data.Used != nil {
|
||||
used = *req.Data.Used
|
||||
}
|
||||
lim.Update(quota.Update{Used: &used})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getRateLimiterListRequest
|
||||
type getRateLimiterListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getRateLimiterListResponse
|
||||
type getRateLimiterListResponse struct {
|
||||
// in: body
|
||||
Data rateLimiterList
|
||||
}
|
||||
|
||||
type rateLimiterList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.LimiterConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getRateLimiterList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/rlimiters Limiter getRateLimiterListRequest
|
||||
//
|
||||
// Get rate limiter list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getRateLimiterListResponse
|
||||
|
||||
var req getRateLimiterListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().CLimiters
|
||||
|
||||
var resp getRateLimiterListResponse
|
||||
resp.Data = rateLimiterList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getRateLimiterRequest
|
||||
type getRateLimiterRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Limiter string `uri:"limiter" json:"limiter"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getRateLimiterResponse
|
||||
type getRateLimiterResponse struct {
|
||||
// in: body
|
||||
Data *config.LimiterConfig
|
||||
}
|
||||
|
||||
func getRateLimiter(ctx *gin.Context) {
|
||||
// swagger:route GET /config/rlimiters/{limiter} Limiter getRateLimiterRequest
|
||||
//
|
||||
// Get conn limiter.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getRateLimiterResponse
|
||||
|
||||
var req getRateLimiterRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getRateLimiterResponse
|
||||
|
||||
for _, limiter := range config.Global().CLimiters{
|
||||
if limiter == nil {
|
||||
continue
|
||||
}
|
||||
if limiter.Name == req.Limiter {
|
||||
resp.Data = limiter
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createRateLimiterRequest
|
||||
type createRateLimiterRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getRecorderListRequest
|
||||
type getRecorderListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getRecorderListResponse
|
||||
type getRecorderListResponse struct {
|
||||
// in: body
|
||||
Data recorderList
|
||||
}
|
||||
|
||||
type recorderList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.RecorderConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getRecorderList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/recorders Recorder getRecorderListRequest
|
||||
//
|
||||
// Get recorder list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getRecorderListResponse
|
||||
|
||||
var req getRecorderListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Recorders
|
||||
|
||||
var resp getRecorderListResponse
|
||||
resp.Data = recorderList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getRecorderRequest
|
||||
type getRecorderRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Recorder string `uri:"recorder" json:"recorder"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getRecorderResponse
|
||||
type getRecorderResponse struct {
|
||||
// in: body
|
||||
Data *config.RecorderConfig
|
||||
}
|
||||
|
||||
func getRecorder(ctx *gin.Context) {
|
||||
// swagger:route GET /config/recorders/{recorder} Recorder getRecorderRequest
|
||||
//
|
||||
// Get recorder.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getRecorderResponse
|
||||
|
||||
var req getRecorderRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getRecorderResponse
|
||||
|
||||
for _, recorder := range config.Global().Recorders {
|
||||
if recorder == nil {
|
||||
continue
|
||||
}
|
||||
if recorder.Name == req.Recorder {
|
||||
resp.Data = recorder
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createRecorderRequest
|
||||
type createRecorderRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getResolverListRequest
|
||||
type getResolverListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getResolverListResponse
|
||||
type getResolverListResponse struct {
|
||||
// in: body
|
||||
Data resolverList
|
||||
}
|
||||
|
||||
type resolverList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.ResolverConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getResolverList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/resolvers Resolver getResolverListRequest
|
||||
//
|
||||
// Get resolver list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getResolverListResponse
|
||||
|
||||
var req getResolverListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Resolvers
|
||||
|
||||
var resp getResolverListResponse
|
||||
resp.Data = resolverList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getResolverRequest
|
||||
type getResolverRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Resolver string `uri:"resolver" json:"resolver"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getResolverResponse
|
||||
type getResolverResponse struct {
|
||||
// in: body
|
||||
Data *config.ResolverConfig
|
||||
}
|
||||
|
||||
func getResolver(ctx *gin.Context) {
|
||||
// swagger:route GET /config/resolvers/{resolver} Resolver getResolverRequest
|
||||
//
|
||||
// Get resolver.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getResolverResponse
|
||||
|
||||
var req getResolverRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getResolverResponse
|
||||
|
||||
for _, resolver := range config.Global().Resolvers {
|
||||
if resolver == nil {
|
||||
continue
|
||||
}
|
||||
if resolver.Name == req.Resolver {
|
||||
resp.Data = resolver
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createResolverRequest
|
||||
type createResolverRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getRouterListRequest
|
||||
type getRouterListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getRouterListResponse
|
||||
type getRouterListResponse struct {
|
||||
// in: body
|
||||
Data routerList
|
||||
}
|
||||
|
||||
type routerList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.RouterConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getRouterList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/routers Router getRouterListRequest
|
||||
//
|
||||
// Get router list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getRouterListResponse
|
||||
|
||||
var req getRouterListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Routers
|
||||
|
||||
var resp getRouterListResponse
|
||||
resp.Data = routerList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getRouterRequest
|
||||
type getRouterRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Router string `uri:"router" json:"router"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getRouterResponse
|
||||
type getRouterResponse struct {
|
||||
// in: body
|
||||
Data *config.RouterConfig
|
||||
}
|
||||
|
||||
func getRouter(ctx *gin.Context) {
|
||||
// swagger:route GET /config/routers/{router} Router getRouterRequest
|
||||
//
|
||||
// Get router.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getRouterResponse
|
||||
|
||||
var req getRouterRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getRouterResponse
|
||||
|
||||
for _, router := range config.Global().Routers {
|
||||
if router == nil {
|
||||
continue
|
||||
}
|
||||
if router.Name == req.Router {
|
||||
resp.Data = router
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createRouterRequest
|
||||
type createRouterRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getSDListRequest
|
||||
type getSDListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getSDListResponse
|
||||
type getSDListResponse struct {
|
||||
// in: body
|
||||
Data sdList
|
||||
}
|
||||
|
||||
type sdList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.SDConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getSDList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/sds SD getSDListRequest
|
||||
//
|
||||
// Get sd list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getSDListResponse
|
||||
|
||||
var req getSDListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().SDs
|
||||
|
||||
var resp getSDListResponse
|
||||
resp.Data = sdList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getSDRequest
|
||||
type getSDRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
SD string `uri:"sd" json:"sd"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getSDResponse
|
||||
type getSDResponse struct {
|
||||
// in: body
|
||||
Data *config.SDConfig
|
||||
}
|
||||
|
||||
func getSD(ctx *gin.Context) {
|
||||
// swagger:route GET /config/sds/{sd} SD getSDRequest
|
||||
//
|
||||
// Get sd.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getSDResponse
|
||||
|
||||
var req getSDRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getSDResponse
|
||||
|
||||
for _, sd := range config.Global().SDs {
|
||||
if sd == nil {
|
||||
continue
|
||||
}
|
||||
if sd.Name == req.SD {
|
||||
resp.Data = sd
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createSDRequest
|
||||
type createSDRequest struct {
|
||||
// in: body
|
||||
|
||||
@@ -11,6 +11,101 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getServiceListRequest
|
||||
type getServiceListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getServiceListResponse
|
||||
type getServiceListResponse struct {
|
||||
// in: body
|
||||
Data serviceList
|
||||
}
|
||||
|
||||
type serviceList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.ServiceConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getServiceList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/services Service getServiceListRequest
|
||||
//
|
||||
// Get service list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getServiceListResponse
|
||||
|
||||
var req getServiceListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Services
|
||||
|
||||
for _, svc := range list {
|
||||
if svc == nil {
|
||||
continue
|
||||
}
|
||||
fillServiceStatus(svc)
|
||||
}
|
||||
|
||||
var resp getServiceListResponse
|
||||
resp.Data = serviceList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getServiceRequest
|
||||
type getServiceRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Service string `uri:"service" json:"service"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getServiceResponse
|
||||
type getServiceResponse struct {
|
||||
// in: body
|
||||
Data *config.ServiceConfig
|
||||
}
|
||||
|
||||
func getService(ctx *gin.Context) {
|
||||
// swagger:route GET /config/services/{service} Service getServiceRequest
|
||||
//
|
||||
// Get service.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getServiceResponse
|
||||
|
||||
var req getServiceRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getServiceResponse
|
||||
|
||||
for _, service := range config.Global().Services {
|
||||
if service == nil {
|
||||
continue
|
||||
}
|
||||
if service.Name == req.Service {
|
||||
fillServiceStatus(service)
|
||||
resp.Data = service
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createServiceRequest
|
||||
type createServiceRequest struct {
|
||||
// in: body
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ func mwBasicAuth(auther auth.Authenticator) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
u, p, _ := c.Request.BasicAuth()
|
||||
if _, ok := auther.Authenticate(c, u, p); !ok {
|
||||
if _, ok := auther.Authenticate(c, u, p, auth.WithService("@api")); !ok {
|
||||
c.Writer.Header().Set("WWW-Authenticate", "Basic")
|
||||
c.JSON(http.StatusUnauthorized, Response{
|
||||
Code: http.StatusUnauthorized,
|
||||
|
||||
+1360
-369
File diff suppressed because it is too large
Load Diff
+59
-40
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"maps"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -67,6 +68,7 @@ type authenticator struct {
|
||||
mu sync.RWMutex
|
||||
cancelFunc context.CancelFunc
|
||||
options options
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewAuthenticator creates an Authenticator that authenticates client by pre-defined user mapping.
|
||||
@@ -75,23 +77,19 @@ func NewAuthenticator(opts ...Option) auth.Authenticator {
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
if options.logger == nil {
|
||||
options.logger = xlogger.Nop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p := &authenticator{
|
||||
kvs: make(map[string]string),
|
||||
cancelFunc: cancel,
|
||||
options: options,
|
||||
logger: options.logger,
|
||||
}
|
||||
if p.logger == nil {
|
||||
p.logger = xlogger.Nop()
|
||||
}
|
||||
|
||||
if err := p.reload(ctx); err != nil {
|
||||
options.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
if p.options.period > 0 {
|
||||
go p.periodReload(ctx)
|
||||
}
|
||||
go p.periodReload(ctx)
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -114,7 +112,14 @@ func (p *authenticator) Authenticate(ctx context.Context, user, password string,
|
||||
}
|
||||
|
||||
func (p *authenticator) periodReload(ctx context.Context) error {
|
||||
if err := p.reload(ctx); err != nil {
|
||||
p.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
|
||||
period := p.options.period
|
||||
if period <= 0 {
|
||||
return nil
|
||||
}
|
||||
if period < time.Second {
|
||||
period = time.Second
|
||||
}
|
||||
@@ -125,7 +130,7 @@ func (p *authenticator) periodReload(ctx context.Context) error {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := p.reload(ctx); err != nil {
|
||||
p.options.logger.Warnf("reload: %v", err)
|
||||
p.logger.Warnf("reload: %v", err)
|
||||
// return err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
@@ -134,81 +139,92 @@ func (p *authenticator) periodReload(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *authenticator) reload(ctx context.Context) (err error) {
|
||||
func (p *authenticator) reload(ctx context.Context) error {
|
||||
kvs := make(map[string]string)
|
||||
for k, v := range p.options.auths {
|
||||
kvs[k] = v
|
||||
}
|
||||
maps.Copy(kvs, p.options.auths)
|
||||
|
||||
m, err := p.load(ctx)
|
||||
for k, v := range m {
|
||||
kvs[k] = v
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maps.Copy(kvs, m)
|
||||
|
||||
p.options.logger.Debugf("load items %d", len(m))
|
||||
p.logger.Debugf("load items %d", len(m))
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.kvs = kvs
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *authenticator) load(ctx context.Context) (m map[string]string, err error) {
|
||||
m = make(map[string]string)
|
||||
func (p *authenticator) load(ctx context.Context) (map[string]string, error) {
|
||||
m := make(map[string]string)
|
||||
var loadErr error
|
||||
|
||||
if p.options.fileLoader != nil {
|
||||
if mapper, ok := p.options.fileLoader.(loader.Mapper); ok {
|
||||
auths, er := mapper.Map(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("file loader: %v", er)
|
||||
p.logger.Warnf("file loader: %v", er)
|
||||
loadErr = er
|
||||
}
|
||||
m = auths
|
||||
} else {
|
||||
r, er := p.options.fileLoader.Load(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("file loader: %v", er)
|
||||
p.logger.Warnf("file loader: %v", er)
|
||||
loadErr = er
|
||||
}
|
||||
if auths, _ := p.parseAuths(r); auths != nil {
|
||||
if auths, err := p.parseAuths(r); err == nil {
|
||||
m = auths
|
||||
} else {
|
||||
p.logger.Warnf("file loader parse: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.options.redisLoader != nil {
|
||||
if mapper, ok := p.options.fileLoader.(loader.Mapper); ok {
|
||||
if mapper, ok := p.options.redisLoader.(loader.Mapper); ok {
|
||||
auths, er := mapper.Map(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("file loader: %v", er)
|
||||
}
|
||||
for k, v := range auths {
|
||||
m[k] = v
|
||||
p.logger.Warnf("redis loader: %v", er)
|
||||
if loadErr == nil {
|
||||
loadErr = er
|
||||
}
|
||||
}
|
||||
maps.Copy(m, auths)
|
||||
} else {
|
||||
r, er := p.options.redisLoader.Load(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("redis loader: %v", er)
|
||||
}
|
||||
if auths, _ := p.parseAuths(r); auths != nil {
|
||||
for k, v := range auths {
|
||||
m[k] = v
|
||||
p.logger.Warnf("redis loader: %v", er)
|
||||
if loadErr == nil {
|
||||
loadErr = er
|
||||
}
|
||||
}
|
||||
if auths, err := p.parseAuths(r); err == nil {
|
||||
maps.Copy(m, auths)
|
||||
} else {
|
||||
p.logger.Warnf("redis loader parse: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.options.httpLoader != nil {
|
||||
r, er := p.options.httpLoader.Load(ctx)
|
||||
if er != nil {
|
||||
p.options.logger.Warnf("http loader: %v", er)
|
||||
}
|
||||
if auths, _ := p.parseAuths(r); auths != nil {
|
||||
for k, v := range auths {
|
||||
m[k] = v
|
||||
p.logger.Warnf("http loader: %v", er)
|
||||
if loadErr == nil {
|
||||
loadErr = er
|
||||
}
|
||||
}
|
||||
if auths, err := p.parseAuths(r); err == nil {
|
||||
maps.Copy(m, auths)
|
||||
} else {
|
||||
p.logger.Warnf("http loader parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
return m, loadErr
|
||||
}
|
||||
|
||||
func (p *authenticator) parseAuths(r io.Reader) (auths map[string]string, err error) {
|
||||
@@ -250,6 +266,9 @@ func (p *authenticator) Close() error {
|
||||
if p.options.redisLoader != nil {
|
||||
p.options.redisLoader.Close()
|
||||
}
|
||||
if p.options.httpLoader != nil {
|
||||
p.options.httpLoader.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+1100
File diff suppressed because it is too large
Load Diff
+22
-7
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/plugin/auth/proto"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
@@ -51,12 +51,27 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
return "", false
|
||||
}
|
||||
|
||||
r, err := p.client.Authenticate(ctx,
|
||||
&proto.AuthenticateRequest{
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: string(ctxvalue.ClientAddrFromContext(ctx)),
|
||||
})
|
||||
var options auth.Options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
var clientAddr string
|
||||
if v := xctx.SrcAddrFromContext(ctx); v != nil {
|
||||
clientAddr = v.String()
|
||||
}
|
||||
req := &proto.AuthenticateRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
}
|
||||
if v := xctx.PeerCertFromContext(ctx); v != nil {
|
||||
req.ClientCn = v.CN
|
||||
req.ClientSan = v.SANs
|
||||
req.ClientCertFingerprint = v.Fingerprint
|
||||
}
|
||||
r, err := p.client.Authenticate(ctx, req)
|
||||
if err != nil {
|
||||
p.log.Error(err)
|
||||
return "", false
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/plugin/auth/proto"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
corelogger.SetDefault(xlogger.Nop())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func newTestGRPCConn(t *testing.T, srv proto.AuthenticatorServer) (*grpc.ClientConn, func()) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
gsrv := grpc.NewServer()
|
||||
proto.RegisterAuthenticatorServer(gsrv, srv)
|
||||
go gsrv.Serve(lis)
|
||||
|
||||
conn, err := grpc.NewClient(lis.Addr().String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return conn, func() {
|
||||
conn.Close()
|
||||
gsrv.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_NilClient(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Authenticate_Success(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: true, id: "user1"})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAuthenticatorClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user1", id)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Authenticate_Fail(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: false, id: ""})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAuthenticatorClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Authenticate_WithService(t *testing.T) {
|
||||
server := &testAuthServer{ok: true, id: "srv"}
|
||||
conn, cleanup := newTestGRPCConn(t, server)
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAuthenticatorClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p", auth.WithService("myservice"))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "srv", id)
|
||||
assert.Equal(t, "myservice", server.lastService)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Authenticate_WithClientAddr(t *testing.T) {
|
||||
server := &testAuthServer{ok: true, id: "addr"}
|
||||
conn, cleanup := newTestGRPCConn(t, server)
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAuthenticatorClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = xctx.ContextWithSrcAddr(ctx, &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234})
|
||||
id, ok := p.Authenticate(ctx, "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "addr", id)
|
||||
assert.Equal(t, "10.0.0.1:1234", server.lastClient)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Authenticate_WithoutClientAddr(t *testing.T) {
|
||||
server := &testAuthServer{ok: true, id: "noclient"}
|
||||
conn, cleanup := newTestGRPCConn(t, server)
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAuthenticatorClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "noclient", id)
|
||||
assert.Empty(t, server.lastClient)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Authenticate_ServerError(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &errorAuthServer{})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewAuthenticatorClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestNewGRPCPlugin_RealConn(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
gsrv := grpc.NewServer()
|
||||
proto.RegisterAuthenticatorServer(gsrv, &testAuthServer{ok: true, id: "real"})
|
||||
go gsrv.Serve(lis)
|
||||
defer gsrv.Stop()
|
||||
|
||||
p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500))
|
||||
require.NotNil(t, p)
|
||||
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "real", id)
|
||||
assert.NoError(t, p.(io.Closer).Close())
|
||||
}
|
||||
|
||||
func TestNewGRPCPlugin_InvalidAddr(t *testing.T) {
|
||||
// Null byte causes grpc.NewClient target parsing to fail synchronously.
|
||||
// This covers the log.Error(err) path.
|
||||
// NOTE: depends on grpc-go's net/url control-character validation.
|
||||
// If a future grpc version changes target parsing, this test may silently
|
||||
// stop covering the error branch.
|
||||
p := NewGRPCPlugin("test", "\x00")
|
||||
require.NotNil(t, p)
|
||||
|
||||
// conn is nil, so client is nil, so Authenticate returns false
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_NilConn(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: &nonCloserConn{},
|
||||
client: nil,
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_WithRealConn(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: true})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: nil,
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
type testAuthServer struct {
|
||||
proto.UnimplementedAuthenticatorServer
|
||||
ok bool
|
||||
id string
|
||||
lastService string
|
||||
lastClient string
|
||||
}
|
||||
|
||||
func (s *testAuthServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateReply, error) {
|
||||
s.lastService = req.Service
|
||||
s.lastClient = req.Client
|
||||
return &proto.AuthenticateReply{Ok: s.ok, Id: s.id}, nil
|
||||
}
|
||||
|
||||
type errorAuthServer struct {
|
||||
proto.UnimplementedAuthenticatorServer
|
||||
}
|
||||
|
||||
func (s *errorAuthServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateReply, error) {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
type nonCloserConn struct{}
|
||||
|
||||
func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ grpc.ClientConnInterface = (*nonCloserConn)(nil)
|
||||
var _ io.Closer = (*grpcPlugin)(nil)
|
||||
+27
-5
@@ -4,18 +4,23 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/logger"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
)
|
||||
|
||||
type httpPluginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
Service string `json:"service"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
ClientCn string `json:"clientCn,omitempty"`
|
||||
ClientSan []string `json:"clientSan,omitempty"`
|
||||
ClientCertFingerprint string `json:"clientCertFingerprint,omitempty"`
|
||||
}
|
||||
|
||||
type httpPluginResponse struct {
|
||||
@@ -53,10 +58,26 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
return
|
||||
}
|
||||
|
||||
var options auth.Options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
var clientAddr string
|
||||
if v := xctx.SrcAddrFromContext(ctx); v != nil {
|
||||
clientAddr = v.String()
|
||||
}
|
||||
|
||||
rb := httpPluginRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: string(ctxvalue.ClientAddrFromContext(ctx)),
|
||||
Client: clientAddr,
|
||||
}
|
||||
if v := xctx.PeerCertFromContext(ctx); v != nil {
|
||||
rb.ClientCn = v.CN
|
||||
rb.ClientSan = v.SANs
|
||||
rb.ClientCertFingerprint = v.Fingerprint
|
||||
}
|
||||
v, err := json.Marshal(&rb)
|
||||
if err != nil {
|
||||
@@ -79,6 +100,7 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPPlugin(t *testing.T) {
|
||||
p := NewHTTPPlugin("test", "http://localhost:9999")
|
||||
require.NotNil(t, p)
|
||||
|
||||
hp, ok := p.(*httpPlugin)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "http://localhost:9999", hp.url)
|
||||
assert.NotNil(t, hp.client)
|
||||
assert.NotNil(t, hp.log)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "user1"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user1", id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_Fail(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": false, "id": ""}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_Non200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_InvalidJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`not json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_ConnectionRefused(t *testing.T) {
|
||||
p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0))
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_NilClient(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
url: "http://localhost",
|
||||
client: nil,
|
||||
}
|
||||
id, ok := hp.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_WithService(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "svc"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p", auth.WithService("myservice"))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "svc", id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_WithHeader(t *testing.T) {
|
||||
var receivedHeader string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeader = r.Header.Get("X-Custom")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "hdr"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{
|
||||
"X-Custom": []string{"test-value"},
|
||||
}))
|
||||
id, ok := p.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "hdr", id)
|
||||
assert.Equal(t, "test-value", receivedHeader)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_NoHeader(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "nohdr"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hp := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
header: nil,
|
||||
}
|
||||
id, ok := hp.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "nohdr", id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_WithClientAddr(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "addr"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hp := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5678})
|
||||
id, ok := hp.Authenticate(ctx, "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "addr", id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_WithoutClientAddr(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "noaddr"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hp := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
id, ok := hp.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "noaddr", id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_BadURL(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
url: "http://invalid hostname",
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
id, ok := hp.Authenticate(context.Background(), "u", "p")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Authenticate_WithAllOptions(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer mytoken", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true, "id": "all"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hp := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
header: http.Header{"Authorization": []string{"Bearer mytoken"}},
|
||||
}
|
||||
id, ok := hp.Authenticate(context.Background(), "u", "p")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "all", id)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/matcher"
|
||||
)
|
||||
|
||||
// whitelistedAuthenticator wraps an Authenticator to skip authentication
|
||||
// for client IPs matching the configured whitelist patterns.
|
||||
type whitelistedAuthenticator struct {
|
||||
auther auth.Authenticator
|
||||
ipMatcher matcher.Matcher
|
||||
cidrMatcher matcher.Matcher
|
||||
}
|
||||
|
||||
// WhitelistedAuthenticator returns an Authenticator that skips the underlying
|
||||
// auther when the client's IP address matches one of the given patterns.
|
||||
// Patterns may be IP addresses (e.g. "192.168.1.1") or CIDR ranges
|
||||
// (e.g. "10.0.0.0/8"). Invalid patterns are silently ignored.
|
||||
func WhitelistedAuthenticator(auther auth.Authenticator, patterns []string) auth.Authenticator {
|
||||
var ips []net.IP
|
||||
var inets []*net.IPNet
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(pattern); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue
|
||||
}
|
||||
if _, inet, err := net.ParseCIDR(pattern); err == nil {
|
||||
inets = append(inets, inet)
|
||||
}
|
||||
}
|
||||
return &whitelistedAuthenticator{
|
||||
auther: auther,
|
||||
ipMatcher: matcher.IPMatcher(ips),
|
||||
cidrMatcher: matcher.CIDRMatcher(inets),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *whitelistedAuthenticator) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
host, _, err := net.SplitHostPort(srcAddr.String())
|
||||
if err == nil && host != "" {
|
||||
if w.ipMatcher.Match(host) || w.cidrMatcher.Match(host) {
|
||||
return "", true
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.auther != nil {
|
||||
return w.auther.Authenticate(ctx, user, password, opts...)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
+338
-134
@@ -1,3 +1,19 @@
|
||||
// Package bypass implements address-based routing that decides whether a
|
||||
// target address should skip the proxy chain and connect directly.
|
||||
//
|
||||
// The package provides:
|
||||
// - NewBypass: a local bypass that matches addresses against CIDR, IP range,
|
||||
// wildcard, and exact-address patterns, with optional periodic reload from
|
||||
// file, Redis, or HTTP sources.
|
||||
// - BypassGroup: composes multiple bypass rules; whitelist rules use AND
|
||||
// logic, blacklist rules use OR logic (evaluated only if whitelist fails).
|
||||
// - Plugin-based bypass (gRPC and HTTP) in the plugin sub-package.
|
||||
//
|
||||
// Matching modes:
|
||||
// - Blacklist (whitelist=false): matching addresses bypass the proxy.
|
||||
// This is the default.
|
||||
// - Whitelist (whitelist=true): matching addresses go through the proxy
|
||||
// chain; all other addresses bypass the proxy.
|
||||
package bypass
|
||||
|
||||
import (
|
||||
@@ -6,125 +22,253 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/logger"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/matcher"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/gobwas/glob"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBypass = errors.New("bypass")
|
||||
)
|
||||
// ErrBypass is returned by wrapped connections when the bypass rule
|
||||
// determines that the connection should skip the proxy chain.
|
||||
var ErrBypass = errors.New("bypass")
|
||||
|
||||
// options holds the configuration for a localBypass instance.
|
||||
type options struct {
|
||||
whitelist bool
|
||||
matchers []string
|
||||
fileLoader loader.Loader
|
||||
// whitelist toggles between blacklist and whitelist mode.
|
||||
// When false (default): matching addresses bypass the proxy.
|
||||
// When true: matching addresses use the proxy and all others bypass it.
|
||||
whitelist bool
|
||||
|
||||
// network restricts this bypass to a specific network protocol.
|
||||
// When set, the incoming network must match as part of the base rule.
|
||||
// Recognized values include "tcp" and "udp". An empty value
|
||||
// disables the network check.
|
||||
network string
|
||||
|
||||
// matchers holds static patterns provided at construction time
|
||||
// (e.g. from config file or command-line arguments).
|
||||
matchers []string
|
||||
|
||||
// fileLoader loads patterns from a file or directory.
|
||||
fileLoader loader.Loader
|
||||
|
||||
// redisLoader loads patterns from a Redis set or key.
|
||||
redisLoader loader.Loader
|
||||
httpLoader loader.Loader
|
||||
period time.Duration
|
||||
logger logger.Logger
|
||||
|
||||
// httpLoader loads patterns from an HTTP endpoint.
|
||||
httpLoader loader.Loader
|
||||
|
||||
// period controls the interval between automatic reloads.
|
||||
// Values less than 1 second are clamped to 1 second.
|
||||
// A value <= 0 disables periodic reload (load once at startup).
|
||||
period time.Duration
|
||||
|
||||
// logger is used for debug and warning messages.
|
||||
// Falls back to a no-op logger if nil.
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// Option is a functional option for configuring a Bypass instance.
|
||||
type Option func(opts *options)
|
||||
|
||||
// WhitelistOption sets whether the bypass operates in whitelist mode.
|
||||
// In whitelist mode, matching addresses go through the proxy chain;
|
||||
// all others bypass the proxy.
|
||||
func WhitelistOption(whitelist bool) Option {
|
||||
return func(opts *options) {
|
||||
opts.whitelist = whitelist
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkOption restricts this bypass to a specific network protocol.
|
||||
// Network and address predicates are combined before whitelist mode is
|
||||
// applied to the resulting match. Accepts values like "tcp" or "udp".
|
||||
func NetworkOption(network string) Option {
|
||||
return func(opts *options) {
|
||||
opts.network = network
|
||||
}
|
||||
}
|
||||
|
||||
// MatchersOption sets the static bypass patterns (CIDR, IP range, wildcard, or address).
|
||||
func MatchersOption(matchers []string) Option {
|
||||
return func(opts *options) {
|
||||
opts.matchers = matchers
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadPeriodOption sets the interval between automatic reloads of bypass
|
||||
// patterns from external loaders (file, Redis, HTTP).
|
||||
func ReloadPeriodOption(period time.Duration) Option {
|
||||
return func(opts *options) {
|
||||
opts.period = period
|
||||
}
|
||||
}
|
||||
|
||||
// FileLoaderOption sets the file loader for reading bypass patterns from a
|
||||
// file or directory.
|
||||
func FileLoaderOption(fileLoader loader.Loader) Option {
|
||||
return func(opts *options) {
|
||||
opts.fileLoader = fileLoader
|
||||
}
|
||||
}
|
||||
|
||||
// RedisLoaderOption sets the Redis loader for reading bypass patterns from a
|
||||
// Redis set or key.
|
||||
func RedisLoaderOption(redisLoader loader.Loader) Option {
|
||||
return func(opts *options) {
|
||||
opts.redisLoader = redisLoader
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPLoaderOption sets the HTTP loader for reading bypass patterns from an
|
||||
// HTTP endpoint.
|
||||
func HTTPLoaderOption(httpLoader loader.Loader) Option {
|
||||
return func(opts *options) {
|
||||
opts.httpLoader = httpLoader
|
||||
}
|
||||
}
|
||||
|
||||
// LoggerOption sets the logger for debug and warning messages.
|
||||
func LoggerOption(logger logger.Logger) Option {
|
||||
return func(opts *options) {
|
||||
opts.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
type localBypass struct {
|
||||
cidrMatcher matcher.Matcher
|
||||
addrMatcher matcher.Matcher
|
||||
wildcardMatcher matcher.Matcher
|
||||
ipRangeMatcher matcher.Matcher
|
||||
cancelFunc context.CancelFunc
|
||||
options options
|
||||
mu sync.RWMutex
|
||||
// bypassDecision represents the outcome of evaluating a bypass rule.
|
||||
type bypassDecision int
|
||||
|
||||
const (
|
||||
// decisionBypass means the address should connect directly, skipping the proxy chain.
|
||||
decisionBypass bypassDecision = iota
|
||||
// decisionProxy means the address should go through the proxy chain.
|
||||
decisionProxy
|
||||
)
|
||||
|
||||
func (d bypassDecision) String() string {
|
||||
switch d {
|
||||
case decisionBypass:
|
||||
return "bypass"
|
||||
case decisionProxy:
|
||||
return "proxy"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// NewBypass creates and initializes a new Bypass.
|
||||
// The rules will be reversed if the reverse option is true.
|
||||
// patternSet holds classified pattern matchers for a single bypass rule.
|
||||
// All four matcher types are built together by classifyPatterns and swapped
|
||||
// atomically under the write lock.
|
||||
type patternSet struct {
|
||||
cidr matcher.Matcher
|
||||
addr matcher.Matcher
|
||||
wildcard matcher.Matcher
|
||||
ipRange matcher.Matcher
|
||||
}
|
||||
|
||||
// matchAny reports whether addr matches any pattern in the set,
|
||||
// trying IP range, address, CIDR, and wildcard matchers in order.
|
||||
// Returns false if ps is nil.
|
||||
func (ps *patternSet) matchAny(addr string) bool {
|
||||
if ps == nil {
|
||||
return false
|
||||
}
|
||||
if ps.ipRange.Match(addr) {
|
||||
return true
|
||||
}
|
||||
if ps.addr.Match(addr) {
|
||||
return true
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
if host == "" {
|
||||
host = addr
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ps.cidr.Match(host) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ps.wildcard.Match(addr)
|
||||
}
|
||||
|
||||
// localBypass is a Bypass that matches addresses against local pattern
|
||||
// matchers. Patterns are classified into CIDR, wildcard, IP range, and
|
||||
// exact address matchers. Patterns can be loaded from static config,
|
||||
// file, Redis, or HTTP sources with optional periodic reload.
|
||||
type localBypass struct {
|
||||
patterns *patternSet
|
||||
options options
|
||||
logger logger.Logger
|
||||
mu sync.RWMutex
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
// NewBypass creates and initializes a local Bypass instance.
|
||||
//
|
||||
// In blacklist mode (the default), addresses matching any pattern bypass the
|
||||
// proxy. In whitelist mode (set via WhitelistOption(true)), matching addresses
|
||||
// go through the proxy chain and all others bypass the proxy.
|
||||
//
|
||||
// If a reload period is configured, patterns from external loaders are
|
||||
// refreshed automatically in the background.
|
||||
func NewBypass(opts ...Option) bypass.Bypass {
|
||||
var options options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
bp := &localBypass{
|
||||
p := &localBypass{
|
||||
cancelFunc: cancel,
|
||||
options: options,
|
||||
logger: options.logger,
|
||||
}
|
||||
if p.logger == nil {
|
||||
p.logger = xlogger.Nop()
|
||||
}
|
||||
|
||||
if err := bp.reload(ctx); err != nil {
|
||||
options.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
if bp.options.period > 0 {
|
||||
go bp.periodReload(ctx)
|
||||
if p.hasLoaders() {
|
||||
go p.periodReload(ctx)
|
||||
} else {
|
||||
_ = p.reload(ctx)
|
||||
}
|
||||
|
||||
return bp
|
||||
return p
|
||||
}
|
||||
|
||||
func (bp *localBypass) periodReload(ctx context.Context) error {
|
||||
period := bp.options.period
|
||||
// periodReload loads patterns immediately, then periodically reloads them
|
||||
// from external sources at the configured interval. Returns when ctx is
|
||||
// cancelled.
|
||||
func (p *localBypass) periodReload(ctx context.Context) error {
|
||||
if err := p.reload(ctx); err != nil {
|
||||
p.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
|
||||
period := p.options.period
|
||||
if period <= 0 {
|
||||
return nil
|
||||
}
|
||||
if period < time.Second {
|
||||
period = time.Second
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(period)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := bp.reload(ctx); err != nil {
|
||||
bp.options.logger.Warnf("reload: %v", err)
|
||||
// return err
|
||||
if err := p.reload(ctx); err != nil {
|
||||
p.logger.Warnf("reload: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -132,18 +276,40 @@ func (bp *localBypass) periodReload(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (bp *localBypass) reload(ctx context.Context) error {
|
||||
v, err := bp.load(ctx)
|
||||
// reload loads patterns from all configured sources, classifies them into
|
||||
// CIDR, wildcard, IP range, and address matchers, then atomically swaps the
|
||||
// pattern set under the write lock.
|
||||
func (p *localBypass) reload(ctx context.Context) error {
|
||||
v, err := p.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patterns := append(bp.options.matchers, v...)
|
||||
bp.options.logger.Debugf("load items %d", len(patterns))
|
||||
patterns := append(p.options.matchers, v...)
|
||||
p.logger.Debugf("load items %d", len(patterns))
|
||||
|
||||
p.mu.Lock()
|
||||
if len(patterns) > 0 {
|
||||
p.patterns = classifyPatterns(patterns, p.logger)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLoaders reports whether any external data source is configured,
|
||||
// which determines whether a background reload goroutine is needed.
|
||||
func (p *localBypass) hasLoaders() bool {
|
||||
return p.options.fileLoader != nil || p.options.redisLoader != nil || p.options.httpLoader != nil || p.options.period > 0
|
||||
}
|
||||
|
||||
// classifyPatterns sorts raw pattern strings into typed matchers.
|
||||
// Invalid wildcard patterns are logged and fall through to address matching.
|
||||
func classifyPatterns(patterns []string, log logger.Logger) *patternSet {
|
||||
var addrs []string
|
||||
var inets []*net.IPNet
|
||||
var wildcards []string
|
||||
var ipRanges []xnet.IPRange
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if _, inet, err := net.ParseCIDR(pattern); err == nil {
|
||||
inets = append(inets, inet)
|
||||
@@ -151,8 +317,11 @@ func (bp *localBypass) reload(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if strings.ContainsAny(pattern, "*?") {
|
||||
wildcards = append(wildcards, pattern)
|
||||
continue
|
||||
if _, err := glob.Compile(pattern); err == nil {
|
||||
wildcards = append(wildcards, pattern)
|
||||
continue
|
||||
}
|
||||
log.Warnf("invalid wildcard pattern %q, treating as plain address", pattern)
|
||||
}
|
||||
|
||||
r := xnet.IPRange{}
|
||||
@@ -164,62 +333,60 @@ func (bp *localBypass) reload(ctx context.Context) error {
|
||||
addrs = append(addrs, pattern)
|
||||
}
|
||||
|
||||
bp.mu.Lock()
|
||||
defer bp.mu.Unlock()
|
||||
|
||||
bp.cidrMatcher = matcher.CIDRMatcher(inets)
|
||||
bp.addrMatcher = matcher.AddrMatcher(addrs)
|
||||
bp.wildcardMatcher = matcher.WildcardMatcher(wildcards)
|
||||
bp.ipRangeMatcher = matcher.IPRangeMatcher(ipRanges)
|
||||
|
||||
return nil
|
||||
return &patternSet{
|
||||
cidr: matcher.CIDRMatcher(inets),
|
||||
addr: matcher.AddrMatcher(addrs),
|
||||
wildcard: matcher.WildcardMatcher(wildcards),
|
||||
ipRange: matcher.IPRangeMatcher(ipRanges),
|
||||
}
|
||||
}
|
||||
|
||||
func (bp *localBypass) load(ctx context.Context) (patterns []string, err error) {
|
||||
if bp.options.fileLoader != nil {
|
||||
if lister, ok := bp.options.fileLoader.(loader.Lister); ok {
|
||||
// load reads patterns from file, Redis and HTTP loaders if configured.
|
||||
func (p *localBypass) load(ctx context.Context) (patterns []string, err error) {
|
||||
if p.options.fileLoader != nil {
|
||||
if lister, ok := p.options.fileLoader.(loader.Lister); ok {
|
||||
list, er := lister.List(ctx)
|
||||
if er != nil {
|
||||
bp.options.logger.Warnf("file loader: %v", er)
|
||||
p.logger.Warnf("file loader: %v", er)
|
||||
}
|
||||
for _, s := range list {
|
||||
if line := bp.parseLine(s); line != "" {
|
||||
if line := p.parseLine(s); line != "" {
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r, er := bp.options.fileLoader.Load(ctx)
|
||||
r, er := p.options.fileLoader.Load(ctx)
|
||||
if er != nil {
|
||||
bp.options.logger.Warnf("file loader: %v", er)
|
||||
p.logger.Warnf("file loader: %v", er)
|
||||
}
|
||||
if v, _ := bp.parsePatterns(r); v != nil {
|
||||
if v, _ := p.parsePatterns(r); v != nil {
|
||||
patterns = append(patterns, v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bp.options.redisLoader != nil {
|
||||
if lister, ok := bp.options.redisLoader.(loader.Lister); ok {
|
||||
if p.options.redisLoader != nil {
|
||||
if lister, ok := p.options.redisLoader.(loader.Lister); ok {
|
||||
list, er := lister.List(ctx)
|
||||
if er != nil {
|
||||
bp.options.logger.Warnf("redis loader: %v", er)
|
||||
p.logger.Warnf("redis loader: %v", er)
|
||||
}
|
||||
patterns = append(patterns, list...)
|
||||
} else {
|
||||
r, er := bp.options.redisLoader.Load(ctx)
|
||||
r, er := p.options.redisLoader.Load(ctx)
|
||||
if er != nil {
|
||||
bp.options.logger.Warnf("redis loader: %v", er)
|
||||
p.logger.Warnf("redis loader: %v", er)
|
||||
}
|
||||
if v, _ := bp.parsePatterns(r); v != nil {
|
||||
if v, _ := p.parsePatterns(r); v != nil {
|
||||
patterns = append(patterns, v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bp.options.httpLoader != nil {
|
||||
r, er := bp.options.httpLoader.Load(ctx)
|
||||
if p.options.httpLoader != nil {
|
||||
r, er := p.options.httpLoader.Load(ctx)
|
||||
if er != nil {
|
||||
bp.options.logger.Warnf("http loader: %v", er)
|
||||
p.logger.Warnf("http loader: %v", er)
|
||||
}
|
||||
if v, _ := bp.parsePatterns(r); v != nil {
|
||||
if v, _ := p.parsePatterns(r); v != nil {
|
||||
patterns = append(patterns, v...)
|
||||
}
|
||||
}
|
||||
@@ -227,14 +394,16 @@ func (bp *localBypass) load(ctx context.Context) (patterns []string, err error)
|
||||
return
|
||||
}
|
||||
|
||||
func (bp *localBypass) parsePatterns(r io.Reader) (patterns []string, err error) {
|
||||
// parsePatterns reads lines from r, strips comments and whitespace, and
|
||||
// returns non-empty lines as patterns.
|
||||
func (p *localBypass) parsePatterns(r io.Reader) (patterns []string, err error) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
if line := bp.parseLine(scanner.Text()); line != "" {
|
||||
if line := p.parseLine(scanner.Text()); line != "" {
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
}
|
||||
@@ -243,107 +412,142 @@ func (bp *localBypass) parsePatterns(r io.Reader) (patterns []string, err error)
|
||||
return
|
||||
}
|
||||
|
||||
func (bp *localBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
||||
if addr == "" || bp == nil {
|
||||
func (p *localBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
||||
if addr == "" || p == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
matched := bp.matched(addr)
|
||||
decision := p.decide(network, addr)
|
||||
|
||||
b := !bp.options.whitelist && matched ||
|
||||
bp.options.whitelist && !matched
|
||||
if b {
|
||||
bp.options.logger.Debugf("bypass: %s, whitelist: %t", addr, bp.options.whitelist)
|
||||
} else {
|
||||
bp.options.logger.Debugf("pass: %s, whitelist: %t", addr, bp.options.whitelist)
|
||||
log := p.logger.WithFields(map[string]any{
|
||||
"sid": ctxvalue.SidFromContext(ctx),
|
||||
})
|
||||
log.Debugf("%s: %s, whitelist: %t", decision, addr, p.options.whitelist)
|
||||
|
||||
return decision == decisionBypass
|
||||
}
|
||||
|
||||
// decide returns the bypass decision for the given address. Network and
|
||||
// address predicates form one base match, then whitelist mode is applied once
|
||||
// to that combined result.
|
||||
func (p *localBypass) decide(network string, addr string) bypassDecision {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
matched := p.options.network == "" || p.options.network == network
|
||||
if p.patterns != nil {
|
||||
matched = matched && p.patterns.matchAny(addr)
|
||||
} else if p.options.network == "" {
|
||||
// A bypass with no predicates has no base match.
|
||||
matched = false
|
||||
}
|
||||
return b
|
||||
|
||||
// Whitelist is a top-level negation of the normal blacklist decision:
|
||||
// blacklist mode proxies a failed combined match, while whitelist mode
|
||||
// proxies a successful combined match.
|
||||
if matched == p.options.whitelist {
|
||||
return decisionProxy
|
||||
}
|
||||
return decisionBypass
|
||||
}
|
||||
|
||||
func (p *localBypass) IsWhitelist() bool {
|
||||
return p.options.whitelist
|
||||
}
|
||||
|
||||
func (bp *localBypass) parseLine(s string) string {
|
||||
// parseLine removes comments (starting with '#') and trims whitespace.
|
||||
func (p *localBypass) parseLine(s string) string {
|
||||
if n := strings.IndexByte(s, '#'); n >= 0 {
|
||||
s = s[:n]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func (bp *localBypass) matched(addr string) bool {
|
||||
bp.mu.RLock()
|
||||
defer bp.mu.RUnlock()
|
||||
|
||||
if bp.ipRangeMatcher.Match(addr) {
|
||||
return true
|
||||
func (p *localBypass) Close() error {
|
||||
p.cancelFunc()
|
||||
if p.options.fileLoader != nil {
|
||||
p.options.fileLoader.Close()
|
||||
}
|
||||
|
||||
if bp.addrMatcher.Match(addr) {
|
||||
return true
|
||||
if p.options.redisLoader != nil {
|
||||
p.options.redisLoader.Close()
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
if host == "" {
|
||||
host = addr
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil && bp.cidrMatcher.Match(host) {
|
||||
return true
|
||||
}
|
||||
|
||||
return bp.wildcardMatcher.Match(addr)
|
||||
}
|
||||
|
||||
func (bp *localBypass) Close() error {
|
||||
bp.cancelFunc()
|
||||
if bp.options.fileLoader != nil {
|
||||
bp.options.fileLoader.Close()
|
||||
}
|
||||
if bp.options.redisLoader != nil {
|
||||
bp.options.redisLoader.Close()
|
||||
if p.options.httpLoader != nil {
|
||||
p.options.httpLoader.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bypassGroup composes multiple Bypass instances into a single rule.
|
||||
// Whitelist rules use AND logic (all must match); blacklist rules use
|
||||
// OR logic (any match triggers bypass) and are only evaluated when
|
||||
// whitelist rules fail.
|
||||
type bypassGroup struct {
|
||||
bypasses []bypass.Bypass
|
||||
}
|
||||
|
||||
// BypassGroup creates a composite Bypass from multiple rules.
|
||||
// Whitelist rules use AND logic (all must match); blacklist rules use
|
||||
// OR logic (any match triggers bypass) and are only evaluated when
|
||||
// whitelist rules fail.
|
||||
func BypassGroup(bypasses ...bypass.Bypass) bypass.Bypass {
|
||||
return &bypassGroup{
|
||||
bypasses: bypasses,
|
||||
}
|
||||
}
|
||||
|
||||
// Contains evaluates all bypass rules in the group with the following logic:
|
||||
// - Whitelist rules: ALL must match (AND logic). If any whitelist rule
|
||||
// returns false, the combined whitelist result is false.
|
||||
// - Blacklist rules: only evaluated if the whitelist result is false.
|
||||
// ANY matching blacklist rule triggers a bypass (OR logic).
|
||||
func (p *bypassGroup) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
||||
var whitelist, blacklist []bool
|
||||
for _, bypass := range p.bypasses {
|
||||
result := bypass.Contains(ctx, network, addr, opts...)
|
||||
if bypass.IsWhitelist() {
|
||||
whitelist = append(whitelist, result)
|
||||
} else {
|
||||
blacklist = append(blacklist, result)
|
||||
}
|
||||
}
|
||||
status := false
|
||||
if len(whitelist) > 0 {
|
||||
if slices.Contains(whitelist, false) {
|
||||
status = false
|
||||
} else {
|
||||
status = true
|
||||
}
|
||||
}
|
||||
if !status && len(blacklist) > 0 {
|
||||
if slices.Contains(blacklist, true) {
|
||||
status = true
|
||||
} else {
|
||||
status = false
|
||||
}
|
||||
}
|
||||
return status
|
||||
return p.evaluate(ctx, network, addr, opts...) == decisionBypass
|
||||
}
|
||||
|
||||
// evaluate returns the bypass decision for the group by applying two-phase
|
||||
// evaluation: whitelist rules use AND logic (all must agree), then blacklist
|
||||
// rules use OR logic (any match wins), evaluated only if whitelist fails.
|
||||
func (p *bypassGroup) evaluate(ctx context.Context, network, addr string, opts ...bypass.Option) bypassDecision {
|
||||
// Phase 1: Whitelist AND — all must agree to bypass.
|
||||
hasWhitelist := false
|
||||
allWhitelistBypass := true
|
||||
for _, bp := range p.bypasses {
|
||||
if !bp.IsWhitelist() {
|
||||
continue
|
||||
}
|
||||
hasWhitelist = true
|
||||
if !bp.Contains(ctx, network, addr, opts...) {
|
||||
allWhitelistBypass = false
|
||||
break // short-circuit: one whitelist failure is enough
|
||||
}
|
||||
}
|
||||
if hasWhitelist && allWhitelistBypass {
|
||||
return decisionBypass
|
||||
}
|
||||
|
||||
// Phase 2: Blacklist OR — any match triggers bypass.
|
||||
for _, bp := range p.bypasses {
|
||||
if bp.IsWhitelist() {
|
||||
continue
|
||||
}
|
||||
if bp.Contains(ctx, network, addr, opts...) {
|
||||
return decisionBypass
|
||||
}
|
||||
}
|
||||
return decisionProxy
|
||||
}
|
||||
|
||||
// IsWhitelist reports whether the group acts as a pure whitelist: every
|
||||
// member is a whitelist rule. A mixed group is neither pure whitelist nor
|
||||
// pure blacklist, so it reports false.
|
||||
func (p *bypassGroup) IsWhitelist() bool {
|
||||
return false
|
||||
if len(p.bypasses) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, bp := range p.bypasses {
|
||||
if !bp.IsWhitelist() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -1,3 +1,6 @@
|
||||
// Package bypass implements plugin-based Bypass using gRPC and HTTP
|
||||
// transports. Plugins delegate bypass decisions to an external process
|
||||
// or service.
|
||||
package bypass
|
||||
|
||||
import (
|
||||
@@ -12,13 +15,18 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// grpcPlugin delegates bypass decisions to a remote gRPC service.
|
||||
// If the connection fails or the client is nil, all addresses bypass
|
||||
// the proxy (fail-open).
|
||||
type grpcPlugin struct {
|
||||
conn grpc.ClientConnInterface
|
||||
client proto.BypassClient
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
// NewGRPCPlugin creates a Bypass plugin based on gRPC.
|
||||
// NewGRPCPlugin creates a Bypass that delegates decisions to a gRPC
|
||||
// bypass service at addr. On connection failure, the plugin logs the
|
||||
// error and returns a fail-open instance (all addresses bypass).
|
||||
func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) bypass.Bypass {
|
||||
var options plugin.Options
|
||||
for _, opt := range opts {
|
||||
@@ -56,6 +64,7 @@ func (p *grpcPlugin) Contains(ctx context.Context, network, addr string, opts ..
|
||||
|
||||
r, err := p.client.Bypass(ctx,
|
||||
&proto.BypassRequest{
|
||||
Service: options.Service,
|
||||
Network: network,
|
||||
Addr: addr,
|
||||
Client: string(ctxvalue.ClientIDFromContext(ctx)),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package bypass
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/plugin/bypass/proto"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
corelogger.SetDefault(xlogger.Nop())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// helper to set up a real gRPC server on a random port, returning the client conn and server stop func.
|
||||
func newTestGRPCConn(t *testing.T, srv proto.BypassServer) (*grpc.ClientConn, func()) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
gsrv := grpc.NewServer()
|
||||
proto.RegisterBypassServer(gsrv, srv)
|
||||
go gsrv.Serve(lis)
|
||||
|
||||
conn, err := grpc.NewClient(lis.Addr().String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return conn, func() {
|
||||
conn.Close()
|
||||
gsrv.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_FailOpen_NilClient(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Contains_Success(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: true})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewBypassClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Contains_Deny(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: false})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewBypassClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.False(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Contains_WithService(t *testing.T) {
|
||||
srv := &testBypassServer{ok: true}
|
||||
conn, cleanup := newTestGRPCConn(t, srv)
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewBypassClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", bypass.WithService("myservice")))
|
||||
assert.Equal(t, "myservice", srv.lastService)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Contains_WithHostAndPath(t *testing.T) {
|
||||
srv := &testBypassServer{ok: true}
|
||||
conn, cleanup := newTestGRPCConn(t, srv)
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewBypassClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1",
|
||||
bypass.WithHostOption("example.com"),
|
||||
bypass.WithPathOption("/api/v1"),
|
||||
))
|
||||
assert.Equal(t, "example.com", srv.lastHost)
|
||||
assert.Equal(t, "/api/v1", srv.lastPath)
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Contains_NilClient(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
}
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Contains_ServerError(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &errorBypassServer{})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: proto.NewBypassClient(conn),
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
// Server returns an error; Contains should return true (fail-open)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_NilConn(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: nil,
|
||||
client: nil,
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) {
|
||||
p := &grpcPlugin{
|
||||
conn: &nonCloserConn{},
|
||||
client: nil,
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_Close_WithRealConn(t *testing.T) {
|
||||
conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: true})
|
||||
defer cleanup()
|
||||
|
||||
p := &grpcPlugin{
|
||||
conn: conn,
|
||||
client: nil,
|
||||
log: xlogger.Nop(),
|
||||
}
|
||||
assert.NoError(t, p.Close())
|
||||
}
|
||||
|
||||
func TestNewGRPCPlugin_RealConn(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
gsrv := grpc.NewServer()
|
||||
proto.RegisterBypassServer(gsrv, &testBypassServer{ok: true})
|
||||
go gsrv.Serve(lis)
|
||||
defer gsrv.Stop()
|
||||
|
||||
p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500))
|
||||
require.NotNil(t, p)
|
||||
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.NoError(t, p.(io.Closer).Close())
|
||||
}
|
||||
|
||||
func TestGRPCPlugin_IsWhitelist(t *testing.T) {
|
||||
p := &grpcPlugin{}
|
||||
assert.False(t, p.IsWhitelist())
|
||||
}
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
type testBypassServer struct {
|
||||
proto.UnimplementedBypassServer
|
||||
ok bool
|
||||
lastService string
|
||||
lastHost string
|
||||
lastPath string
|
||||
}
|
||||
|
||||
func (s *testBypassServer) Bypass(ctx context.Context, req *proto.BypassRequest) (*proto.BypassReply, error) {
|
||||
s.lastService = req.Service
|
||||
s.lastHost = req.Host
|
||||
s.lastPath = req.Path
|
||||
return &proto.BypassReply{Ok: s.ok}, nil
|
||||
}
|
||||
|
||||
type errorBypassServer struct {
|
||||
proto.UnimplementedBypassServer
|
||||
}
|
||||
|
||||
func (s *errorBypassServer) Bypass(ctx context.Context, req *proto.BypassRequest) (*proto.BypassReply, error) {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
type nonCloserConn struct{}
|
||||
|
||||
func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ grpc.ClientConnInterface = (*nonCloserConn)(nil)
|
||||
var _ io.Closer = (*grpcPlugin)(nil)
|
||||
+35
-8
@@ -12,7 +12,9 @@ import (
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
)
|
||||
|
||||
// httpPluginRequest is the JSON payload sent to a remote HTTP bypass service.
|
||||
type httpPluginRequest struct {
|
||||
Service string `json:"service"`
|
||||
Network string `json:"network"`
|
||||
Addr string `json:"addr"`
|
||||
Client string `json:"client"`
|
||||
@@ -20,10 +22,14 @@ type httpPluginRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// httpPluginResponse is the JSON response from a remote HTTP bypass service.
|
||||
type httpPluginResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// httpPlugin delegates bypass decisions to a remote HTTP service.
|
||||
// All error paths return true (fail-open) so that a down plugin
|
||||
// does not block traffic.
|
||||
type httpPlugin struct {
|
||||
url string
|
||||
client *http.Client
|
||||
@@ -31,7 +37,9 @@ type httpPlugin struct {
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
// NewHTTPPlugin creates an Bypass plugin based on HTTP.
|
||||
// NewHTTPPlugin creates a Bypass that delegates decisions to an HTTP
|
||||
// bypass service at url. The service should accept POST requests with
|
||||
// a JSON body and return {"ok": true/false}.
|
||||
func NewHTTPPlugin(name string, url string, opts ...plugin.Option) bypass.Bypass {
|
||||
var options plugin.Options
|
||||
for _, opt := range opts {
|
||||
@@ -49,9 +57,14 @@ func NewHTTPPlugin(name string, url string, opts ...plugin.Option) bypass.Bypass
|
||||
}
|
||||
}
|
||||
|
||||
func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) (ok bool) {
|
||||
func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
||||
if p.client == nil {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
log := p.log
|
||||
if log == nil {
|
||||
log = logger.Default()
|
||||
}
|
||||
|
||||
var options bypass.Options
|
||||
@@ -60,6 +73,7 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ..
|
||||
}
|
||||
|
||||
rb := httpPluginRequest{
|
||||
Service: options.Service,
|
||||
Network: network,
|
||||
Addr: addr,
|
||||
Client: string(ctxvalue.ClientIDFromContext(ctx)),
|
||||
@@ -68,12 +82,14 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ..
|
||||
}
|
||||
v, err := json.Marshal(&rb)
|
||||
if err != nil {
|
||||
return
|
||||
log.Error(err)
|
||||
return true
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(v))
|
||||
if err != nil {
|
||||
return
|
||||
log.Error(err)
|
||||
return true
|
||||
}
|
||||
|
||||
if p.header != nil {
|
||||
@@ -82,21 +98,32 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts ..
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
log.Error(err)
|
||||
return true
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
res := httpPluginResponse{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return
|
||||
log.Error(err)
|
||||
return true
|
||||
}
|
||||
return res.OK
|
||||
}
|
||||
|
||||
func (p *httpPlugin) Close() error {
|
||||
if p.client != nil {
|
||||
if tr := plugin.HTTPClientTransport(p.client); tr != nil {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *httpPlugin) IsWhitelist() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package bypass
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPPlugin(t *testing.T) {
|
||||
p := NewHTTPPlugin("test", "http://localhost:9999")
|
||||
require.NotNil(t, p)
|
||||
|
||||
hp, ok := p.(*httpPlugin)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "http://localhost:9999", hp.url)
|
||||
assert.NotNil(t, hp.client)
|
||||
assert.NotNil(t, hp.log)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_Deny(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.False(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_Non200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
// Non-200 returns true (fail-open)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_InvalidJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`not json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
// Invalid JSON returns true (fail-open)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_ConnectionRefused(t *testing.T) {
|
||||
p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0))
|
||||
// Connection refused returns true (fail-open)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_NilClient(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
url: "http://localhost",
|
||||
client: nil,
|
||||
}
|
||||
// Nil client returns true (fail-open)
|
||||
assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_WithService(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", bypass.WithService("myservice")))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_WithHostAndPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL)
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1",
|
||||
bypass.WithHostOption("example.com"),
|
||||
bypass.WithPathOption("/api/v1"),
|
||||
))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_WithCustomHeader(t *testing.T) {
|
||||
var receivedHeader string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeader = r.Header.Get("X-Custom")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{
|
||||
"X-Custom": []string{"test-value"},
|
||||
}))
|
||||
assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.Equal(t, "test-value", receivedHeader)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_NoHeader(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hp := &httpPlugin{
|
||||
url: srv.URL,
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
header: nil,
|
||||
}
|
||||
assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Contains_BadURL(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
url: "http://invalid hostname", // space causes NewRequest to fail
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
// Bad URL returns true (fail-open)
|
||||
assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Close_NilClient(t *testing.T) {
|
||||
hp := &httpPlugin{client: nil}
|
||||
err := hp.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_Close_WithClient(t *testing.T) {
|
||||
hp := &httpPlugin{
|
||||
client: plugin.NewHTTPClient(&plugin.Options{}),
|
||||
}
|
||||
err := hp.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHTTPPlugin_IsWhitelist(t *testing.T) {
|
||||
hp := &httpPlugin{}
|
||||
assert.False(t, hp.IsWhitelist())
|
||||
}
|
||||
|
||||
var _ io.Closer = (*httpPlugin)(nil)
|
||||
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
// Package cache provides in-memory implementations of the core cache.Cache
|
||||
// interface.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/logger"
|
||||
)
|
||||
|
||||
// EvictionPolicy selects which entry to remove when the cache is full.
|
||||
type EvictionPolicy string
|
||||
|
||||
const (
|
||||
// EvictOldest removes the entry inserted least recently (by insertion time).
|
||||
EvictOldest EvictionPolicy = "oldest"
|
||||
// EvictLRU removes the entry accessed least recently.
|
||||
EvictLRU EvictionPolicy = "lru"
|
||||
)
|
||||
|
||||
// Options configures a memory cache.
|
||||
type Options struct {
|
||||
// DefaultTTL is applied to entries stored without an explicit TTL.
|
||||
// A non-positive value means entries never expire by default.
|
||||
DefaultTTL time.Duration
|
||||
// MaxSize bounds the number of entries. 0 means unbounded.
|
||||
MaxSize int
|
||||
// MaxBytes bounds the total size of stored data in bytes. 0 means unbounded.
|
||||
MaxBytes int64
|
||||
// CleanupInterval sets the period for the background expiry sweep.
|
||||
// A non-positive value disables the background goroutine.
|
||||
CleanupInterval time.Duration
|
||||
// Eviction is the policy used when MaxSize or MaxBytes is exceeded.
|
||||
// Defaults to EvictOldest.
|
||||
Eviction EvictionPolicy
|
||||
// Logger is used for debug logging. Optional.
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
data []byte
|
||||
expiration time.Time // zero = never expires
|
||||
created time.Time
|
||||
lastAccess time.Time
|
||||
}
|
||||
|
||||
// memoryCache is a concurrency-safe in-memory cache.Cache implementation.
|
||||
type memoryCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*entry
|
||||
currentBytes int64
|
||||
|
||||
defaultTTL time.Duration
|
||||
maxSize int
|
||||
maxBytes int64
|
||||
eviction EvictionPolicy
|
||||
logger logger.Logger
|
||||
|
||||
stopCleanup chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewMemoryCache creates an in-memory cache. If opts.CleanupInterval is
|
||||
// positive, a background goroutine periodically removes expired entries; it is
|
||||
// stopped by Close.
|
||||
func NewMemoryCache(opts Options) cache.Cache {
|
||||
eviction := opts.Eviction
|
||||
if eviction != EvictLRU {
|
||||
eviction = EvictOldest
|
||||
}
|
||||
|
||||
c := &memoryCache{
|
||||
entries: make(map[string]*entry),
|
||||
defaultTTL: opts.DefaultTTL,
|
||||
maxSize: opts.MaxSize,
|
||||
maxBytes: opts.MaxBytes,
|
||||
eviction: eviction,
|
||||
logger: opts.Logger,
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
|
||||
if opts.CleanupInterval > 0 {
|
||||
go c.cleanupLoop(opts.CleanupInterval)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns the entry for key. An expired-but-present entry is returned with
|
||||
// cache.ErrNotFound reserved for absent keys, so callers can serve stale.
|
||||
func (c *memoryCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
|
||||
c.mu.Lock()
|
||||
e, ok := c.entries[key]
|
||||
if ok {
|
||||
e.lastAccess = time.Now()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return nil, cache.ErrNotFound
|
||||
}
|
||||
|
||||
// Return a copy so callers can't mutate the stored buffer.
|
||||
data := make([]byte, len(e.data))
|
||||
copy(data, e.data)
|
||||
return &cache.Entry{Data: data, Expiration: e.expiration}, nil
|
||||
}
|
||||
|
||||
// Set stores data under key. A non-positive TTL uses the cache default; a
|
||||
// non-positive default means the entry never expires.
|
||||
func (c *memoryCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
|
||||
var so cache.SetOptions
|
||||
for _, opt := range opts {
|
||||
opt(&so)
|
||||
}
|
||||
|
||||
ttl := so.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = c.defaultTTL
|
||||
}
|
||||
var expiration time.Time
|
||||
if ttl > 0 {
|
||||
expiration = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
cp := make([]byte, len(data))
|
||||
copy(cp, data)
|
||||
|
||||
now := time.Now()
|
||||
e := &entry{
|
||||
data: cp,
|
||||
expiration: expiration,
|
||||
created: now,
|
||||
lastAccess: now,
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Replace: account for the size delta of an existing key.
|
||||
if old, ok := c.entries[key]; ok {
|
||||
c.currentBytes -= int64(len(old.data))
|
||||
}
|
||||
c.entries[key] = e
|
||||
c.currentBytes += int64(len(cp))
|
||||
|
||||
c.evictLocked()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes key from the cache.
|
||||
func (c *memoryCache) Delete(ctx context.Context, key string) error {
|
||||
c.mu.Lock()
|
||||
if e, ok := c.entries[key]; ok {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the background cleanup goroutine. Safe to call more than once.
|
||||
// Implements io.Closer so the registry stops the goroutine on hot reload.
|
||||
func (c *memoryCache) Close() error {
|
||||
c.stopOnce.Do(func() { close(c.stopCleanup) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memoryCache) cleanupLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.stopCleanup:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, e := range c.entries {
|
||||
if !e.expiration.IsZero() && now.After(e.expiration) {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictLocked enforces the size/byte bounds. It first drops expired entries,
|
||||
// then evicts by policy until within bounds. Caller must hold c.mu.
|
||||
func (c *memoryCache) evictLocked() {
|
||||
if !c.overCapacityLocked() {
|
||||
return
|
||||
}
|
||||
|
||||
// First pass: drop expired entries — they're free wins.
|
||||
now := time.Now()
|
||||
for k, e := range c.entries {
|
||||
if !e.expiration.IsZero() && now.After(e.expiration) {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: evict by policy until within bounds.
|
||||
for c.overCapacityLocked() && len(c.entries) > 0 {
|
||||
var victim string
|
||||
var victimTS time.Time
|
||||
first := true
|
||||
for k, e := range c.entries {
|
||||
ts := e.created
|
||||
if c.eviction == EvictLRU {
|
||||
ts = e.lastAccess
|
||||
}
|
||||
if first || ts.Before(victimTS) {
|
||||
victim = k
|
||||
victimTS = ts
|
||||
first = false
|
||||
}
|
||||
}
|
||||
if e, ok := c.entries[victim]; ok {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *memoryCache) overCapacityLocked() bool {
|
||||
if c.maxSize > 0 && len(c.entries) > c.maxSize {
|
||||
return true
|
||||
}
|
||||
if c.maxBytes > 0 && c.currentBytes > c.maxBytes {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Vendored
+205
@@ -0,0 +1,205 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
)
|
||||
|
||||
func TestSetGet(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
|
||||
ctx := context.Background()
|
||||
|
||||
err := c.Set(ctx, "k", []byte("hello"))
|
||||
if err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
e, err := c.Get(ctx, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if string(e.Data) != "hello" {
|
||||
t.Fatalf("data = %q, want %q", e.Data, "hello")
|
||||
}
|
||||
if e.Expired() {
|
||||
t.Fatal("fresh entry reported expired")
|
||||
}
|
||||
if e.Expiration.IsZero() {
|
||||
t.Fatal("expiration is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
c := NewMemoryCache(Options{})
|
||||
_, err := c.Get(context.Background(), "nosuch")
|
||||
if err != cache.ErrNotFound {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpiredStillReturned(t *testing.T) {
|
||||
c := NewMemoryCache(Options{})
|
||||
ctx := context.Background()
|
||||
if err := c.Set(ctx, "k", []byte("data"), cache.WithTTL(1*time.Nanosecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
e, err := c.Get(ctx, "k")
|
||||
if err != nil {
|
||||
t.Fatal("expired entry should still be returned for serve-stale")
|
||||
}
|
||||
if !e.Expired() {
|
||||
t.Fatal("expected expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTTLOverride(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
|
||||
ctx := context.Background()
|
||||
if err := c.Set(ctx, "k", []byte("x"), cache.WithTTL(50*time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
e, _ := c.Get(ctx, "k")
|
||||
if !e.Expired() {
|
||||
t.Fatal("WithTTL should override default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "k", []byte("x"))
|
||||
if err := c.Delete(ctx, "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Get(ctx, "k"); err != cache.ErrNotFound {
|
||||
t.Fatalf("err = %v, want ErrNotFound after delete", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAbsentKey(t *testing.T) {
|
||||
c := NewMemoryCache(Options{})
|
||||
if err := c.Delete(context.Background(), "nosuch"); err != nil {
|
||||
t.Fatalf("Delete absent key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeverExpires(t *testing.T) {
|
||||
c := NewMemoryCache(Options{}) // no default TTL
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "k", []byte("data"))
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
e, _ := c.Get(ctx, "k")
|
||||
if e.Expired() {
|
||||
t.Fatal("entry with no TTL should never expire")
|
||||
}
|
||||
if e.TTL() != -1 {
|
||||
t.Fatalf("TTL = %v, want -1", e.TTL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxSizeEviction(t *testing.T) {
|
||||
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictOldest})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "a", []byte("1"))
|
||||
c.Set(ctx, "b", []byte("2"))
|
||||
c.Set(ctx, "c", []byte("3")) // evicts oldest (a)
|
||||
|
||||
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
|
||||
t.Fatal("a should have been evicted")
|
||||
}
|
||||
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
|
||||
t.Fatal("b should remain")
|
||||
}
|
||||
if _, err := c.Get(ctx, "c"); err == cache.ErrNotFound {
|
||||
t.Fatal("c should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxSizeLRU(t *testing.T) {
|
||||
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictLRU})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "a", []byte("1"))
|
||||
c.Set(ctx, "b", []byte("2"))
|
||||
c.Get(ctx, "a") // touch a
|
||||
c.Set(ctx, "c", []byte("3")) // LRU-evicts b (a's lastAccess is newer)
|
||||
|
||||
if _, err := c.Get(ctx, "b"); err != cache.ErrNotFound {
|
||||
t.Fatal("b should have been evicted (LRU)")
|
||||
}
|
||||
if _, err := c.Get(ctx, "a"); err == cache.ErrNotFound {
|
||||
t.Fatal("a should remain (touched)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBytesEviction(t *testing.T) {
|
||||
c := NewMemoryCache(Options{MaxBytes: 4})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "a", []byte{1, 2}) // 2 bytes, total=2
|
||||
c.Set(ctx, "b", []byte{3, 4}) // 2 bytes, total=4
|
||||
c.Set(ctx, "c", []byte{5}) // 1 byte, total=5 > 4 → evicts oldest (a)
|
||||
|
||||
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
|
||||
t.Fatal("a should have been evicted (maxBytes)")
|
||||
}
|
||||
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
|
||||
t.Fatal("b should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
c := NewMemoryCache(Options{CleanupInterval: 10 * time.Millisecond})
|
||||
closer := c.(io.Closer)
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Fatal("second Close should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseStopsBackgroundScan(t *testing.T) {
|
||||
c := NewMemoryCache(Options{
|
||||
DefaultTTL: 5 * time.Millisecond,
|
||||
CleanupInterval: 10 * time.Millisecond,
|
||||
})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "k", []byte("data"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
e, _ := c.Get(ctx, "k")
|
||||
// Without Close, background scanner cleared expired entries. With Close
|
||||
// before sleep, the entry stays. We test that Close means the goroutine
|
||||
// stops and does NOT panic another background prune. Just exercising the
|
||||
// code path.
|
||||
if e.Expired() {
|
||||
t.Log("expired (expected if scanner ran)")
|
||||
}
|
||||
c.(io.Closer).Close()
|
||||
time.Sleep(30 * time.Millisecond) // goroutine should have stopped
|
||||
// No panic = pass
|
||||
}
|
||||
|
||||
func TestDataCopied(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
|
||||
ctx := context.Background()
|
||||
data := []byte("mutable")
|
||||
c.Set(ctx, "k", data)
|
||||
data[0] = 'X'
|
||||
|
||||
e, _ := c.Get(ctx, "k")
|
||||
if string(e.Data) != "mutable" {
|
||||
t.Fatal("Get returns internal data, not a copy")
|
||||
}
|
||||
// Mutate Get's returned slice — original should be unchanged
|
||||
e.Data[0] = 'Y'
|
||||
e2, _ := c.Get(ctx, "k")
|
||||
if string(e2.Data) != "mutable" {
|
||||
t.Fatal("Get returns shared slice")
|
||||
}
|
||||
}
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
// defaultKeyPrefix is prepended to cache keys when RedisOptions.KeyPrefix is empty.
|
||||
const defaultKeyPrefix = "gost:cache:"
|
||||
|
||||
// RedisOptions configures a Redis cache.
|
||||
type RedisOptions struct {
|
||||
// Addr is the Redis server address (host:port).
|
||||
Addr string
|
||||
// DB is the Redis database number.
|
||||
DB int
|
||||
// Username for Redis authentication. Optional.
|
||||
Username string
|
||||
// Password for Redis authentication. Optional.
|
||||
Password string
|
||||
// DefaultTTL is applied to entries stored without an explicit TTL.
|
||||
// A non-positive value means entries never expire by default.
|
||||
DefaultTTL time.Duration
|
||||
// KeyPrefix is prepended to all cache keys. Defaults to "gost:cache:".
|
||||
KeyPrefix string
|
||||
// Logger is used for debug logging. Optional.
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// redisCache is a cache.Cache backed by a Redis server.
|
||||
//
|
||||
// Redis expires keys natively, so an expired entry is a true miss here — the
|
||||
// interface's serve-stale contract (returning expired entries from Get) does
|
||||
// not apply to this backend.
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
defaultTTL time.Duration
|
||||
keyPrefix string
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewRedisCache creates a Redis-backed cache. Close releases the underlying
|
||||
// client connections.
|
||||
func NewRedisCache(opts RedisOptions) cache.Cache {
|
||||
keyPrefix := opts.KeyPrefix
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = defaultKeyPrefix
|
||||
}
|
||||
|
||||
return &redisCache{
|
||||
client: redis.NewClient(&redis.Options{
|
||||
Addr: opts.Addr,
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
}),
|
||||
defaultTTL: opts.DefaultTTL,
|
||||
keyPrefix: keyPrefix,
|
||||
logger: opts.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the entry for key, or cache.ErrNotFound if the key is absent
|
||||
// (including keys Redis has already expired).
|
||||
func (c *redisCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
|
||||
k := c.keyPrefix + key
|
||||
|
||||
pipe := c.client.Pipeline()
|
||||
getCmd := pipe.Get(ctx, k)
|
||||
ttlCmd := pipe.PTTL(ctx, k)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
if err == redis.Nil {
|
||||
return nil, cache.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := &cache.Entry{
|
||||
Data: []byte(getCmd.Val()),
|
||||
}
|
||||
if ttl := ttlCmd.Val(); ttl > 0 {
|
||||
entry.Expiration = time.Now().Add(ttl)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Set stores data under key. A non-positive TTL (after applying the default)
|
||||
// stores the entry without expiration.
|
||||
func (c *redisCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
|
||||
var options cache.SetOptions
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
ttl := options.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = c.defaultTTL
|
||||
}
|
||||
if ttl < 0 {
|
||||
ttl = 0 // redis: 0 = no expiration
|
||||
}
|
||||
|
||||
return c.client.Set(ctx, c.keyPrefix+key, data, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete removes key from the cache. Deleting an absent key is a no-op.
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
return c.client.Del(ctx, c.keyPrefix+key).Err()
|
||||
}
|
||||
|
||||
// Close releases the Redis client. Implements io.Closer so the registry
|
||||
// closes the client on hot reload.
|
||||
func (c *redisCache) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
+235
-9
@@ -1,13 +1,45 @@
|
||||
// Package chain implements the core routing infrastructure for GOST.
|
||||
//
|
||||
// It provides three key abstractions:
|
||||
//
|
||||
// - Router: top-level entry point that resolves addresses, selects routes
|
||||
// via a Chainer, retries on failure, and records telemetry.
|
||||
//
|
||||
// - Chain: a named sequence of proxy hops (nodes). Each hop selects a node
|
||||
// from its group, and the resulting Route carries traffic through every
|
||||
// selected node in order.
|
||||
//
|
||||
// - Transport: bundles a dialer and connector for a single chain node. It
|
||||
// handles Dial, Handshake, Connect, and Bind — the four steps needed to
|
||||
// move traffic through a proxy hop.
|
||||
//
|
||||
// # Route traversal
|
||||
//
|
||||
// For a chain of N nodes, the first node is reached via Dial → Handshake,
|
||||
// and each subsequent node via Connect → Handshake through the previous
|
||||
// connection. On failure, connections are cleaned up and nodes are marked
|
||||
// so selectors can deprioritize them.
|
||||
//
|
||||
// # Multiplexing
|
||||
//
|
||||
// When a node's transport supports multiplexing, Chain splits the route at
|
||||
// that point: nodes before the multiplex-capable node form a sub-route that is
|
||||
// copied into the transport, establishing a reusable tunnel for subsequent
|
||||
// connections.
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/core/selector"
|
||||
"github.com/go-gost/x/internal/probe"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -45,6 +77,7 @@ type Chain struct {
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewChain creates a new Chain with the given name and options.
|
||||
func NewChain(name string, opts ...ChainOption) *Chain {
|
||||
var options ChainOptions
|
||||
for _, opt := range opts {
|
||||
@@ -61,11 +94,14 @@ func NewChain(name string, opts ...ChainOption) *Chain {
|
||||
}
|
||||
}
|
||||
|
||||
// AddHop appends a hop to the chain. Hops are traversed in order during
|
||||
// route construction.
|
||||
func (c *Chain) AddHop(hop hop.Hop) {
|
||||
c.hops = append(c.hops, hop)
|
||||
}
|
||||
|
||||
// Metadata implements metadata.Metadatable interface.
|
||||
// Metadata returns the chain's metadata.
|
||||
// Implements metadata.Metadatable interface.
|
||||
func (c *Chain) Metadata() metadata.Metadata {
|
||||
return c.metadata
|
||||
}
|
||||
@@ -79,6 +115,20 @@ func (c *Chain) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
// Close stops all probe goroutines by cascading to each hop's Close,
|
||||
// which in turn cascades to node.Close() for every node.
|
||||
func (c *Chain) Close() error {
|
||||
for _, h := range c.hops {
|
||||
if closer, ok := h.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Route builds a route by selecting one node from each hop. If a node
|
||||
// supports multiplexing, the route is split — nodes before it form a
|
||||
// sub-route that is copied into the transport for reuse.
|
||||
func (c *Chain) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
|
||||
if c == nil || len(c.hops) == 0 {
|
||||
return nil
|
||||
@@ -113,12 +163,20 @@ func (c *Chain) Route(ctx context.Context, network, address string, opts ...chai
|
||||
}
|
||||
|
||||
type chainGroup struct {
|
||||
chains []chain.Chainer
|
||||
selector selector.Selector[chain.Chainer]
|
||||
entries []*ChainEntry
|
||||
selector selector.Selector[chain.Chainer]
|
||||
logger logger.Logger
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
// NewChainGroup creates a chain group that selects one Chainer from the
|
||||
// given list using the configured selector (round-robin by default).
|
||||
func NewChainGroup(chains ...chain.Chainer) *chainGroup {
|
||||
return &chainGroup{chains: chains}
|
||||
entries := make([]*ChainEntry, len(chains))
|
||||
for i, c := range chains {
|
||||
entries[i] = NewChainEntry(c, nil, nil)
|
||||
}
|
||||
return &chainGroup{entries: entries}
|
||||
}
|
||||
|
||||
func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGroup {
|
||||
@@ -126,17 +184,185 @@ func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGrou
|
||||
return p
|
||||
}
|
||||
|
||||
// WithGroupEntries sets the chain entries for the group (replaces defaults)
|
||||
// and starts probe goroutines for entries that have probe configurations.
|
||||
func (p *chainGroup) WithGroupEntries(entries ...*ChainEntry) *chainGroup {
|
||||
// Cancel any previous probe goroutines before replacing entries.
|
||||
if p.cancelFunc != nil {
|
||||
p.cancelFunc()
|
||||
}
|
||||
p.entries = entries
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.cancelFunc = cancel
|
||||
for _, e := range entries {
|
||||
if e.probe != nil {
|
||||
go p.runEntryProbe(ctx, e)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// WithGroupLogger sets the logger for the group.
|
||||
func (p *chainGroup) WithGroupLogger(log logger.Logger) *chainGroup {
|
||||
p.logger = log
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *chainGroup) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
|
||||
if chain := p.next(ctx); chain != nil {
|
||||
return chain.Route(ctx, network, address, opts...)
|
||||
if c := p.selectChain(ctx, network, address, opts...); c != nil {
|
||||
return c.Route(ctx, network, address, opts...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *chainGroup) next(ctx context.Context) chain.Chainer {
|
||||
if p == nil || len(p.chains) == 0 {
|
||||
func (p *chainGroup) selectChain(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Chainer {
|
||||
if p == nil || len(p.entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.selector.Select(ctx, p.chains...)
|
||||
var options chain.RouteOptions
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
// When no entry has a matcher, the eligible pool equals all entries; the
|
||||
// selector must still run (FailFilter excludes probe-failed chains), so
|
||||
// skip the per-call allocation and pass entries directly.
|
||||
anyMatcher := false
|
||||
for _, e := range p.entries {
|
||||
if e.matcher != nil {
|
||||
anyMatcher = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyMatcher {
|
||||
eligible := make([]chain.Chainer, len(p.entries))
|
||||
for i, e := range p.entries {
|
||||
eligible[i] = e
|
||||
}
|
||||
return p.selectFrom(ctx, eligible...)
|
||||
}
|
||||
|
||||
// Stage 1: matcher filter — build eligible pool.
|
||||
req := routing.Request{
|
||||
Network: network,
|
||||
Host: options.Host,
|
||||
}
|
||||
|
||||
eligible := make([]chain.Chainer, 0, len(p.entries))
|
||||
for _, e := range p.entries {
|
||||
if e.matcher != nil {
|
||||
if !e.matcher.Match(&req) {
|
||||
continue
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Debugf("chain entry matched request %s %s", req.Network, req.Host)
|
||||
}
|
||||
}
|
||||
eligible = append(eligible, e) // *ChainEntry as chain.Chainer, FailFilter reads e.Marker()
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stage 2: group selector — FailFilter + strategy.
|
||||
return p.selectFrom(ctx, eligible...)
|
||||
}
|
||||
|
||||
func (p *chainGroup) selectFrom(ctx context.Context, eligible ...chain.Chainer) chain.Chainer {
|
||||
if p.selector != nil {
|
||||
return p.selector.Select(ctx, eligible...)
|
||||
}
|
||||
return eligible[0]
|
||||
}
|
||||
|
||||
// Close stops all probe goroutines.
|
||||
func (p *chainGroup) Close() error {
|
||||
if p.cancelFunc != nil {
|
||||
p.cancelFunc()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runEntryProbe periodically probes a chain and marks
|
||||
// the chain entry (not the chain's nodes) on failure.
|
||||
func (p *chainGroup) runEntryProbe(ctx context.Context, e *ChainEntry) {
|
||||
cfg := e.probe
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
p.probeEntry(ctx, e, cfg) // first probe immediately
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
p.probeEntry(ctx, e, cfg)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *chainGroup) probeEntry(ctx context.Context, e *ChainEntry, cfg *chain.ProbeConfig) {
|
||||
// cmd probe runs a shell command — no chain routing needed.
|
||||
if cfg.Type == chain.ProbeTypeCmd {
|
||||
probe.RunCmdProbe(cfg, e.marker, p.logger)
|
||||
return
|
||||
}
|
||||
|
||||
// TCP/HTTP probe: route through the chain to probe target.
|
||||
addr := cfg.Addr
|
||||
if addr == "" {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("chain entry probe: no probe address configured")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Route through the probe lifecycle context so cancellation (reload/close)
|
||||
// aborts an in-flight probe instead of blocking forever on a stuck route.
|
||||
route := e.chainer.Route(ctx, "tcp", addr)
|
||||
if route == nil {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("chain entry probe: no route available")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := route.Dial(probeCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debugf("chain entry probe dial %s failed: %v", addr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if cfg.Type == chain.ProbeTypeHTTP {
|
||||
if err := probe.NewHTTPProber(cfg).Probe(conn); err != nil {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debugf("chain entry probe http %s failed: %v", addr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
e.marker.Reset()
|
||||
}
|
||||
|
||||
+1841
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/core/selector"
|
||||
)
|
||||
|
||||
// ChainEntry wraps a chain.Chainer with an optional matcher and an independent
|
||||
// failure marker for chain-level probe tracking. The entry's marker is
|
||||
// separate from any node-level markers inside the chain so that probe
|
||||
// Reset() cannot undo a node-level failCodes Mark().
|
||||
type ChainEntry struct {
|
||||
chainer chain.Chainer
|
||||
matcher routing.Matcher
|
||||
probe *chain.ProbeConfig
|
||||
marker selector.Marker
|
||||
}
|
||||
|
||||
// NewChainEntry creates a ChainEntry. matcher may be nil (unconditional
|
||||
// catch-all). probe may be nil (no health check for this entry).
|
||||
func NewChainEntry(c chain.Chainer, m routing.Matcher, probe *chain.ProbeConfig) *ChainEntry {
|
||||
return &ChainEntry{
|
||||
chainer: c,
|
||||
matcher: m,
|
||||
probe: probe,
|
||||
marker: selector.NewFailMarker(),
|
||||
}
|
||||
}
|
||||
|
||||
// Route implements chain.Chainer so the entry can be passed directly to the
|
||||
// group selector. The selector's FailFilter reads Marker() from the entry,
|
||||
// not from the underlying chain — this keeps probe-driven marking independent
|
||||
// of any node-level markers.
|
||||
func (e *ChainEntry) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
|
||||
return e.chainer.Route(ctx, network, address, opts...)
|
||||
}
|
||||
|
||||
// Marker implements selector.Markable.
|
||||
func (e *ChainEntry) Marker() selector.Marker {
|
||||
return e.marker
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/internal/probe"
|
||||
)
|
||||
|
||||
// StartNodeProbe starts a background goroutine that periodically probes the
|
||||
// node's transport. On success the node's marker is reset; on failure the
|
||||
// marker is incremented. cfg may be nil (no-op).
|
||||
func StartNodeProbe(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.Addr == "" && cfg.Type != chain.ProbeTypeCmd {
|
||||
return
|
||||
}
|
||||
if cfg.Type == chain.ProbeTypeCmd && cfg.Command == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
node.SetProbeCancel(cancel)
|
||||
go runNodeProbe(ctx, node, cfg, log)
|
||||
}
|
||||
|
||||
func runNodeProbe(ctx context.Context, node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
probeNode(node, cfg, log) // first probe immediately
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
probeNode(node, cfg, log)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func probeNode(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
|
||||
// cmd probe runs a shell command directly — no transport connection needed.
|
||||
if cfg.Type == chain.ProbeTypeCmd {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
start := time.Now()
|
||||
err := (&probe.CmdProber{Command: cfg.Command, Timeout: timeout}).Probe()
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
recordProbe(node, false, latency, err.Error(), log)
|
||||
} else {
|
||||
recordProbe(node, true, latency, "", log)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
tr := node.Options().Transport
|
||||
conn, err := tr.Dial(ctx, node.Addr)
|
||||
if err != nil {
|
||||
recordProbe(node, false, time.Since(start), err.Error(), log)
|
||||
return
|
||||
}
|
||||
|
||||
if hc, err2 := tr.Handshake(ctx, conn); err2 == nil {
|
||||
conn = hc
|
||||
} else {
|
||||
conn.Close()
|
||||
recordProbe(node, false, time.Since(start), err2.Error(), log)
|
||||
return
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
if cfg.Type == chain.ProbeTypeHTTP {
|
||||
err = probe.NewHTTPProber(cfg).Probe(conn)
|
||||
conn.Close()
|
||||
if err != nil {
|
||||
recordProbe(node, false, latency, err.Error(), log)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
recordProbe(node, true, latency, "", log)
|
||||
}
|
||||
|
||||
func recordProbe(node *chain.Node, success bool, latency time.Duration, errStr string, log logger.Logger) {
|
||||
result := &chain.ProbeResult{
|
||||
Success: success,
|
||||
Latency: latency,
|
||||
Error: errStr,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
node.SetProbeResult(result)
|
||||
|
||||
if success {
|
||||
node.Marker().Reset()
|
||||
} else {
|
||||
node.Marker().Mark()
|
||||
}
|
||||
|
||||
log.Debugf("node probe %s: success=%v latency=%v err=%s", node.Name, success, latency, errStr)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// testTransport implements chain.Transporter with real TCP dials.
|
||||
type testTransport struct {
|
||||
dialer *net.Dialer
|
||||
options chain.TransportOptions
|
||||
}
|
||||
|
||||
func (t *testTransport) Dial(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return t.dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
|
||||
func (t *testTransport) Handshake(ctx context.Context, conn net.Conn) (net.Conn, error) {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (t *testTransport) Connect(ctx context.Context, conn net.Conn, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (t *testTransport) Bind(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (t *testTransport) Multiplex() bool { return false }
|
||||
|
||||
func (t *testTransport) Options() *chain.TransportOptions { return &t.options }
|
||||
|
||||
func (t *testTransport) Copy() chain.Transporter { return t }
|
||||
|
||||
func TestNodeProbeTCP(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
// accept one connection, then close immediately (TCP probe only needs dial+handshake)
|
||||
go func() {
|
||||
for {
|
||||
conn, _ := l.Accept()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
|
||||
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
|
||||
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeTCP,
|
||||
Addr: addr,
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
|
||||
// Wait for first probe
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr := node.ProbeResult()
|
||||
if pr == nil || !pr.Success {
|
||||
t.Fatalf("expected successful probe, got %+v", pr)
|
||||
}
|
||||
|
||||
// Close listener → next probe fails
|
||||
l.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr = node.ProbeResult()
|
||||
if pr == nil || pr.Success {
|
||||
t.Fatalf("expected failed probe after listener close, got %+v", pr)
|
||||
}
|
||||
if node.Marker().Count() == 0 {
|
||||
t.Error("expected marker count > 0 after failure")
|
||||
}
|
||||
|
||||
node.Close()
|
||||
}
|
||||
|
||||
func TestNodeProbeClose(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, _ := l.Accept()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
|
||||
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
|
||||
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeTCP,
|
||||
Addr: addr,
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Snapshot result before close
|
||||
lastSuccess := node.ProbeResult().Success
|
||||
if !lastSuccess {
|
||||
t.Fatal("expected initial success before close")
|
||||
}
|
||||
|
||||
node.Close()
|
||||
|
||||
// Capture marker count right after close
|
||||
countAfterClose := node.Marker().Count()
|
||||
|
||||
// Wait longer than probe interval — no more updates after close
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Marker count should not increase after close (no more probes running)
|
||||
if node.Marker().Count() != countAfterClose {
|
||||
t.Errorf("marker changed after close: was %d, now %d", countAfterClose, node.Marker().Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeProbeDisabled(t *testing.T) {
|
||||
node := chain.NewNode("no-probe", "addr")
|
||||
// nil config → no-op
|
||||
StartNodeProbe(node, nil, xlogger.Nop())
|
||||
if pr := node.ProbeResult(); pr != nil {
|
||||
t.Error("expected nil probe result for disabled probe")
|
||||
}
|
||||
// empty addr → no-op
|
||||
StartNodeProbe(node, &chain.ProbeConfig{}, xlogger.Nop())
|
||||
if pr := node.ProbeResult(); pr != nil {
|
||||
t.Error("expected nil probe result for empty addr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeProbeCmd(t *testing.T) {
|
||||
// Cmd probe doesn't need a transport — StartNodeProbe skips the addr check.
|
||||
node := chain.NewNode("cmd-test", "")
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "true",
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr := node.ProbeResult()
|
||||
if pr == nil || !pr.Success {
|
||||
t.Fatalf("expected cmd probe success, got %+v", pr)
|
||||
}
|
||||
if node.Marker().Count() != 0 {
|
||||
t.Errorf("expected marker count 0 after success, got %d", node.Marker().Count())
|
||||
}
|
||||
|
||||
// Switch to failing command
|
||||
cfg2 := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "false",
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
node.Close() // stop old probe
|
||||
node2 := chain.NewNode("cmd-test-2", "")
|
||||
StartNodeProbe(node2, cfg2, xlogger.Nop())
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr2 := node2.ProbeResult()
|
||||
if pr2 == nil || pr2.Success {
|
||||
t.Fatalf("expected cmd probe failure for exit 1, got %+v", pr2)
|
||||
}
|
||||
if node2.Marker().Count() == 0 {
|
||||
t.Error("expected marker count > 0 after cmd failure")
|
||||
}
|
||||
|
||||
node2.Close()
|
||||
}
|
||||
|
||||
func TestNodeProbeCmdDisabled(t *testing.T) {
|
||||
// cmd probe without command → no-op
|
||||
node := chain.NewNode("cmd-disabled", "")
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "",
|
||||
}
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
if pr := node.ProbeResult(); pr != nil {
|
||||
t.Error("expected nil probe result for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeProbeHTTP(t *testing.T) {
|
||||
// Start a small HTTP server
|
||||
mux := &httpTestMux{}
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go mux.serve(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
|
||||
node := chain.NewNode("test-http", addr, chain.TransportNodeOption(tr))
|
||||
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeHTTP,
|
||||
Addr: addr,
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
HTTPPath: "/health",
|
||||
ExpectedStatus: 200,
|
||||
}
|
||||
|
||||
// Case 1: server returns 200
|
||||
mux.status.Store(200)
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr := node.ProbeResult()
|
||||
if pr == nil || !pr.Success {
|
||||
t.Fatalf("expected HTTP 200 probe success, got %+v", pr)
|
||||
}
|
||||
|
||||
// Case 2: server returns 500
|
||||
mux.status.Store(500)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr = node.ProbeResult()
|
||||
if pr == nil || pr.Success {
|
||||
t.Fatalf("expected HTTP 500 probe failure, got %+v", pr)
|
||||
}
|
||||
|
||||
node.Close()
|
||||
}
|
||||
|
||||
// httpTestMux is a minimal HTTP server that returns a configurable status.
|
||||
type httpTestMux struct {
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
func (m *httpTestMux) serve(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := conn.Read(buf)
|
||||
// Parse just enough to construct a response
|
||||
body := "HTTP/1.1 000 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
switch m.status.Load() {
|
||||
case 200:
|
||||
body = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case 500:
|
||||
body = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
}
|
||||
_ = n
|
||||
conn.Write([]byte(body))
|
||||
}
|
||||
+25
-3
@@ -19,14 +19,18 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEmptyRoute is returned when the configured chain produces a route
|
||||
// with no nodes.
|
||||
ErrEmptyRoute = errors.New("empty route")
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultRoute is a fallback route that dials the target directly
|
||||
// without going through any proxy nodes.
|
||||
DefaultRoute chain.Route = &defaultRoute{}
|
||||
)
|
||||
|
||||
// defaultRoute is a Route without nodes.
|
||||
// defaultRoute dials the target directly without any proxy nodes.
|
||||
type defaultRoute struct{}
|
||||
|
||||
func (*defaultRoute) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (net.Conn, error) {
|
||||
@@ -38,7 +42,7 @@ func (*defaultRoute) Dial(ctx context.Context, network, address string, opts ...
|
||||
netd := dialer.Dialer{
|
||||
Interface: options.Interface,
|
||||
Netns: options.Netns,
|
||||
Logger: options.Logger,
|
||||
Log: options.Logger,
|
||||
}
|
||||
if options.SockOpts != nil {
|
||||
netd.Mark = options.SockOpts.Mark
|
||||
@@ -82,6 +86,12 @@ func (*defaultRoute) Bind(ctx context.Context, network, address string, opts ...
|
||||
Logger: logger,
|
||||
})
|
||||
return ln, err
|
||||
case "unix":
|
||||
addr, err := net.ResolveUnixAddr(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.ListenUnix(network, addr)
|
||||
default:
|
||||
err := fmt.Errorf("network %s unsupported", network)
|
||||
return nil, err
|
||||
@@ -98,6 +108,8 @@ type RouteOptions struct {
|
||||
|
||||
type RouteOption func(*RouteOptions)
|
||||
|
||||
// ChainRouteOption sets the parent chain on a route, enabling error
|
||||
// tracking and metrics for the chain.
|
||||
func ChainRouteOption(c chain.Chainer) RouteOption {
|
||||
return func(o *RouteOptions) {
|
||||
o.Chain = c
|
||||
@@ -109,6 +121,7 @@ type chainRoute struct {
|
||||
options RouteOptions
|
||||
}
|
||||
|
||||
// NewRoute creates a new route with the given options.
|
||||
func NewRoute(opts ...RouteOption) *chainRoute {
|
||||
var options RouteOptions
|
||||
for _, opt := range opts {
|
||||
@@ -271,20 +284,29 @@ func (r *chainRoute) connect(ctx context.Context, logger logger.Logger) (conn ne
|
||||
}
|
||||
cc, err = preNode.Options().Transport.Connect(ctx, cn, "tcp", addr)
|
||||
if err != nil {
|
||||
if cc != nil {
|
||||
cc.Close()
|
||||
}
|
||||
cn.Close()
|
||||
if marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
return
|
||||
}
|
||||
cc, err = node.Options().Transport.Handshake(ctx, cc)
|
||||
var cc2 net.Conn
|
||||
cc2, err = node.Options().Transport.Handshake(ctx, cc)
|
||||
if err != nil {
|
||||
if cc2 != nil {
|
||||
cc2.Close()
|
||||
}
|
||||
cc.Close()
|
||||
cn.Close()
|
||||
if marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
return
|
||||
}
|
||||
cc = cc2
|
||||
if marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
+145
-80
@@ -4,20 +4,28 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
)
|
||||
|
||||
// Router is the top-level routing entry point. It resolves addresses,
|
||||
// selects routes through the configured chain, retries on failure, and
|
||||
// records telemetry (address events, errors, metrics).
|
||||
type Router struct {
|
||||
options chain.RouterOptions
|
||||
}
|
||||
|
||||
// NewRouter creates a Router with the given options. Defaults: 15s timeout,
|
||||
// a logger with kind "router" if none is provided.
|
||||
func NewRouter(opts ...chain.RouterOption) *Router {
|
||||
r := &Router{}
|
||||
for _, opt := range opts {
|
||||
@@ -42,7 +50,25 @@ func (r *Router) Options() *chain.RouterOptions {
|
||||
return &r.options
|
||||
}
|
||||
|
||||
func (r *Router) Dial(ctx context.Context, network, address string) (conn net.Conn, err error) {
|
||||
// Close releases resources held by the router's chain. A chain that owns
|
||||
// background goroutines (e.g. a chainGroup running liveness probes) is closed
|
||||
// here so its goroutines stop when the service shuts down or is reloaded.
|
||||
func (r *Router) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if c, ok := r.options.Chain.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dial establishes a connection to the target address through the configured
|
||||
// chain. It resolves the address, records the dial event, retries up to
|
||||
// Retries+1 times, and returns the resulting connection.
|
||||
// For UDP networks the returned connection is wrapped as a PacketConn if the
|
||||
// underlying transport does not already implement net.PacketConn.
|
||||
func (r *Router) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (conn net.Conn, err error) {
|
||||
host := address
|
||||
if h, _, _ := net.SplitHostPort(address); h != "" {
|
||||
host = h
|
||||
@@ -50,10 +76,10 @@ func (r *Router) Dial(ctx context.Context, network, address string) (conn net.Co
|
||||
r.record(ctx, recorder.RecorderServiceRouterDialAddress, []byte(host))
|
||||
|
||||
log := r.options.Logger.WithFields(map[string]any{
|
||||
"sid": ctxvalue.SidFromContext(ctx),
|
||||
"sid": xctx.SidFromContext(ctx),
|
||||
})
|
||||
|
||||
conn, err = r.dial(ctx, network, address, log)
|
||||
conn, err = r.dial(ctx, network, address, log, opts...)
|
||||
if err != nil {
|
||||
r.record(ctx, recorder.RecorderServiceRouterDialAddressError, []byte(host))
|
||||
return
|
||||
@@ -80,7 +106,7 @@ func (r *Router) record(ctx context.Context, name string, data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) dial(ctx context.Context, network, address string, log logger.Logger) (conn net.Conn, err error) {
|
||||
func (r *Router) dial(ctx context.Context, network, address string, log logger.Logger, callerOpts ...chain.DialOption) (conn net.Conn, err error) {
|
||||
count := r.options.Retries + 1
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
@@ -89,61 +115,95 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
|
||||
log.Debugf("dial %s/%s", address, network)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
ctx := ctx
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
func() {
|
||||
ctx := ctx
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
buf := ctxvalue.BufferFromContext(ctx)
|
||||
if buf != nil {
|
||||
buf.Reset()
|
||||
}
|
||||
buf := ictx.BufferFromContext(ctx)
|
||||
if buf != nil {
|
||||
buf.Reset()
|
||||
}
|
||||
|
||||
var ipAddr string
|
||||
ipAddr, err = xnet.Resolve(ctx, "ip", address, r.options.Resolver, r.options.HostMapper, log)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
var ipAddr string
|
||||
ipAddr, err = xnet.Resolve(ctx, "ip", address, r.options.Resolver, r.options.HostMapper, log)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if buf != nil {
|
||||
buf.Reset()
|
||||
}
|
||||
|
||||
var route chain.Route
|
||||
if r.options.Chain != nil {
|
||||
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
|
||||
}
|
||||
|
||||
if buf == nil {
|
||||
buf = &bytes.Buffer{}
|
||||
}
|
||||
for _, node := range routePath(route) {
|
||||
fmt.Fprintf(buf, "%s@%s > ", node.Name, node.Addr)
|
||||
}
|
||||
fmt.Fprintf(buf, "%s", ipAddr)
|
||||
log.Debugf("route(retry=%d) %s", i, buf.String())
|
||||
|
||||
if route == nil {
|
||||
route = DefaultRoute
|
||||
}
|
||||
ifceName := r.options.IfceName
|
||||
if strings.Contains(ifceName, "auto") {
|
||||
// "auto" triggers per-connection interface detection: the
|
||||
// ingress IP (conn.LocalAddr()) is used as the bind address,
|
||||
// ensuring egress traffic leaves via the same interface that
|
||||
// received the connection — the "source-in source-out"
|
||||
// pattern for multi-homed hosts.
|
||||
if dstAddr := xctx.DstAddrFromContext(ctx); dstAddr != nil {
|
||||
if host, _, _ := net.SplitHostPort(dstAddr.String()); host != "" &&
|
||||
host != "0.0.0.0" && host != "::" {
|
||||
// Replace only exact "auto" tokens in the
|
||||
// comma-separated interface list.
|
||||
parts := strings.Split(ifceName, ",")
|
||||
for i, p := range parts {
|
||||
if p == "auto" {
|
||||
parts[i] = host
|
||||
}
|
||||
}
|
||||
ifceName = strings.Join(parts, ",")
|
||||
}
|
||||
}
|
||||
if strings.Contains(ifceName, "auto") {
|
||||
log.Debugf("auto interface: no suitable ingress address, keeping literal %q", ifceName)
|
||||
}
|
||||
}
|
||||
conn, err = route.Dial(ctx, network, ipAddr,
|
||||
append([]chain.DialOption{
|
||||
chain.InterfaceDialOption(ifceName),
|
||||
chain.NetnsDialOption(r.options.Netns),
|
||||
chain.SockOptsDialOption(r.options.SockOpts),
|
||||
chain.LoggerDialOption(log),
|
||||
}, callerOpts...)...,
|
||||
)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("route(retry=%d) %s", i, err)
|
||||
}()
|
||||
if conn != nil || err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if buf != nil {
|
||||
buf.Reset()
|
||||
}
|
||||
|
||||
var route chain.Route
|
||||
if r.options.Chain != nil {
|
||||
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
|
||||
}
|
||||
|
||||
if buf == nil {
|
||||
buf = &bytes.Buffer{}
|
||||
}
|
||||
for _, node := range routePath(route) {
|
||||
fmt.Fprintf(buf, "%s@%s > ", node.Name, node.Addr)
|
||||
}
|
||||
fmt.Fprintf(buf, "%s", ipAddr)
|
||||
log.Debugf("route(retry=%d) %s", i, buf.String())
|
||||
|
||||
if route == nil {
|
||||
route = DefaultRoute
|
||||
}
|
||||
conn, err = route.Dial(ctx, network, ipAddr,
|
||||
chain.InterfaceDialOption(r.options.IfceName),
|
||||
chain.NetnsDialOption(r.options.Netns),
|
||||
chain.SockOptsDialOption(r.options.SockOpts),
|
||||
chain.LoggerDialOption(log),
|
||||
)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Errorf("route(retry=%d) %s", i, err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Bind creates a listener bound to the given address through the configured
|
||||
// chain. It retries up to Retries+1 times on failure.
|
||||
func (r *Router) Bind(ctx context.Context, network, address string, opts ...chain.BindOption) (ln net.Listener, err error) {
|
||||
count := r.options.Retries + 1
|
||||
if count <= 0 {
|
||||
@@ -151,45 +211,50 @@ func (r *Router) Bind(ctx context.Context, network, address string, opts ...chai
|
||||
}
|
||||
|
||||
log := r.options.Logger.WithFields(map[string]any{
|
||||
"sid": ctxvalue.SidFromContext(ctx),
|
||||
"sid": xctx.SidFromContext(ctx),
|
||||
})
|
||||
|
||||
log.Debugf("bind on %s/%s", address, network)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
ctx := ctx
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
func() {
|
||||
ctx := ctx
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
var route chain.Route
|
||||
if r.options.Chain != nil {
|
||||
route = r.options.Chain.Route(ctx, network, address)
|
||||
if route == nil || len(route.Nodes()) == 0 {
|
||||
err = ErrEmptyRoute
|
||||
var route chain.Route
|
||||
if r.options.Chain != nil {
|
||||
route = r.options.Chain.Route(ctx, network, address)
|
||||
if route == nil || len(route.Nodes()) == 0 {
|
||||
err = ErrEmptyRoute
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if log.IsLevelEnabled(logger.DebugLevel) {
|
||||
buf := bytes.Buffer{}
|
||||
for _, node := range routePath(route) {
|
||||
fmt.Fprintf(&buf, "%s@%s > ", node.Name, node.Addr)
|
||||
}
|
||||
fmt.Fprintf(&buf, "%s", address)
|
||||
log.Debugf("route(retry=%d) %s", i, buf.String())
|
||||
}
|
||||
|
||||
if route == nil {
|
||||
route = DefaultRoute
|
||||
}
|
||||
ln, err = route.Bind(ctx, network, address, opts...)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if log.IsLevelEnabled(logger.DebugLevel) {
|
||||
buf := bytes.Buffer{}
|
||||
for _, node := range routePath(route) {
|
||||
fmt.Fprintf(&buf, "%s@%s > ", node.Name, node.Addr)
|
||||
}
|
||||
fmt.Fprintf(&buf, "%s", address)
|
||||
log.Debugf("route(retry=%d) %s", i, buf.String())
|
||||
}
|
||||
|
||||
if route == nil {
|
||||
route = DefaultRoute
|
||||
}
|
||||
ln, err = route.Bind(ctx, network, address, opts...)
|
||||
if err == nil {
|
||||
log.Errorf("route(retry=%d) %s", i, err)
|
||||
}()
|
||||
if ln != nil || err == nil {
|
||||
break
|
||||
}
|
||||
log.Errorf("route(retry=%d) %s", i, err)
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
+8
-1
@@ -10,12 +10,16 @@ import (
|
||||
net_dialer "github.com/go-gost/x/internal/net/dialer"
|
||||
)
|
||||
|
||||
// Transport bundles a dialer and connector for a single chain node.
|
||||
// It implements chain.Transporter and handles the four operations in
|
||||
// the proxy chain lifecycle: Dial, Handshake, Connect, and Bind.
|
||||
type Transport struct {
|
||||
dialer dialer.Dialer
|
||||
connector connector.Connector
|
||||
options chain.TransportOptions
|
||||
}
|
||||
|
||||
// NewTransport creates a Transport with the given dialer and connector.
|
||||
func NewTransport(d dialer.Dialer, c connector.Connector, opts ...chain.TransportOption) *Transport {
|
||||
tr := &Transport{
|
||||
dialer: d,
|
||||
@@ -99,8 +103,11 @@ func (tr *Transport) Options() *chain.TransportOptions {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy returns a shallow copy of the transport. Used by Chain when
|
||||
// splitting a route at a multiplex-capable node so the sub-route can
|
||||
// be embedded in the transport without sharing state across calls.
|
||||
func (tr *Transport) Copy() chain.Transporter {
|
||||
tr2 := &Transport{}
|
||||
*tr2 = *tr
|
||||
return tr
|
||||
return tr2
|
||||
}
|
||||
|
||||
+350
-59
@@ -1,3 +1,7 @@
|
||||
// Package cmd converts CLI command-line arguments (-L services, -F chain nodes)
|
||||
// into structured config.Config objects. It supports URL-format service and node
|
||||
// specifications, metadata query parameters, and auto-detection of handler,
|
||||
// listener, dialer, and connector types via the registry.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -20,10 +24,17 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCmd = errors.New("invalid cmd")
|
||||
// ErrInvalidCmd is returned when the command-line argument is empty.
|
||||
ErrInvalidCmd = errors.New("invalid cmd")
|
||||
// ErrInvalidNode is returned when a node specification cannot be parsed.
|
||||
ErrInvalidNode = errors.New("invalid node")
|
||||
)
|
||||
|
||||
// BuildConfigFromCmd converts CLI service (-L) and node (-F) arguments into
|
||||
// a Config. Each node becomes a hop in a single chain; each service produces
|
||||
// a ServiceConfig with auto-detected handler and listener types. Query
|
||||
// parameters in the URL are parsed as metadata and routed to the appropriate
|
||||
// config sub-struct (handler, listener, hop, connector, dialer, service).
|
||||
func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error) {
|
||||
namePrefix := ""
|
||||
cfg := &config.Config{}
|
||||
@@ -42,24 +53,6 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
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)
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
for k, v := range url.Query() {
|
||||
if len(v) > 0 {
|
||||
@@ -71,8 +64,6 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
hopConfig := &config.HopConfig{
|
||||
Name: fmt.Sprintf("%shop-%d", namePrefix, i),
|
||||
Selector: parseSelector(m),
|
||||
Nodes: nodes,
|
||||
Metadata: m,
|
||||
}
|
||||
|
||||
if v := mdutil.GetString(md, "bypass"); v != "" {
|
||||
@@ -135,15 +126,56 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
}
|
||||
|
||||
if v := mdutil.GetString(md, "interface"); v != "" {
|
||||
hopConfig.Interface = v
|
||||
m["hop.interface"] = v
|
||||
delete(m, "interface")
|
||||
}
|
||||
if v := mdutil.GetInt(md, "so_mark"); v > 0 {
|
||||
hopConfig.SockOpts = &config.SockOptsConfig{
|
||||
Mark: v,
|
||||
}
|
||||
m["hop.so_mark"] = v
|
||||
delete(m, "so_mark")
|
||||
}
|
||||
if v := mdutil.GetInt(md, "proxyProtocol"); v > 0 {
|
||||
m["hop.proxyProtocol"] = v
|
||||
delete(m, "proxyProtocol")
|
||||
}
|
||||
|
||||
hopMd := map[string]any{}
|
||||
for k, v := range m {
|
||||
if strings.HasPrefix(k, "node.") ||
|
||||
strings.HasPrefix(k, "connector.") ||
|
||||
strings.HasPrefix(k, "dialer.") {
|
||||
continue
|
||||
}
|
||||
if s, found := strings.CutPrefix(k, "hop."); found {
|
||||
hopMd[s] = v
|
||||
delete(m, k)
|
||||
continue
|
||||
}
|
||||
|
||||
hopMd[k] = v
|
||||
}
|
||||
hopConfig.Metadata = hopMd
|
||||
|
||||
nodeConfig, err := buildNodeConfig(url, m)
|
||||
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.Connector = copyConnectorConfig(nodeConfig.Connector)
|
||||
nodeCfg.Dialer = copyDialerConfig(nodeConfig.Dialer)
|
||||
nodeCfg.Metadata = copyMap(nodeConfig.Metadata)
|
||||
nodeCfg.Name = fmt.Sprintf("%snode-%d", namePrefix, len(nodes))
|
||||
nodeCfg.Addr = host
|
||||
nodes = append(nodes, nodeCfg)
|
||||
}
|
||||
hopConfig.Nodes = nodes
|
||||
|
||||
chain.Hops = append(chain.Hops, hopConfig)
|
||||
}
|
||||
@@ -177,7 +209,7 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
for i, service := range services {
|
||||
service.Name = fmt.Sprintf("%sservice-%d", namePrefix, i)
|
||||
if chain != nil {
|
||||
if service.Listener.Type == "rtcp" || service.Listener.Type == "rudp" {
|
||||
if service.Listener.Type == "rtcp" || service.Listener.Type == "rudp" || service.Listener.Type == "runix" {
|
||||
service.Listener.Chain = chain.Name
|
||||
} else {
|
||||
service.Handler.Chain = chain.Name
|
||||
@@ -317,6 +349,9 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// cutHost extracts the host portion from a URL string, stripping any auth
|
||||
// info (user:pass@). It returns the host and the remainder of the URL with
|
||||
// the host portion removed.
|
||||
func cutHost(s string) (host, remain string) {
|
||||
if s == "" {
|
||||
return
|
||||
@@ -341,6 +376,10 @@ func cutHost(s string) (host, remain string) {
|
||||
return
|
||||
}
|
||||
|
||||
// buildServiceConfig converts a parsed service URL into one or more
|
||||
// ServiceConfig entries. Scheme components (handler+listener) are resolved
|
||||
// against the registry; path-based protocols use the URL path as the listen
|
||||
// address; a non-empty path for other protocols indicates forwarding mode.
|
||||
func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
namePrefix := ""
|
||||
if v := os.Getenv("_GOST_ID"); v != "" {
|
||||
@@ -358,9 +397,33 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
listener = schemes[1]
|
||||
}
|
||||
|
||||
addrs := xnet.AddrPortRange(url.Host).Addrs()
|
||||
if len(addrs) == 0 {
|
||||
addrs = append(addrs, url.Host)
|
||||
// Decide path-based from the scheme before registry remaps listener to tcp.
|
||||
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
|
||||
for _, s := range schemes {
|
||||
if s == "unix" || s == "runix" || s == "serial" {
|
||||
isPathBasedProtocol = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// A path-based protocol with an empty host (three-slash URL like
|
||||
// unix:///var/run/sock) uses the path as the absolute listen address and
|
||||
// has no forward target. With a non-empty host (two-slash URL like
|
||||
// unix://sock/other.sock), the host is the listen address and any path is a
|
||||
// forward target (port-forward to another socket), handled below.
|
||||
isPathAddr := isPathBasedProtocol && url.Host == ""
|
||||
|
||||
var addrs []string
|
||||
if isPathAddr {
|
||||
if path := url.EscapedPath(); path != "" {
|
||||
addrs = append(addrs, path)
|
||||
} else {
|
||||
addrs = append(addrs, url.Host)
|
||||
}
|
||||
} else {
|
||||
addrs = xnet.AddrPortRange(url.Host).Addrs()
|
||||
if len(addrs) == 0 {
|
||||
addrs = append(addrs, url.Host)
|
||||
}
|
||||
}
|
||||
|
||||
var services []*config.ServiceConfig
|
||||
@@ -382,7 +445,10 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
|
||||
var nodes []*config.ForwardNodeConfig
|
||||
// forward mode
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" {
|
||||
// A path-based protocol with an empty host (e.g. unix:///abs.sock) treats
|
||||
// the path as the listen address, not a forward target. With a host
|
||||
// (e.g. unix://a.sock/b.sock) the path is a forward target.
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathAddr {
|
||||
i := 0
|
||||
for _, addr := range strings.Split(remotes, ",") {
|
||||
addrs := xnet.AddrPortRange(addr).Addrs()
|
||||
@@ -402,7 +468,7 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
if listener == "tcp" || listener == "udp" ||
|
||||
listener == "rtcp" || listener == "rudp" ||
|
||||
listener == "tun" || listener == "tap" ||
|
||||
listener == "dns" || listener == "unix" ||
|
||||
listener == "dns" || listener == "unix" || listener == "runix" ||
|
||||
listener == "serial" {
|
||||
handler = listener
|
||||
} else {
|
||||
@@ -411,6 +477,17 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// stdio listener uses url.Host as the forwarding target.
|
||||
if listener == "stdio" && len(nodes) == 0 && url.Host != "" {
|
||||
nodes = append(nodes, &config.ForwardNodeConfig{
|
||||
Name: fmt.Sprintf("%starget-0", namePrefix),
|
||||
Addr: url.Host,
|
||||
})
|
||||
if handler == "auto" {
|
||||
handler = "tcp"
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) > 0 {
|
||||
if len(services) == 1 {
|
||||
services[0].Forwarder = &config.ForwarderConfig{
|
||||
@@ -435,10 +512,15 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
|
||||
var auth *config.AuthConfig
|
||||
if url.User != nil {
|
||||
auth = &config.AuthConfig{
|
||||
Username: url.User.Username(),
|
||||
if strings.HasPrefix(url.Scheme, "ss") {
|
||||
auth = decodeSIP002Auth(url)
|
||||
}
|
||||
if auth == nil {
|
||||
auth = &config.AuthConfig{
|
||||
Username: url.User.Username(),
|
||||
}
|
||||
auth.Password, _ = url.User.Password()
|
||||
}
|
||||
auth.Password, _ = url.User.Password()
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
@@ -449,7 +531,7 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
}
|
||||
md := mdx.NewMetadata(m)
|
||||
|
||||
if sa := mdutil.GetString(md, "auth"); sa != "" {
|
||||
if sa := mdutil.GetString(md, "auth"); sa != "" && auth == nil {
|
||||
au, err := parseAuthFromCmd(sa)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -479,19 +561,63 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
}
|
||||
|
||||
if v := mdutil.GetString(md, "dns"); v != "" {
|
||||
md.Set("dns", strings.Split(v, ","))
|
||||
m["dns"] = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
if v := mdutil.GetString(md, "interface"); v != "" {
|
||||
m["service.interface"] = v
|
||||
delete(m, "interface")
|
||||
}
|
||||
if v := mdutil.GetInt(md, "so_mark"); v > 0 {
|
||||
m["service.so_mark"] = v
|
||||
delete(m, "so_mark")
|
||||
}
|
||||
if v := mdutil.GetInt(md, "proxyProtocol"); v > 0 {
|
||||
m["service.proxyProtocol"] = v
|
||||
delete(m, "proxyProtocol")
|
||||
}
|
||||
if v := mdutil.GetString(md, "netns"); v != "" {
|
||||
m["service.netns"] = v
|
||||
delete(m, "netns")
|
||||
}
|
||||
if v := mdutil.GetString(md, "netns.out"); v != "" {
|
||||
m["service.netns.out"] = v
|
||||
delete(m, "netns.out")
|
||||
}
|
||||
|
||||
selector := parseSelector(m)
|
||||
|
||||
serviceMd := map[string]any{}
|
||||
handleMd := map[string]any{}
|
||||
listenerMd := map[string]any{}
|
||||
for k, v := range m {
|
||||
if s, found := strings.CutPrefix(k, "service."); found {
|
||||
serviceMd[s] = v
|
||||
continue
|
||||
}
|
||||
if s, found := strings.CutPrefix(k, "handler."); found {
|
||||
handleMd[s] = v
|
||||
continue
|
||||
}
|
||||
if s, found := strings.CutPrefix(k, "listener."); found {
|
||||
listenerMd[s] = v
|
||||
continue
|
||||
}
|
||||
|
||||
handleMd[k] = v
|
||||
listenerMd[k] = v
|
||||
serviceMd[k] = v
|
||||
}
|
||||
|
||||
handlerCfg := &config.HandlerConfig{
|
||||
Type: handler,
|
||||
Auth: auth,
|
||||
Metadata: m,
|
||||
Metadata: handleMd,
|
||||
}
|
||||
listenerCfg := &config.ListenerConfig{
|
||||
Type: listener,
|
||||
TLS: tlsConfig,
|
||||
Metadata: m,
|
||||
Metadata: listenerMd,
|
||||
}
|
||||
if listenerCfg.Type == "ssh" || listenerCfg.Type == "sshd" {
|
||||
handlerCfg.Auth = nil
|
||||
@@ -502,15 +628,18 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
if svc.Forwarder != nil {
|
||||
svc.Forwarder.Selector = selector
|
||||
}
|
||||
svc.Handler = handlerCfg
|
||||
svc.Listener = listenerCfg
|
||||
svc.Metadata = m
|
||||
svc.Handler = copyHandlerConfig(handlerCfg)
|
||||
svc.Listener = copyListenerConfig(listenerCfg)
|
||||
svc.Metadata = copyMap(serviceMd)
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
|
||||
// buildNodeConfig converts a parsed node URL into a NodeConfig. Scheme
|
||||
// components (connector+dialer) are resolved against the registry; path-based
|
||||
// dialers use the URL path as the node address.
|
||||
func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error) {
|
||||
var connector, dialer string
|
||||
schemes := strings.Split(url.Scheme, "+")
|
||||
if len(schemes) == 1 {
|
||||
@@ -522,18 +651,19 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
|
||||
dialer = schemes[1]
|
||||
}
|
||||
|
||||
m := map[string]any{}
|
||||
for k, v := range url.Query() {
|
||||
if len(v) > 0 {
|
||||
m[k] = v[0]
|
||||
// Path-based address logic must use the requested scheme, not the
|
||||
// registry fallback (unix/serial often remap to http/tcp when only
|
||||
// the CLI registry is partially populated, or in unit tests).
|
||||
isPathBasedProtocol := dialer == "unix" || dialer == "serial" ||
|
||||
connector == "unix" || connector == "serial"
|
||||
for _, s := range schemes {
|
||||
if s == "unix" || s == "serial" || s == "runix" {
|
||||
isPathBasedProtocol = true
|
||||
break
|
||||
}
|
||||
}
|
||||
md := mdx.NewMetadata(m)
|
||||
|
||||
node := &config.NodeConfig{
|
||||
Addr: url.Host,
|
||||
Metadata: m,
|
||||
}
|
||||
md := mdx.NewMetadata(m)
|
||||
|
||||
if c := registry.ConnectorRegistry().Get(connector); c == nil {
|
||||
connector = "http"
|
||||
@@ -547,10 +677,15 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
|
||||
|
||||
var auth *config.AuthConfig
|
||||
if url.User != nil {
|
||||
auth = &config.AuthConfig{
|
||||
Username: url.User.Username(),
|
||||
if strings.HasPrefix(url.Scheme, "ss") {
|
||||
auth = decodeSIP002Auth(url)
|
||||
}
|
||||
if auth == nil {
|
||||
auth = &config.AuthConfig{
|
||||
Username: url.User.Username(),
|
||||
}
|
||||
auth.Password, _ = url.User.Password()
|
||||
}
|
||||
auth.Password, _ = url.User.Password()
|
||||
}
|
||||
|
||||
if sauth := mdutil.GetString(md, "auth"); sauth != "" && auth == nil {
|
||||
@@ -591,15 +726,53 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
|
||||
tlsConfig = nil
|
||||
}
|
||||
|
||||
nodeMd := map[string]any{}
|
||||
connectorMd := map[string]any{}
|
||||
dialerMd := map[string]any{}
|
||||
for k, v := range m {
|
||||
if s, found := strings.CutPrefix(k, "node."); found {
|
||||
nodeMd[s] = v
|
||||
continue
|
||||
}
|
||||
if s, found := strings.CutPrefix(k, "connector."); found {
|
||||
connectorMd[s] = v
|
||||
continue
|
||||
}
|
||||
if s, found := strings.CutPrefix(k, "dialer."); found {
|
||||
dialerMd[s] = v
|
||||
continue
|
||||
}
|
||||
|
||||
connectorMd[k] = v
|
||||
dialerMd[k] = v
|
||||
nodeMd[k] = v
|
||||
}
|
||||
|
||||
// For path-based protocols (Unix socket dialers and serial), use path as address.
|
||||
// Three-slash URLs (empty host) give the absolute socket path; two-slash URLs
|
||||
// use the host as the socket. The path on a host form is not part of the node
|
||||
// address (it is a forward target for services, ignored for nodes).
|
||||
var nodeAddr string
|
||||
if isPathBasedProtocol && url.Host == "" {
|
||||
// Three slashes: path is absolute (keep leading slash)
|
||||
nodeAddr = url.EscapedPath()
|
||||
} else {
|
||||
nodeAddr = url.Host
|
||||
}
|
||||
|
||||
node := &config.NodeConfig{
|
||||
Addr: nodeAddr,
|
||||
Metadata: nodeMd,
|
||||
}
|
||||
node.Connector = &config.ConnectorConfig{
|
||||
Type: connector,
|
||||
Auth: auth,
|
||||
Metadata: m,
|
||||
Metadata: connectorMd,
|
||||
}
|
||||
node.Dialer = &config.DialerConfig{
|
||||
Type: dialer,
|
||||
TLS: tlsConfig,
|
||||
Metadata: m,
|
||||
Metadata: dialerMd,
|
||||
}
|
||||
|
||||
if node.Dialer.Type == "ssh" || node.Dialer.Type == "sshd" {
|
||||
@@ -610,6 +783,9 @@ func buildNodeConfig(url *url.URL) (*config.NodeConfig, error) {
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// Norm normalizes a raw command-line string into a parsed URL. Missing schemes
|
||||
// default to "auto" and "https" is rewritten to "http+tls". An empty string
|
||||
// returns ErrInvalidCmd.
|
||||
func Norm(s string) (*url.URL, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -631,10 +807,51 @@ func Norm(s string) (*url.URL, error) {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
|
||||
v, err := base64.StdEncoding.DecodeString(sa)
|
||||
// decodeSIP002Auth attempts to decode a Shadowsocks SIP002-style URL where
|
||||
// the userinfo is a single base64-encoded "method:password" string with no
|
||||
// separate password field. Returns nil if the URL does not use this format.
|
||||
func decodeSIP002Auth(u *url.URL) *config.AuthConfig {
|
||||
if u.User == nil {
|
||||
return nil
|
||||
}
|
||||
_, hasPassword := u.User.Password()
|
||||
if hasPassword {
|
||||
return nil
|
||||
}
|
||||
username := u.User.Username()
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
decoded, err = base64.StdEncoding.DecodeString(username)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cs := string(decoded)
|
||||
n := strings.IndexByte(cs, ':')
|
||||
if n < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &config.AuthConfig{
|
||||
Username: cs[:n],
|
||||
Password: cs[n+1:],
|
||||
}
|
||||
}
|
||||
|
||||
// parseAuthFromCmd decodes a base64-encoded "username:password" string from
|
||||
// a query parameter into an AuthConfig. The password portion is optional.
|
||||
func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
|
||||
v, err := base64.RawURLEncoding.DecodeString(sa)
|
||||
if err != nil {
|
||||
v, err = base64.StdEncoding.DecodeString(sa)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cs := string(v)
|
||||
n := strings.IndexByte(cs, ':')
|
||||
@@ -650,6 +867,9 @@ func parseAuthFromCmd(sa string) (*config.AuthConfig, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseSelector extracts selector configuration (strategy, maxFails,
|
||||
// failTimeout) from metadata and removes the consumed keys. Returns nil
|
||||
// if no selector parameters are present.
|
||||
func parseSelector(m map[string]any) *config.SelectorConfig {
|
||||
md := mdx.NewMetadata(m)
|
||||
strategy := mdutil.GetString(md, "strategy")
|
||||
@@ -680,3 +900,74 @@ func parseSelector(m map[string]any) *config.SelectorConfig {
|
||||
FailTimeout: failTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func copyMap(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
c := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
c[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func copyAuth(cfg *config.AuthConfig) *config.AuthConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
c := *cfg
|
||||
return &c
|
||||
}
|
||||
|
||||
func copyTLS(cfg *config.TLSConfig) *config.TLSConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
c := *cfg
|
||||
return &c
|
||||
}
|
||||
|
||||
func copyConnectorConfig(cfg *config.ConnectorConfig) *config.ConnectorConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
c := *cfg
|
||||
c.Auth = copyAuth(cfg.Auth)
|
||||
c.TLS = copyTLS(cfg.TLS)
|
||||
c.Metadata = copyMap(cfg.Metadata)
|
||||
return &c
|
||||
}
|
||||
|
||||
func copyDialerConfig(cfg *config.DialerConfig) *config.DialerConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
c := *cfg
|
||||
c.Auth = copyAuth(cfg.Auth)
|
||||
c.TLS = copyTLS(cfg.TLS)
|
||||
c.Metadata = copyMap(cfg.Metadata)
|
||||
return &c
|
||||
}
|
||||
|
||||
func copyHandlerConfig(cfg *config.HandlerConfig) *config.HandlerConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
c := *cfg
|
||||
c.Auth = copyAuth(cfg.Auth)
|
||||
c.TLS = copyTLS(cfg.TLS)
|
||||
c.Metadata = copyMap(cfg.Metadata)
|
||||
return &c
|
||||
}
|
||||
|
||||
func copyListenerConfig(cfg *config.ListenerConfig) *config.ListenerConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
c := *cfg
|
||||
c.Auth = copyAuth(cfg.Auth)
|
||||
c.TLS = copyTLS(cfg.TLS)
|
||||
c.Metadata = copyMap(cfg.Metadata)
|
||||
return &c
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+268
-36
@@ -3,11 +3,16 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -24,8 +29,21 @@ func init() {
|
||||
var (
|
||||
global = &Config{}
|
||||
globalMux sync.RWMutex
|
||||
|
||||
// configFile remembers the last file loaded via ReadFile so the API can
|
||||
// save back to the same location (issue go-gost/x#123).
|
||||
configFile atomic.Value // string
|
||||
)
|
||||
|
||||
// ConfigFile returns the path of the last config file loaded via ReadFile,
|
||||
// or an empty string when the config came from stdin, a URL, or inline JSON.
|
||||
func ConfigFile() string {
|
||||
if s, ok := configFile.Load().(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Global() *Config {
|
||||
globalMux.RLock()
|
||||
defer globalMux.RUnlock()
|
||||
@@ -115,6 +133,9 @@ type TLSConfig struct {
|
||||
Validity time.Duration `yaml:",omitempty" json:"validity,omitempty"`
|
||||
CommonName string `yaml:"commonName,omitempty" json:"commonName,omitempty"`
|
||||
Organization string `yaml:",omitempty" json:"organization,omitempty"`
|
||||
|
||||
RejectUnknownSNI bool `yaml:"rejectUnknownSNI,omitempty" json:"rejectUnknownSNI,omitempty"`
|
||||
ServerNames []string `yaml:"serverNames,omitempty" json:"serverNames,omitempty"`
|
||||
}
|
||||
|
||||
type TLSOptions struct {
|
||||
@@ -127,6 +148,7 @@ type TLSOptions struct {
|
||||
type PluginConfig struct {
|
||||
Type string `json:"type"`
|
||||
Addr string `json:"addr"`
|
||||
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
TLS *TLSConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Timeout time.Duration `yaml:",omitempty" json:"timeout,omitempty"`
|
||||
Token string `yaml:",omitempty" json:"token,omitempty"`
|
||||
@@ -145,13 +167,14 @@ type AutherConfig struct {
|
||||
type AuthConfig struct {
|
||||
Username string `yaml:",omitempty" json:"username,omitempty"`
|
||||
Password string `yaml:",omitempty" json:"password,omitempty"`
|
||||
File string `yaml:",omitempty" json:"file,omitempty"`
|
||||
File string `yaml:",omitempty" json:"file,omitempty"`
|
||||
}
|
||||
|
||||
type SelectorConfig struct {
|
||||
Strategy string `json:"strategy"`
|
||||
MaxFails int `yaml:"maxFails" json:"maxFails"`
|
||||
FailTimeout time.Duration `yaml:"failTimeout" json:"failTimeout"`
|
||||
MaxLatency time.Duration `yaml:"maxLatency,omitempty" json:"maxLatency,omitempty"`
|
||||
}
|
||||
|
||||
type AdmissionConfig struct {
|
||||
@@ -172,6 +195,7 @@ type BypassConfig struct {
|
||||
// Deprecated: use whitelist instead
|
||||
Reverse bool `yaml:",omitempty" json:"reverse,omitempty"`
|
||||
Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"`
|
||||
Network string `yaml:",omitempty" json:"network,omitempty"`
|
||||
Matchers []string `yaml:",omitempty" json:"matchers,omitempty"`
|
||||
Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"`
|
||||
File *FileLoader `yaml:",omitempty" json:"file,omitempty"`
|
||||
@@ -278,6 +302,13 @@ type RecorderConfig struct {
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
// P2PConfig defines a named p2p tunnel provider, currently backed by an
|
||||
// external gRPC plugin process.
|
||||
type P2PConfig struct {
|
||||
Name string `json:"name"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
type FileRecorder struct {
|
||||
Path string `json:"path"`
|
||||
Sep string `yaml:",omitempty" json:"sep,omitempty"`
|
||||
@@ -310,6 +341,38 @@ type RecorderObject struct {
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RewriterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
// CacheConfig defines a named cache store. Memory and Redis backends are
|
||||
// supported; other backends (file, plugin) are follow-ups.
|
||||
type CacheConfig struct {
|
||||
Name string `json:"name"`
|
||||
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
|
||||
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
|
||||
}
|
||||
|
||||
// RedisCache configures the Redis cache backend.
|
||||
type RedisCache struct {
|
||||
Addr string `json:"addr"`
|
||||
DB int `yaml:",omitempty" json:"db,omitempty"`
|
||||
Username string `yaml:",omitempty" json:"username,omitempty"`
|
||||
Password string `yaml:",omitempty" json:"password,omitempty"`
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
Key string `yaml:",omitempty" json:"key,omitempty"` // key prefix, default "gost:cache:"
|
||||
}
|
||||
|
||||
// MemoryCache configures the in-memory cache backend.
|
||||
type MemoryCache struct {
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
MaxSize int `yaml:"maxSize,omitempty" json:"maxSize,omitempty"`
|
||||
MaxBytes int64 `yaml:"maxBytes,omitempty" json:"maxBytes,omitempty"`
|
||||
CleanupInterval time.Duration `yaml:"cleanupInterval,omitempty" json:"cleanupInterval,omitempty"`
|
||||
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
|
||||
}
|
||||
|
||||
type LimiterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
|
||||
@@ -354,9 +417,21 @@ type ForwarderConfig struct {
|
||||
// Deprecated: use hop instead
|
||||
Name string `yaml:",omitempty" json:"name,omitempty"`
|
||||
// the referenced hop name
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Nodes []*ForwardNodeConfig `json:"nodes"`
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Nodes []*ForwardNodeConfig `json:"nodes"`
|
||||
HopGroup *ForwardHopGroupConfig `yaml:"hopGroup,omitempty" json:"hopGroup,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardHopGroupConfig struct {
|
||||
Hops []*ForwardHopConfig `yaml:",omitempty" json:"hops,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardHopConfig struct {
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardNodeConfig struct {
|
||||
@@ -378,6 +453,7 @@ type ForwardNodeConfig struct {
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -391,6 +467,20 @@ type HTTPBodyRewriteConfig struct {
|
||||
Type string
|
||||
Match string
|
||||
Replacement string
|
||||
// name of the rewriter plugin (via registry)
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPHeaderRewriteConfig struct {
|
||||
// Name is the header name regex, e.g. "(?i)^(location|set-cookie)$".
|
||||
Name string
|
||||
// Match is the header value regex.
|
||||
Match string
|
||||
// Replacement is the replacement string.
|
||||
Replacement string
|
||||
// Rewriter is the name of the rewriter plugin (via registry).
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
}
|
||||
|
||||
type NodeFilterConfig struct {
|
||||
@@ -399,14 +489,44 @@ type NodeFilterConfig struct {
|
||||
Path string `yaml:",omitempty" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMatcherConfig defines a routing-rule matcher for a hop node.
|
||||
//
|
||||
// Priority controls election among multiple matching nodes:
|
||||
// - 0 (default): auto-computed from the rule string length — longer rules
|
||||
// (more specific) get higher priority.
|
||||
// - negative: the node participates in matching but priority short-circuit
|
||||
// is disabled; the selector (round-robin, random, hash, etc.) always applies.
|
||||
// - positive: explicit priority; when a single node has strictly higher
|
||||
// priority than all others, it wins directly, bypassing the selector.
|
||||
type NodeMatcherConfig struct {
|
||||
Rule string `yaml:",omitempty" json:"rule,omitempty"`
|
||||
Priority int `yaml:",omitempty" json:"priority,omitempty"`
|
||||
// BodySize is the max request body prefix (bytes) exposed to BodyRegexp
|
||||
// and BodyJSON matchers. 0 (default) uses DefaultMatcherBodySize (1MB).
|
||||
// Capped at MaxMatcherBodySize (10MB). Only takes effect under an HTTP
|
||||
// sniffing handler whose sniffer reads the body prefix before node selection.
|
||||
BodySize int `yaml:",omitempty" json:"bodySize,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeConfig holds the configuration for a node-level liveness probe.
|
||||
type ProbeConfig struct {
|
||||
Type string `json:"type"`
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
HTTPPath string `yaml:"httpPath,omitempty" json:"httpPath,omitempty"`
|
||||
HTTPHost string `yaml:"httpHost,omitempty" json:"httpHost,omitempty"`
|
||||
HTTPHeaders map[string]string `yaml:"httpHeaders,omitempty" json:"httpHeaders,omitempty"`
|
||||
ExpectedStatus int `yaml:"expectedStatus,omitempty" json:"expectedStatus,omitempty"`
|
||||
Command string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPNodeConfig struct {
|
||||
// rewrite host header
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
// regex matched against the request path to derive Host; Host becomes a
|
||||
// replacement template ($1, $2, ...) when set.
|
||||
HostPattern string `yaml:"hostPattern,omitempty" json:"hostPattern,omitempty"`
|
||||
// Deprecated: use requestHeader instead
|
||||
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
|
||||
// additional request header
|
||||
@@ -417,8 +537,21 @@ type HTTPNodeConfig struct {
|
||||
Rewrite []HTTPURLRewriteConfig `yaml:",omitempty" json:"rewrite,omitempty"`
|
||||
// rewrite URL
|
||||
RewriteURL []HTTPURLRewriteConfig `yaml:"rewriteURL,omitempty" json:"rewriteURL,omitempty"`
|
||||
// rewrite response body
|
||||
// Deprecated: use rewriteResponseBody instead
|
||||
RewriteBody []HTTPBodyRewriteConfig `yaml:"rewriteBody,omitempty" json:"rewriteBody,omitempty"`
|
||||
// rewrite request body
|
||||
RewriteRequestBody []HTTPBodyRewriteConfig `yaml:"rewriteRequestBody,omitempty" json:"rewriteRequestBody,omitempty"`
|
||||
// rewrite response body
|
||||
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
|
||||
|
||||
// rewrite request header values
|
||||
RewriteRequestHeader []HTTPHeaderRewriteConfig `yaml:"rewriteRequestHeader,omitempty" json:"rewriteRequestHeader,omitempty"`
|
||||
// rewrite response header values
|
||||
RewriteResponseHeader []HTTPHeaderRewriteConfig `yaml:"rewriteResponseHeader,omitempty" json:"rewriteResponseHeader,omitempty"`
|
||||
|
||||
// comma-separated response status codes marking the node failed, e.g. "429,5xx"
|
||||
FailCodes string `yaml:"failCodes,omitempty" json:"failCodes,omitempty"`
|
||||
|
||||
// HTTP basic auth
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
}
|
||||
@@ -461,12 +594,15 @@ type ServiceConfig struct {
|
||||
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
|
||||
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
|
||||
Limiter string `yaml:",omitempty" json:"limiter,omitempty"`
|
||||
Quotas []string `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"`
|
||||
RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"`
|
||||
Logger string `yaml:",omitempty" json:"logger,omitempty"`
|
||||
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
|
||||
Observer string `yaml:",omitempty" json:"observer,omitempty"`
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Cache string `yaml:",omitempty" json:"cache,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
|
||||
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
|
||||
@@ -476,10 +612,12 @@ type ServiceConfig struct {
|
||||
}
|
||||
|
||||
type ServiceStatus struct {
|
||||
CreateTime int64 `yaml:"createTime" json:"createTime"`
|
||||
State string `yaml:"state" json:"state"`
|
||||
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
|
||||
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
|
||||
CreateTime int64 `yaml:"createTime" json:"createTime"`
|
||||
State string `yaml:"state" json:"state"`
|
||||
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
|
||||
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
|
||||
StoppedByLimit bool `yaml:"stoppedByLimit,omitempty" json:"stoppedByLimit,omitempty"`
|
||||
Quotas []ServiceQuota `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceEvent struct {
|
||||
@@ -495,33 +633,116 @@ type ServiceStats struct {
|
||||
OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"`
|
||||
}
|
||||
|
||||
// QuotaConfig is a named cumulative traffic-volume limiter; the same name
|
||||
// referenced by several services (ServiceConfig.Quotas) shares one counter.
|
||||
type QuotaConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limit string `yaml:",omitempty" json:"limit,omitempty"`
|
||||
StartsAt string `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
|
||||
ExpiresAt string `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
Direction string `yaml:",omitempty" json:"direction,omitempty"`
|
||||
Flush string `yaml:",omitempty" json:"flush,omitempty"`
|
||||
Store *QuotaStoreConfig `yaml:",omitempty" json:"store,omitempty"`
|
||||
// read-only
|
||||
Status *QuotaStatus `yaml:",omitempty" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaStoreConfig struct {
|
||||
Type string `json:"type"` // "file" (default) | "redis" (stub, not implemented)
|
||||
File string `yaml:",omitempty" json:"file,omitempty"`
|
||||
Redis *QuotaRedisConfig `yaml:",omitempty" json:"redis,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaRedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
DB int `yaml:",omitempty" json:"db,omitempty"`
|
||||
Username string `yaml:",omitempty" json:"username,omitempty"`
|
||||
Password string `yaml:",omitempty" json:"password,omitempty"`
|
||||
Key string `yaml:",omitempty" json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaStatus struct {
|
||||
Used uint64 `yaml:"used" json:"used"`
|
||||
Limit uint64 `yaml:"limit" json:"limit"`
|
||||
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
|
||||
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
|
||||
Blocked bool `yaml:"blocked" json:"blocked"`
|
||||
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceQuota struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Used uint64 `yaml:"used" json:"used"`
|
||||
Limit uint64 `yaml:"limit" json:"limit"`
|
||||
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
|
||||
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
|
||||
Blocked bool `yaml:"blocked" json:"blocked"`
|
||||
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
|
||||
}
|
||||
|
||||
type ChainConfig struct {
|
||||
Name string `json:"name"`
|
||||
Hops []*HopConfig `json:"hops"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ChainGroupEntry struct {
|
||||
Chain string `yaml:",omitempty" json:"chain,omitempty"`
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
}
|
||||
|
||||
// gostDecodeHook is a composite mapstructure decode hook that handles custom
|
||||
// type conversions needed by GOST config fields. viper's internal unmarshalling
|
||||
// never calls yaml.Unmarshaler / json.Unmarshaler on leaf types, so the hook is
|
||||
// the only way to support these.
|
||||
func gostDecodeHook() mapstructure.DecodeHookFunc {
|
||||
return func(f, t reflect.Type, data any) (any, error) {
|
||||
// Plain string → ChainGroupEntry: backward compat for "chains: [a, b]".
|
||||
if t == reflect.TypeOf(ChainGroupEntry{}) {
|
||||
if s, ok := data.(string); ok {
|
||||
return ChainGroupEntry{Chain: s}, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
// String → time.Duration: accept "10s", "1m", etc. in YAML/JSON.
|
||||
if t == reflect.TypeOf(time.Duration(0)) {
|
||||
if s, ok := data.(string); ok {
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
type ChainGroupConfig struct {
|
||||
Chains []string `yaml:",omitempty" json:"chains,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Chains []*ChainGroupEntry `yaml:",omitempty" json:"chains,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
}
|
||||
|
||||
type HopConfig struct {
|
||||
Name string `json:"name"`
|
||||
Interface string `yaml:",omitempty" json:"interface,omitempty"`
|
||||
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
|
||||
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
|
||||
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
|
||||
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
|
||||
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
|
||||
Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"`
|
||||
File *FileLoader `yaml:",omitempty" json:"file,omitempty"`
|
||||
Redis *RedisLoader `yaml:",omitempty" json:"redis,omitempty"`
|
||||
HTTP *HTTPLoader `yaml:"http,omitempty" json:"http,omitempty"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
Name string `json:"name"`
|
||||
// Deprecated: use metadata.interface instead
|
||||
Interface string `yaml:",omitempty" json:"interface,omitempty"`
|
||||
// Deprecated: use metadata.so_mark instead
|
||||
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
|
||||
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
|
||||
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
|
||||
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
|
||||
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
|
||||
Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"`
|
||||
File *FileLoader `yaml:",omitempty" json:"file,omitempty"`
|
||||
Redis *RedisLoader `yaml:",omitempty" json:"redis,omitempty"`
|
||||
HTTP *HTTPLoader `yaml:"http,omitempty" json:"http,omitempty"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type NodeConfig struct {
|
||||
@@ -534,14 +755,18 @@ type NodeConfig struct {
|
||||
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
|
||||
Connector *ConnectorConfig `yaml:",omitempty" json:"connector,omitempty"`
|
||||
Dialer *DialerConfig `yaml:",omitempty" json:"dialer,omitempty"`
|
||||
Interface string `yaml:",omitempty" json:"interface,omitempty"`
|
||||
Netns string `yaml:",omitempty" json:"netns,omitempty"`
|
||||
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
|
||||
// Deprecated: use metadata.interface instead
|
||||
Interface string `yaml:",omitempty" json:"interface,omitempty"`
|
||||
// Deprecated: use metadata.netns instead
|
||||
Netns string `yaml:",omitempty" json:"netns,omitempty"`
|
||||
// Deprecated: use metadata.so_mark instead
|
||||
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
|
||||
// Deprecated: use matcher instead
|
||||
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -549,6 +774,7 @@ type Config struct {
|
||||
Services []*ServiceConfig `json:"services"`
|
||||
Chains []*ChainConfig `yaml:",omitempty" json:"chains,omitempty"`
|
||||
Hops []*HopConfig `yaml:",omitempty" json:"hops,omitempty"`
|
||||
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
|
||||
Authers []*AutherConfig `yaml:",omitempty" json:"authers,omitempty"`
|
||||
Admissions []*AdmissionConfig `yaml:",omitempty" json:"admissions,omitempty"`
|
||||
Bypasses []*BypassConfig `yaml:",omitempty" json:"bypasses,omitempty"`
|
||||
@@ -558,7 +784,11 @@ type Config struct {
|
||||
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
|
||||
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
|
||||
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
|
||||
P2Ps []*P2PConfig `yaml:"p2ps,omitempty" json:"p2ps,omitempty"`
|
||||
Caches []*CacheConfig `yaml:",omitempty" json:"caches,omitempty"`
|
||||
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
|
||||
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
|
||||
RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"`
|
||||
Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"`
|
||||
@@ -575,23 +805,26 @@ func (c *Config) Load() error {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.Unmarshal(c)
|
||||
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
|
||||
}
|
||||
|
||||
func (c *Config) Read(r io.Reader) error {
|
||||
func (c *Config) Read(r io.Reader, configType string) error {
|
||||
v.SetConfigType(configType)
|
||||
if err := v.ReadConfig(r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.Unmarshal(c)
|
||||
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
|
||||
}
|
||||
|
||||
func (c *Config) ReadFile(file string) error {
|
||||
v.SetConfigFile(file)
|
||||
v.SetConfigType(strings.TrimPrefix(filepath.Ext(file), ".")) // force format from extension
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.Unmarshal(c)
|
||||
configFile.Store(file)
|
||||
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
|
||||
}
|
||||
|
||||
func (c *Config) Write(w io.Writer, format string) error {
|
||||
@@ -604,9 +837,8 @@ func (c *Config) Write(w io.Writer, format string) error {
|
||||
case "yaml":
|
||||
fallthrough
|
||||
default:
|
||||
enc := yaml.NewEncoder(w)
|
||||
enc := yaml.NewEncoder(w, yaml.Indent(2))
|
||||
defer enc.Close()
|
||||
enc.SetIndent(2)
|
||||
|
||||
return enc.Encode(c)
|
||||
}
|
||||
|
||||
+328
-150
@@ -1,12 +1,39 @@
|
||||
// Package loader converts a parsed [config.Config] into running components
|
||||
// by parsing each config section and registering the results into the
|
||||
// global registries. Components are registered in dependency order so that
|
||||
// when a component reads its dependencies from registries during parsing,
|
||||
// those dependencies are already available.
|
||||
package loader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/ingress"
|
||||
"github.com/go-gost/core/limiter/conn"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
"github.com/go-gost/core/limiter/traffic"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer"
|
||||
"github.com/go-gost/core/recorder"
|
||||
reg "github.com/go-gost/core/registry"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/router"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
admission_parser "github.com/go-gost/x/config/parsing/admission"
|
||||
auth_parser "github.com/go-gost/x/config/parsing/auth"
|
||||
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
|
||||
cache_parser "github.com/go-gost/x/config/parsing/cache"
|
||||
chain_parser "github.com/go-gost/x/config/parsing/chain"
|
||||
hop_parser "github.com/go-gost/x/config/parsing/hop"
|
||||
hosts_parser "github.com/go-gost/x/config/parsing/hosts"
|
||||
@@ -14,25 +41,42 @@ import (
|
||||
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
observer_parser "github.com/go-gost/x/config/parsing/observer"
|
||||
p2p_parser "github.com/go-gost/x/config/parsing/p2p"
|
||||
quota_parser "github.com/go-gost/x/config/parsing/quota"
|
||||
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
|
||||
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
|
||||
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
|
||||
router_parser "github.com/go-gost/x/config/parsing/router"
|
||||
sd_parser "github.com/go-gost/x/config/parsing/sd"
|
||||
service_parser "github.com/go-gost/x/config/parsing/service"
|
||||
quota "github.com/go-gost/x/limiter/quota"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// defaultLoader is a singleton loader used by the top-level Load function.
|
||||
var (
|
||||
defaultLoader *loader = &loader{}
|
||||
)
|
||||
|
||||
// Load parses all config sections from cfg and registers the resulting
|
||||
// components into the global registries, then sets up the default logger
|
||||
// and TLS config.
|
||||
func Load(cfg *config.Config) error {
|
||||
return defaultLoader.Load(cfg)
|
||||
}
|
||||
|
||||
// loader holds no state; its Load method is the entry point for converting
|
||||
// a config into running components.
|
||||
type loader struct{}
|
||||
|
||||
// Load builds the default TLS config, registers all named components from
|
||||
// cfg into the global registries, and sets the default logger and TLS config.
|
||||
func (l *loader) Load(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logCfg := cfg.Log
|
||||
if logCfg == nil {
|
||||
logCfg = &config.LogConfig{}
|
||||
@@ -43,6 +87,7 @@ func (l *loader) Load(cfg *config.Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsing.SetDefaultTLSConfig(tlsCfg)
|
||||
|
||||
if err := register(cfg); err != nil {
|
||||
@@ -52,179 +97,312 @@ func (l *loader) Load(cfg *config.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// named is a generic name+value pair used to buffer parsed components
|
||||
// before registering them.
|
||||
type named[T any] struct {
|
||||
name string
|
||||
v T
|
||||
}
|
||||
|
||||
// registerGroup replaces all entries in r with the given entries. Old entries
|
||||
// are unregistered first (see unregisterAll); if any new entry fails to
|
||||
// register, the group is left partially updated (matching the historical
|
||||
// reload behavior for intra-group failures).
|
||||
func registerGroup[T any](entries []named[T], r reg.Registry[T]) error {
|
||||
unregisterAll(r)
|
||||
for _, e := range entries {
|
||||
if err := r.Register(e.name, e.v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unregisterAll removes every entry from r. registry.Unregister closes a value
|
||||
// before deleting it when it implements io.Closer, so for services this frees
|
||||
// the bound port (a service's Close closes its listener).
|
||||
func unregisterAll[T any](r reg.Registry[T]) {
|
||||
for name := range r.GetAll() {
|
||||
r.Unregister(name)
|
||||
}
|
||||
}
|
||||
|
||||
// register parses config sections and registers them into the global
|
||||
// registries. Groups are processed in dependency order: leaf components
|
||||
// first, then hops, chains, and finally services. This ensures that
|
||||
// when a component reads its dependencies from registries during
|
||||
// parsing, those dependencies are already registered.
|
||||
func register(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for name := range registry.LoggerRegistry().GetAll() {
|
||||
registry.LoggerRegistry().Unregister(name)
|
||||
}
|
||||
for _, loggerCfg := range cfg.Loggers {
|
||||
if err := registry.LoggerRegistry().Register(loggerCfg.Name, logger_parser.ParseLogger(loggerCfg)); err != nil {
|
||||
// --- leaf components (no inter-group registry dependencies) ---
|
||||
|
||||
{
|
||||
var entries []named[logger.Logger]
|
||||
for _, c := range cfg.Loggers {
|
||||
entries = append(entries, named[logger.Logger]{c.Name, logger_parser.ParseLogger(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.LoggerRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.AutherRegistry().GetAll() {
|
||||
registry.AutherRegistry().Unregister(name)
|
||||
}
|
||||
for _, autherCfg := range cfg.Authers {
|
||||
if err := registry.AutherRegistry().Register(autherCfg.Name, auth_parser.ParseAuther(autherCfg)); err != nil {
|
||||
{
|
||||
var entries []named[auth.Authenticator]
|
||||
for _, c := range cfg.Authers {
|
||||
entries = append(entries, named[auth.Authenticator]{c.Name, auth_parser.ParseAuther(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.AutherRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.AdmissionRegistry().GetAll() {
|
||||
registry.AdmissionRegistry().Unregister(name)
|
||||
}
|
||||
for _, admissionCfg := range cfg.Admissions {
|
||||
if err := registry.AdmissionRegistry().Register(admissionCfg.Name, admission_parser.ParseAdmission(admissionCfg)); err != nil {
|
||||
{
|
||||
var entries []named[admission.Admission]
|
||||
for _, c := range cfg.Admissions {
|
||||
entries = append(entries, named[admission.Admission]{c.Name, admission_parser.ParseAdmission(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.AdmissionRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.BypassRegistry().GetAll() {
|
||||
registry.BypassRegistry().Unregister(name)
|
||||
}
|
||||
for _, bypassCfg := range cfg.Bypasses {
|
||||
if err := registry.BypassRegistry().Register(bypassCfg.Name, bypass_parser.ParseBypass(bypassCfg)); err != nil {
|
||||
{
|
||||
var entries []named[bypass.Bypass]
|
||||
for _, c := range cfg.Bypasses {
|
||||
entries = append(entries, named[bypass.Bypass]{c.Name, bypass_parser.ParseBypass(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.BypassRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.ResolverRegistry().GetAll() {
|
||||
registry.ResolverRegistry().Unregister(name)
|
||||
}
|
||||
for _, resolverCfg := range cfg.Resolvers {
|
||||
r, err := resolver_parser.ParseResolver(resolverCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.ResolverRegistry().Register(resolverCfg.Name, r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.HostsRegistry().GetAll() {
|
||||
registry.HostsRegistry().Unregister(name)
|
||||
}
|
||||
for _, hostsCfg := range cfg.Hosts {
|
||||
if err := registry.HostsRegistry().Register(hostsCfg.Name, hosts_parser.ParseHostMapper(hostsCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.IngressRegistry().GetAll() {
|
||||
registry.IngressRegistry().Unregister(name)
|
||||
}
|
||||
for _, ingressCfg := range cfg.Ingresses {
|
||||
if err := registry.IngressRegistry().Register(ingressCfg.Name, ingress_parser.ParseIngress(ingressCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.RouterRegistry().GetAll() {
|
||||
registry.RouterRegistry().Unregister(name)
|
||||
}
|
||||
for _, routerCfg := range cfg.Routers {
|
||||
if err := registry.RouterRegistry().Register(routerCfg.Name, router_parser.ParseRouter(routerCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.SDRegistry().GetAll() {
|
||||
registry.SDRegistry().Unregister(name)
|
||||
}
|
||||
for _, sdCfg := range cfg.SDs {
|
||||
if err := registry.SDRegistry().Register(sdCfg.Name, sd_parser.ParseSD(sdCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.ObserverRegistry().GetAll() {
|
||||
registry.ObserverRegistry().Unregister(name)
|
||||
}
|
||||
for _, observerCfg := range cfg.Observers {
|
||||
if err := registry.ObserverRegistry().Register(observerCfg.Name, observer_parser.ParseObserver(observerCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.RecorderRegistry().GetAll() {
|
||||
registry.RecorderRegistry().Unregister(name)
|
||||
}
|
||||
for _, recorderCfg := range cfg.Recorders {
|
||||
if err := registry.RecorderRegistry().Register(recorderCfg.Name, recorder_parser.ParseRecorder(recorderCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.TrafficLimiterRegistry().GetAll() {
|
||||
registry.TrafficLimiterRegistry().Unregister(name)
|
||||
}
|
||||
for _, limiterCfg := range cfg.Limiters {
|
||||
if err := registry.TrafficLimiterRegistry().Register(limiterCfg.Name, limiter_parser.ParseTrafficLimiter(limiterCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.ConnLimiterRegistry().GetAll() {
|
||||
registry.ConnLimiterRegistry().Unregister(name)
|
||||
}
|
||||
for _, limiterCfg := range cfg.CLimiters {
|
||||
if err := registry.ConnLimiterRegistry().Register(limiterCfg.Name, limiter_parser.ParseConnLimiter(limiterCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.RateLimiterRegistry().GetAll() {
|
||||
registry.RateLimiterRegistry().Unregister(name)
|
||||
}
|
||||
for _, limiterCfg := range cfg.RLimiters {
|
||||
if err := registry.RateLimiterRegistry().Register(limiterCfg.Name, limiter_parser.ParseRateLimiter(limiterCfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.HopRegistry().GetAll() {
|
||||
registry.HopRegistry().Unregister(name)
|
||||
}
|
||||
for _, hopCfg := range cfg.Hops {
|
||||
hop, err := hop_parser.ParseHop(hopCfg, logger.Default())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.HopRegistry().Register(hopCfg.Name, hop); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.ChainRegistry().GetAll() {
|
||||
registry.ChainRegistry().Unregister(name)
|
||||
}
|
||||
for _, chainCfg := range cfg.Chains {
|
||||
c, err := chain_parser.ParseChain(chainCfg, logger.Default())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.ChainRegistry().Register(chainCfg.Name, c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for name := range registry.ServiceRegistry().GetAll() {
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
for _, svcCfg := range cfg.Services {
|
||||
svc, err := service_parser.ParseService(svcCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if svc != nil {
|
||||
if err := registry.ServiceRegistry().Register(svcCfg.Name, svc); err != nil {
|
||||
{
|
||||
var entries []named[resolver.Resolver]
|
||||
for _, c := range cfg.Resolvers {
|
||||
r, err := resolver_parser.ParseResolver(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, named[resolver.Resolver]{c.Name, r})
|
||||
}
|
||||
if err := registerGroup(entries, registry.ResolverRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[hosts.HostMapper]
|
||||
for _, c := range cfg.Hosts {
|
||||
entries = append(entries, named[hosts.HostMapper]{c.Name, hosts_parser.ParseHostMapper(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.HostsRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[ingress.Ingress]
|
||||
for _, c := range cfg.Ingresses {
|
||||
entries = append(entries, named[ingress.Ingress]{c.Name, ingress_parser.ParseIngress(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.IngressRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[router.Router]
|
||||
for _, c := range cfg.Routers {
|
||||
entries = append(entries, named[router.Router]{c.Name, router_parser.ParseRouter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.RouterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[sd.SD]
|
||||
for _, c := range cfg.SDs {
|
||||
entries = append(entries, named[sd.SD]{c.Name, sd_parser.ParseSD(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.SDRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[observer.Observer]
|
||||
for _, c := range cfg.Observers {
|
||||
entries = append(entries, named[observer.Observer]{c.Name, observer_parser.ParseObserver(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.ObserverRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[recorder.Recorder]
|
||||
for _, c := range cfg.Recorders {
|
||||
entries = append(entries, named[recorder.Recorder]{c.Name, recorder_parser.ParseRecorder(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.RecorderRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[rewriter.Rewriter]
|
||||
for _, c := range cfg.Rewriters {
|
||||
entries = append(entries, named[rewriter.Rewriter]{c.Name, rewriter_parser.ParseRewriter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.RewriterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[xp2p.Tunnel]
|
||||
for _, c := range cfg.P2Ps {
|
||||
entries = append(entries, named[xp2p.Tunnel]{c.Name, p2p_parser.ParseP2P(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.P2PRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[cache.Cache]
|
||||
for _, c := range cfg.Caches {
|
||||
entries = append(entries, named[cache.Cache]{c.Name, cache_parser.ParseCache(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.CacheRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[traffic.TrafficLimiter]
|
||||
for _, c := range cfg.Limiters {
|
||||
entries = append(entries, named[traffic.TrafficLimiter]{c.Name, limiter_parser.ParseTrafficLimiter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.TrafficLimiterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[*quota.Limiter]
|
||||
for _, c := range cfg.Quotas {
|
||||
entries = append(entries, named[*quota.Limiter]{c.Name, quota_parser.ParseQuotaLimiter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.QuotaLimiterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[conn.ConnLimiter]
|
||||
for _, c := range cfg.CLimiters {
|
||||
entries = append(entries, named[conn.ConnLimiter]{c.Name, limiter_parser.ParseConnLimiter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.ConnLimiterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[rate.RateLimiter]
|
||||
for _, c := range cfg.RLimiters {
|
||||
entries = append(entries, named[rate.RateLimiter]{c.Name, limiter_parser.ParseRateLimiter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.RateLimiterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- nodes (definitions referenced by hops) ---
|
||||
|
||||
{
|
||||
var entries []named[*config.NodeConfig]
|
||||
for _, c := range cfg.Nodes {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
c.Name = strings.TrimSpace(c.Name)
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("global node name is required")
|
||||
}
|
||||
entries = append(entries, named[*config.NodeConfig]{c.Name, c})
|
||||
}
|
||||
if err := registerGroup(entries, registry.NodeRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- hops (references nodes, bypasses, resolvers, hosts from registries) ---
|
||||
|
||||
{
|
||||
var entries []named[hop.Hop]
|
||||
for _, c := range cfg.Hops {
|
||||
h, err := hop_parser.ParseHop(c, logger.Default())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, named[hop.Hop]{c.Name, h})
|
||||
}
|
||||
if err := registerGroup(entries, registry.HopRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- chains (references hops from registries) ---
|
||||
|
||||
{
|
||||
var entries []named[chain.Chainer]
|
||||
for _, c := range cfg.Chains {
|
||||
ch, err := chain_parser.ParseChain(c, logger.Default())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, named[chain.Chainer]{c.Name, ch})
|
||||
}
|
||||
if err := registerGroup(entries, registry.ChainRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- services (references chains, resolvers, hosts, recorders,
|
||||
// limiters, observers, hops from registries) ---
|
||||
//
|
||||
// Services are the only component group whose construction binds a port:
|
||||
// service_parser.ParseService calls listener.Init, which binds. The generic
|
||||
// registerGroup "parse-all-then-swap" sequence used by the other groups
|
||||
// would therefore bind every new listener while the old services are still
|
||||
// listening, causing EADDRINUSE on SIGHUP reload (issue #754, regressed by
|
||||
// 82e7e50). Instead, unregister all old services first — unregisterAll
|
||||
// closes each one (a service implements io.Closer), freeing its port —
|
||||
// then parse, bind, and register the new ones.
|
||||
//
|
||||
// Trade-off: a parse error in the loop below leaves the registry partially
|
||||
// updated (the old services are already closed and only the services parsed
|
||||
// before the failure are registered). The construct/bind split would be
|
||||
// needed for atomic reload, but this restores the pre-82e7e50 behavior.
|
||||
{
|
||||
unregisterAll(registry.ServiceRegistry())
|
||||
|
||||
for _, c := range cfg.Services {
|
||||
svc, err := service_parser.ParseService(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if svc != nil {
|
||||
if err := registry.ServiceRegistry().Register(c.Name, svc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,23 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// ParseAdmission converts an AdmissionConfig into an admission.Admission. It
|
||||
// resolves plugin backends (HTTP or gRPC) when cfg.Plugin is set, or
|
||||
// constructs an in-process admission controller with optional file, Redis, and
|
||||
// HTTP hot-reload support.
|
||||
func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -31,6 +39,7 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
|
||||
case "http":
|
||||
return admission_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
@@ -74,6 +83,9 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
|
||||
return xadmission.NewAdmission(opts...)
|
||||
}
|
||||
|
||||
// List resolves one or more admission controller names from the registry. It
|
||||
// returns only the controllers that were found, skipping any that are not
|
||||
// registered.
|
||||
func List(name string, names ...string) []admission.Admission {
|
||||
var admissions []admission.Admission
|
||||
if adm := registry.AdmissionRegistry().Get(name); adm != nil {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseAdmission_Nil(t *testing.T) {
|
||||
adm := ParseAdmission(nil)
|
||||
if adm != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_PluginHTTP(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "http-adm",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_PluginGRPC(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "grpc-adm",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "admission.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_PluginDefaultType(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "default-adm",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_WithMatchers(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "matcher-adm",
|
||||
Matchers: []string{"192.168.0.0/16", "10.0.0.1"},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_Whitelist(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "whitelist-adm",
|
||||
Whitelist: true,
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_Reverse(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "reverse-adm",
|
||||
Reverse: true,
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_FileLoader(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "file-adm",
|
||||
File: &config.FileLoader{Path: "/tmp/admission.txt"},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_HTTPLoader(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "http-adm",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/list"},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdmission_RedisLoader(t *testing.T) {
|
||||
adm := ParseAdmission(&config.AdmissionConfig{
|
||||
Name: "redis-adm",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "admission-set",
|
||||
},
|
||||
})
|
||||
if adm == nil {
|
||||
t.Fatal("expected non-nil admission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_ReturnsEntries(t *testing.T) {
|
||||
// The registry returns a lazy wrapper for any non-empty name.
|
||||
got := List("test-admission", "test-admission-2")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_EmptyNameReturnsNothing(t *testing.T) {
|
||||
// Empty names are filtered by the registry Get.
|
||||
got := List("")
|
||||
if len(got) != 0 {
|
||||
t.Fatal("expected empty list for empty name")
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,22 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// ParseAuther converts an AutherConfig into an auth.Authenticator. It handles
|
||||
// plugin backends (HTTP or gRPC), inline auth credentials, and optional file,
|
||||
// Redis, and HTTP hot-reload sources.
|
||||
func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -35,6 +42,7 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
|
||||
case "http":
|
||||
return auth_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
@@ -85,6 +93,9 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
|
||||
return xauth.NewAuthenticator(opts...)
|
||||
}
|
||||
|
||||
// ParseAutherFromAuth creates a simple authenticator from a single
|
||||
// username/password pair in an AuthConfig. Returns nil if the config is nil or
|
||||
// has no username.
|
||||
func ParseAutherFromAuth(au *config.AuthConfig) auth.Authenticator {
|
||||
if au == nil || au.Username == "" {
|
||||
return nil
|
||||
@@ -101,6 +112,9 @@ func ParseAutherFromAuth(au *config.AuthConfig) auth.Authenticator {
|
||||
)
|
||||
}
|
||||
|
||||
// Info extracts authentication credentials from an AuthConfig as a
|
||||
// url.Userinfo. It supports reading the first line from a password file when
|
||||
// cfg.File is set, or falls back to inline username/password fields.
|
||||
func Info(cfg *config.AuthConfig) *url.Userinfo {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
@@ -158,6 +172,9 @@ func parseInfo(r io.Reader, max int) (infos []*url.Userinfo, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// List resolves one or more authenticator names from the registry. It returns
|
||||
// only the authenticators that were found, skipping any that are not
|
||||
// registered.
|
||||
func List(name string, names ...string) []auth.Authenticator {
|
||||
var authers []auth.Authenticator
|
||||
if auther := registry.AutherRegistry().Get(name); auther != nil {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestInfo_Nil(t *testing.T) {
|
||||
if u := Info(nil); u != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_EmptyUsername(t *testing.T) {
|
||||
u := Info(&config.AuthConfig{Username: ""})
|
||||
if u != nil {
|
||||
t.Fatal("expected nil for empty username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_UsernameOnly(t *testing.T) {
|
||||
u := Info(&config.AuthConfig{Username: "user"})
|
||||
if u == nil {
|
||||
t.Fatal("expected non-nil userinfo")
|
||||
}
|
||||
if u.Username() != "user" {
|
||||
t.Fatalf("username = %q, want %q", u.Username(), "user")
|
||||
}
|
||||
if _, hasPassword := u.Password(); hasPassword {
|
||||
t.Fatal("expected no password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_UsernameAndPassword(t *testing.T) {
|
||||
u := Info(&config.AuthConfig{Username: "user", Password: "pass"})
|
||||
if u == nil {
|
||||
t.Fatal("expected non-nil userinfo")
|
||||
}
|
||||
if u.Username() != "user" {
|
||||
t.Fatalf("username = %q, want %q", u.Username(), "user")
|
||||
}
|
||||
if p, ok := u.Password(); !ok || p != "pass" {
|
||||
t.Fatalf("password = %q, want %q", p, "pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_WithFile(t *testing.T) {
|
||||
// File that doesn't exist - should fall back to username
|
||||
u := Info(&config.AuthConfig{
|
||||
Username: "fallback",
|
||||
Password: "pass",
|
||||
File: "/nonexistent/auth_file",
|
||||
})
|
||||
if u == nil {
|
||||
t.Fatal("expected non-nil userinfo (fallback)")
|
||||
}
|
||||
if u.Username() != "fallback" {
|
||||
t.Fatalf("username = %q, want %q", u.Username(), "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutherFromAuth_Nil(t *testing.T) {
|
||||
a := ParseAutherFromAuth(nil)
|
||||
if a != nil {
|
||||
t.Fatal("expected nil for nil auth config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutherFromAuth_EmptyUsername(t *testing.T) {
|
||||
a := ParseAutherFromAuth(&config.AuthConfig{Username: ""})
|
||||
if a != nil {
|
||||
t.Fatal("expected nil for empty username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutherFromAuth_Valid(t *testing.T) {
|
||||
a := ParseAutherFromAuth(&config.AuthConfig{
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_Nil(t *testing.T) {
|
||||
a := ParseAuther(nil)
|
||||
if a != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_WithUsers(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "test-auth",
|
||||
Auths: []*config.AuthConfig{
|
||||
{Username: "u1", Password: "p1"},
|
||||
{Username: "u2", Password: "p2"},
|
||||
{Username: "", Password: "nope"}, // skipped
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_WithEmptyAuths(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "empty-auth",
|
||||
Auths: []*config.AuthConfig{},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_PluginHTTP(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "http-plugin",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil plugin authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_PluginGRPC(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "grpc-plugin",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "auth.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil plugin authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_PluginDefaultType(t *testing.T) {
|
||||
// Default plugin type should be treated as gRPC
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "default-plugin",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil plugin authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_ParseFile(t *testing.T) {
|
||||
// parseInfo with empty reader should return nil
|
||||
u := Info(&config.AuthConfig{
|
||||
File: "/nonexistent/file",
|
||||
})
|
||||
if u != nil {
|
||||
t.Fatal("expected nil for nonexistent file (should not crash)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_ReturnsEntries(t *testing.T) {
|
||||
// The registry returns a lazy wrapper for any non-empty name.
|
||||
got := List("test-auther", "test-auther-2")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_EmptyNameReturnsNothing(t *testing.T) {
|
||||
// Empty names are filtered by the registry Get.
|
||||
got := List("")
|
||||
if len(got) != 0 {
|
||||
t.Fatal("expected empty list for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_HTTPLoader(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "http-loader-auth",
|
||||
HTTP: &config.HTTPLoader{
|
||||
URL: "http://localhost:8080/auth",
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_FileLoader(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "file-loader-auth",
|
||||
File: &config.FileLoader{
|
||||
Path: "/tmp/auth.txt",
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuther_RedisLoader(t *testing.T) {
|
||||
a := ParseAuther(&config.AutherConfig{
|
||||
Name: "redis-loader-auth",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
},
|
||||
})
|
||||
if a == nil {
|
||||
t.Fatal("expected non-nil authenticator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfo_Integration(t *testing.T) {
|
||||
// Verify Info returns *url.Userinfo whose String() matches the standard library.
|
||||
u := Info(&config.AuthConfig{Username: "alice", Password: "secret"})
|
||||
if u == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
want := "alice:secret"
|
||||
if u.String() != want {
|
||||
t.Fatalf("String() = %q, want %q", u.String(), want)
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,22 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// ParseBypass converts a BypassConfig into a bypass.Bypass. It resolves plugin
|
||||
// backends (HTTP or gRPC) when cfg.Plugin is set, or constructs an in-process
|
||||
// bypass with optional file, Redis, and HTTP hot-reload support.
|
||||
func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -31,6 +38,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
|
||||
case "http":
|
||||
return bypass_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
@@ -46,6 +54,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
|
||||
opts := []xbypass.Option{
|
||||
xbypass.MatchersOption(cfg.Matchers),
|
||||
xbypass.WhitelistOption(cfg.Reverse || cfg.Whitelist),
|
||||
xbypass.NetworkOption(cfg.Network),
|
||||
xbypass.ReloadPeriodOption(cfg.Reload),
|
||||
xbypass.LoggerOption(logger.Default().WithFields(map[string]any{
|
||||
"kind": "bypass",
|
||||
@@ -74,6 +83,8 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
|
||||
return xbypass.NewBypass(opts...)
|
||||
}
|
||||
|
||||
// List resolves one or more bypass names from the registry. It returns only
|
||||
// the bypasses that were found, skipping any that are not registered.
|
||||
func List(name string, names ...string) []bypass.Bypass {
|
||||
var bypasses []bypass.Bypass
|
||||
if bp := registry.BypassRegistry().Get(name); bp != nil {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package bypass
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseBypass_Nil(t *testing.T) {
|
||||
bp := ParseBypass(nil)
|
||||
if bp != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_PluginHTTP(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "http-bp",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_PluginGRPC(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "grpc-bp",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "bypass.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_PluginDefaultType(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "default-bp",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_WithMatchers(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "matcher-bp",
|
||||
Matchers: []string{"127.0.0.1", "10.0.0.0/8", "*.example.com"},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_Whitelist(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "whitelist-bp",
|
||||
Whitelist: true,
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_Reverse(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "reverse-bp",
|
||||
Reverse: true,
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_FileLoader(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "file-bp",
|
||||
File: &config.FileLoader{Path: "/tmp/bypass.txt"},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_HTTPLoader(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "http-bp",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/list"},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBypass_RedisLoader(t *testing.T) {
|
||||
bp := ParseBypass(&config.BypassConfig{
|
||||
Name: "redis-bp",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "bypass-set",
|
||||
},
|
||||
})
|
||||
if bp == nil {
|
||||
t.Fatal("expected non-nil bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_ReturnsEntries(t *testing.T) {
|
||||
// The registry returns a lazy wrapper for any non-empty name.
|
||||
got := List("test-bypass", "test-bypass-2")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_EmptyNameReturnsNothing(t *testing.T) {
|
||||
// Empty names are filtered by the registry Get.
|
||||
got := List("")
|
||||
if len(got) != 0 {
|
||||
t.Fatal("expected empty list for empty name")
|
||||
}
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/logger"
|
||||
xcache "github.com/go-gost/x/cache"
|
||||
"github.com/go-gost/x/config"
|
||||
)
|
||||
|
||||
// ParseCache converts a CacheConfig into a cache.Cache. Memory and Redis
|
||||
// backends are supported. Returns nil when cfg is nil or no backend is
|
||||
// configured.
|
||||
func ParseCache(cfg *config.CacheConfig) cache.Cache {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Memory != nil {
|
||||
return xcache.NewMemoryCache(xcache.Options{
|
||||
DefaultTTL: cfg.Memory.TTL,
|
||||
MaxSize: cfg.Memory.MaxSize,
|
||||
MaxBytes: cfg.Memory.MaxBytes,
|
||||
CleanupInterval: cfg.Memory.CleanupInterval,
|
||||
Eviction: xcache.EvictionPolicy(strings.ToLower(cfg.Memory.Eviction)),
|
||||
Logger: logger.Default(),
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Redis != nil {
|
||||
return xcache.NewRedisCache(xcache.RedisOptions{
|
||||
Addr: cfg.Redis.Addr,
|
||||
DB: cfg.Redis.DB,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DefaultTTL: cfg.Redis.TTL,
|
||||
KeyPrefix: cfg.Redis.Key,
|
||||
Logger: logger.Default(),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// ParseChain converts a ChainConfig into a chain.Chainer. Each hop reference
|
||||
// is either looked up from the registry (by name) or parsed inline via
|
||||
// ParseHop. Returns nil with no error when cfg is nil.
|
||||
func ParseChain(cfg *config.ChainConfig, log logger.Logger) (chain.Chainer, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
@@ -33,6 +36,10 @@ func ParseChain(cfg *config.ChainConfig, log logger.Logger) (chain.Chainer, erro
|
||||
)
|
||||
|
||||
for _, ch := range cfg.Hops {
|
||||
if ch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var hop hop.Hop
|
||||
var err error
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func testLogger() logger.Logger {
|
||||
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
|
||||
}
|
||||
|
||||
func TestParseChain_Nil(t *testing.T) {
|
||||
c, err := ParseChain(nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChain_EmptyHops(t *testing.T) {
|
||||
c, err := ParseChain(&config.ChainConfig{
|
||||
Name: "empty-hops",
|
||||
Hops: []*config.HopConfig{},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected non-nil chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChain_NilHops(t *testing.T) {
|
||||
c, err := ParseChain(&config.ChainConfig{
|
||||
Name: "nil-hops",
|
||||
Hops: nil,
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected non-nil chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChain_NilHopEntry(t *testing.T) {
|
||||
c, err := ParseChain(&config.ChainConfig{
|
||||
Name: "nil-entry",
|
||||
Hops: []*config.HopConfig{nil},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected non-nil chain when hop entry is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChain_WithMetadata(t *testing.T) {
|
||||
c, err := ParseChain(&config.ChainConfig{
|
||||
Name: "meta-chain",
|
||||
Metadata: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected non-nil chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChain_NamedHop(t *testing.T) {
|
||||
// Named hop (Nodes==nil, Plugin==nil) uses registry lookup
|
||||
c, err := ParseChain(&config.ChainConfig{
|
||||
Name: "named-hop",
|
||||
Hops: []*config.HopConfig{
|
||||
{Name: "nonexistent-hop"},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected non-nil chain even with unregistered hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChain_InlineHopWithPlugin(t *testing.T) {
|
||||
// Inline hop (Nodes!=nil or Plugin!=nil) calls hop_parser.ParseHop
|
||||
c, err := ParseChain(&config.ChainConfig{
|
||||
Name: "inline-hop",
|
||||
Hops: []*config.HopConfig{
|
||||
{
|
||||
Name: "plugin-hop",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("expected non-nil chain")
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,23 @@ import (
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
xs "github.com/go-gost/x/selector"
|
||||
)
|
||||
|
||||
// ParseHop converts a HopConfig into a hop.Hop. It resolves plugin backends
|
||||
// (HTTP or gRPC), parses inline nodes with metadata inheritance from the parent
|
||||
// hop, wires up a node selector, and configures optional file, Redis, and HTTP
|
||||
// hot-reload sources. Returns nil with no error when cfg is nil.
|
||||
func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -38,6 +47,7 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
case plugin.HTTP:
|
||||
return hop_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
), nil
|
||||
@@ -50,14 +60,26 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var ppv int
|
||||
var soMark int
|
||||
ifce := cfg.Interface
|
||||
var netns string
|
||||
|
||||
if cfg.SockOpts != nil {
|
||||
soMark = cfg.SockOpts.Mark
|
||||
}
|
||||
|
||||
if cfg.Metadata != nil {
|
||||
md := metadata.NewMetadata(cfg.Metadata)
|
||||
if v := mdutil.GetString(md, parsing.MDKeyInterface); v != "" {
|
||||
ifce = v
|
||||
}
|
||||
netns = mdutil.GetString(md, "netns")
|
||||
|
||||
if v := mdutil.GetInt(md, parsing.MDKeySoMark); v > 0 {
|
||||
soMark = v
|
||||
}
|
||||
ppv = mdutil.GetInt(md, parsing.MDKeyProxyProtocol)
|
||||
netns = mdutil.GetString(md, parsing.MDKeyNetns)
|
||||
}
|
||||
|
||||
var nodes []*chain.Node
|
||||
@@ -66,21 +88,57 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
v, err = node_parser.ResolveConfig(v)
|
||||
if err != nil {
|
||||
// Fail the whole hop: static config is validated at startup,
|
||||
// so a dangling reference is a hard error. (Contrast with
|
||||
// chainHop.parseNode, which warns and skips bad nodes to
|
||||
// keep hot-reloaded hops resilient.)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build a merged metadata map for inheritance without mutating
|
||||
// the original node config.
|
||||
merged := make(map[string]any)
|
||||
for k, val := range v.Metadata {
|
||||
merged[k] = val
|
||||
}
|
||||
md := metadata.NewMetadata(merged)
|
||||
|
||||
if v.Resolver == "" {
|
||||
v.Resolver = cfg.Resolver
|
||||
}
|
||||
if v.Hosts == "" {
|
||||
v.Hosts = cfg.Hosts
|
||||
}
|
||||
if v.Interface == "" {
|
||||
v.Interface = ifce
|
||||
}
|
||||
if v.Netns == "" {
|
||||
v.Netns = netns
|
||||
}
|
||||
|
||||
if v.SockOpts == nil {
|
||||
v.SockOpts = cfg.SockOpts
|
||||
if !md.IsExists(parsing.MDKeyInterface) {
|
||||
if ifce != "" {
|
||||
merged[parsing.MDKeyInterface] = ifce
|
||||
}
|
||||
if v.Interface != "" {
|
||||
merged[parsing.MDKeyInterface] = v.Interface
|
||||
}
|
||||
}
|
||||
if !md.IsExists(parsing.MDKeySoMark) {
|
||||
if soMark != 0 {
|
||||
merged[parsing.MDKeySoMark] = soMark
|
||||
}
|
||||
if v.SockOpts != nil && v.SockOpts.Mark != 0 {
|
||||
merged[parsing.MDKeySoMark] = v.SockOpts.Mark
|
||||
}
|
||||
}
|
||||
if !md.IsExists(parsing.MDKeyProxyProtocol) && ppv > 0 {
|
||||
merged[parsing.MDKeyProxyProtocol] = ppv
|
||||
}
|
||||
if !md.IsExists(parsing.MDKeyNetns) {
|
||||
if netns != "" {
|
||||
merged[parsing.MDKeyNetns] = netns
|
||||
}
|
||||
if v.Netns != "" {
|
||||
merged[parsing.MDKeyNetns] = v.Netns
|
||||
}
|
||||
}
|
||||
|
||||
if v.Connector == nil {
|
||||
@@ -97,7 +155,11 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
v.Dialer.Type = "tcp"
|
||||
}
|
||||
|
||||
// Temporarily swap merged metadata so ParseNode sees inherited values.
|
||||
origMeta := v.Metadata
|
||||
v.Metadata = merged
|
||||
node, err := node_parser.ParseNode(cfg.Name, v, log)
|
||||
v.Metadata = origMeta
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,10 +173,21 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
sel = selector_parser.DefaultNodeSelector()
|
||||
}
|
||||
|
||||
maxFails, timeout := xs.DefaultMaxFails, xs.DefaultFailTimeout
|
||||
if cfg.Selector != nil {
|
||||
if cfg.Selector.MaxFails > 0 {
|
||||
maxFails = cfg.Selector.MaxFails
|
||||
}
|
||||
if cfg.Selector.FailTimeout > 0 {
|
||||
timeout = cfg.Selector.FailTimeout
|
||||
}
|
||||
}
|
||||
|
||||
opts := []xhop.Option{
|
||||
xhop.NameOption(cfg.Name),
|
||||
xhop.NodeOption(nodes...),
|
||||
xhop.SelectorOption(sel),
|
||||
xhop.FailFilterSettingsOption(maxFails, timeout),
|
||||
xhop.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
xhop.ReloadPeriodOption(cfg.Reload),
|
||||
xhop.LoggerOption(log.WithFields(map[string]any{
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/registry"
|
||||
|
||||
// Register connector and dialer implementations needed for node parsing.
|
||||
_ "github.com/go-gost/x/connector/http"
|
||||
_ "github.com/go-gost/x/dialer/tcp"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func testLogger() logger.Logger {
|
||||
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
|
||||
}
|
||||
|
||||
func TestParseHop_Nil(t *testing.T) {
|
||||
h, err := ParseHop(nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_PluginHTTP(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "http-hop",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_PluginGRPC(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "grpc-hop",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "hop.local",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_PluginDefaultType(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "default-hop",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_PluginHTTPWithTLS(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "http-tls-hop",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9003",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: false,
|
||||
ServerName: "hop.example.com",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_PluginGRPCWithTLSInsecure(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "grpc-insecure-hop",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9004",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: false, // InsecureSkipVerify = true
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NoNodesNoPlugin(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "empty-hop",
|
||||
Nodes: nil,
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop even with no nodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_WithBypass(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "bypass-hop",
|
||||
Bypass: "bypass1",
|
||||
Bypasses: []string{"bypass2", "bypass3"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_WithSelector(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "selector-hop",
|
||||
Selector: &config.SelectorConfig{
|
||||
Strategy: "round",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_WithNodeNilEntries(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "nil-nodes-hop",
|
||||
Nodes: []*config.NodeConfig{
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop when nodes contain nil entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_GlobalNodeReference(t *testing.T) {
|
||||
global := &config.NodeConfig{Name: "global-node", Addr: "example.com:8080"}
|
||||
registry.NodeRegistry().Register(global.Name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
|
||||
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "reference-hop",
|
||||
Nodes: []*config.NodeConfig{{Name: global.Name}},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 || nodes[0].Name != global.Name || nodes[0].Addr != global.Addr {
|
||||
t.Fatalf("referenced nodes = %v, want node %q at %q", nodes, global.Name, global.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_GlobalNodeReferenceNotFound(t *testing.T) {
|
||||
_, err := ParseHop(&config.HopConfig{
|
||||
Name: "reference-hop",
|
||||
Nodes: []*config.NodeConfig{{Name: "missing-global-node"}},
|
||||
}, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing global node error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NodeWithAdditionalFieldRemainsInline(t *testing.T) {
|
||||
global := &config.NodeConfig{Name: "shared-name", Addr: "global.example:8080"}
|
||||
registry.NodeRegistry().Register(global.Name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
|
||||
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "inline-hop",
|
||||
Nodes: []*config.NodeConfig{{
|
||||
Name: global.Name,
|
||||
Addr: "inline.example:8080",
|
||||
}},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].Addr != "inline.example:8080" {
|
||||
t.Fatalf("node addr = %q, want inline node address", nodes[0].Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_GlobalNodeCreatesIndependentRuntimeNodes(t *testing.T) {
|
||||
global := &config.NodeConfig{Name: "independent-node", Addr: "example.com:8080"}
|
||||
registry.NodeRegistry().Register(global.Name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
|
||||
|
||||
parse := func(name string) *chain.Node {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: name, Nodes: []*config.NodeConfig{{Name: global.Name}},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHop: %v", err)
|
||||
}
|
||||
return h.(hop.NodeList).Nodes()[0]
|
||||
}
|
||||
|
||||
if first, second := parse("hop-1"), parse("hop-2"); first == second {
|
||||
t.Fatal("global node references must create independent runtime nodes")
|
||||
}
|
||||
if global.Connector != nil || global.Dialer != nil {
|
||||
t.Fatal("parsing a reference must not mutate the global definition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_SockOptsMark(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "sockopts-hop",
|
||||
SockOpts: &config.SockOptsConfig{
|
||||
Mark: 1234,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_MetadataSoMark(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "md-somark-hop",
|
||||
Metadata: map[string]any{
|
||||
parsing.MDKeySoMark: 5678,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_MetadataInterface(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "md-iface-hop",
|
||||
Metadata: map[string]any{
|
||||
parsing.MDKeyInterface: "eth0",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_MetadataProxyProtocol(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "md-ppv-hop",
|
||||
Metadata: map[string]any{
|
||||
parsing.MDKeyProxyProtocol: 2,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_MetadataNetns(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "md-netns-hop",
|
||||
Metadata: map[string]any{
|
||||
parsing.MDKeyNetns: "mynetns",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_InterfaceDeprecated(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "iface-deprecated-hop",
|
||||
Interface: "eth1",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_FileLoader(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "file-hop",
|
||||
File: &config.FileLoader{Path: "/tmp/hop.txt"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_HTTPLoader(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "http-hop",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/hop"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_RedisLoader(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "redis-hop",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "hop-key",
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
DB: 1,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NodeInheritsInterface(t *testing.T) {
|
||||
// When hop-level interface is set and node-level is not, node inherits.
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "inherit-iface-hop",
|
||||
Interface: "eth0",
|
||||
Nodes: []*config.NodeConfig{
|
||||
{
|
||||
Name: "node1",
|
||||
Addr: "example.com:8080",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if got := mdutil.GetString(nodes[0].Metadata(), parsing.MDKeyInterface); got != "eth0" {
|
||||
t.Fatalf("inherited interface = %q, want %q", got, "eth0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NodeInheritsSoMark(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "inherit-somark-hop",
|
||||
SockOpts: &config.SockOptsConfig{
|
||||
Mark: 9999,
|
||||
},
|
||||
Nodes: []*config.NodeConfig{
|
||||
{
|
||||
Name: "node1",
|
||||
Addr: "example.com:8080",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if got := mdutil.GetInt(nodes[0].Metadata(), parsing.MDKeySoMark); got != 9999 {
|
||||
t.Fatalf("inherited so_mark = %d, want %d", got, 9999)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NodeInheritsNetns(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "inherit-netns-hop",
|
||||
Metadata: map[string]any{
|
||||
parsing.MDKeyNetns: "mynetns",
|
||||
},
|
||||
Nodes: []*config.NodeConfig{
|
||||
{
|
||||
Name: "node1",
|
||||
Addr: "example.com:8080",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if got := mdutil.GetString(nodes[0].Metadata(), parsing.MDKeyNetns); got != "mynetns" {
|
||||
t.Fatalf("inherited netns = %q, want %q", got, "mynetns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NodeResolverHostsInheritance(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "inherit-resolver-hop",
|
||||
Resolver: "my-resolver",
|
||||
Hosts: "my-hosts",
|
||||
Nodes: []*config.NodeConfig{
|
||||
{
|
||||
Name: "node1",
|
||||
Addr: "example.com:8080",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil hop")
|
||||
}
|
||||
// Verify the node was created (no panic from nil resolver/hosts).
|
||||
// The resolver/hosts names are inherited, but registry lookup returns
|
||||
// nil since these names are not registered in this test.
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,21 @@ import (
|
||||
hosts_plugin "github.com/go-gost/x/hosts/plugin"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseHostMapper converts a HostsConfig into a hosts.HostMapper. It resolves
|
||||
// plugin backends (HTTP or gRPC), parses inline IP→hostname mappings, and
|
||||
// configures optional file, Redis, and HTTP hot-reload support.
|
||||
func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -31,6 +38,7 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
|
||||
case "http":
|
||||
return hosts_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package hosts
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseHostMapper_Nil(t *testing.T) {
|
||||
hm := ParseHostMapper(nil)
|
||||
if hm != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_PluginHTTP(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "http-hosts",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_PluginGRPC(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "grpc-hosts",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "hosts.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_PluginDefaultType(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "default-hosts",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_WithMappings(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "mapping-hosts",
|
||||
Mappings: []*config.HostMappingConfig{
|
||||
{IP: "127.0.0.1", Hostname: "localhost"},
|
||||
{IP: "::1", Hostname: "ipv6-localhost"},
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_EmptyMapping(t *testing.T) {
|
||||
// Mappings with empty IP or Hostname should be skipped
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "partial-hosts",
|
||||
Mappings: []*config.HostMappingConfig{
|
||||
{IP: "", Hostname: "nope"}, // skipped
|
||||
{IP: "127.0.0.1", Hostname: ""}, // skipped
|
||||
{IP: "127.0.0.1", Hostname: "valid"}, // kept
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper even with partial mappings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_InvalidIP(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "invalid-ip",
|
||||
Mappings: []*config.HostMappingConfig{
|
||||
{IP: "not-an-ip", Hostname: "example.com"}, // skipped
|
||||
{IP: "127.0.0.1", Hostname: "valid"}, // kept
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper even with invalid IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_FileLoader(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "file-hosts",
|
||||
File: &config.FileLoader{Path: "/etc/hosts"},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_HTTPLoader(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "http-hosts",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/hosts"},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_RedisLoader(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "redis-hosts",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "hosts-key",
|
||||
Type: "set",
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostMapper_RedisListLoader(t *testing.T) {
|
||||
hm := ParseHostMapper(&config.HostsConfig{
|
||||
Name: "redis-list-hosts",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "hosts-list",
|
||||
Type: "list",
|
||||
},
|
||||
})
|
||||
if hm == nil {
|
||||
t.Fatal("expected non-nil host mapper")
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,21 @@ import (
|
||||
ingress_plugin "github.com/go-gost/x/ingress/plugin"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseIngress converts an IngressConfig into an ingress.Ingress. It resolves
|
||||
// plugin backends (HTTP or gRPC), parses inline hostname→endpoint rules, and
|
||||
// configures optional file, Redis, and HTTP hot-reload support.
|
||||
func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -30,6 +37,7 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
|
||||
case "http":
|
||||
return ingress_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package ingress
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseIngress_Nil(t *testing.T) {
|
||||
ing := ParseIngress(nil)
|
||||
if ing != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_PluginHTTP(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "http-ing",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_PluginGRPC(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "grpc-ing",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "ingress.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_PluginDefaultType(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "default-ing",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_WithRules(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "rule-ing",
|
||||
Rules: []*config.IngressRuleConfig{
|
||||
{Hostname: "example.com", Endpoint: "10.0.0.1:8080"},
|
||||
{Hostname: "test.com", Endpoint: "10.0.0.2:9090"},
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_EmptyRules(t *testing.T) {
|
||||
// Rules with empty hostname or endpoint should be skipped
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "partial-ing",
|
||||
Rules: []*config.IngressRuleConfig{
|
||||
{Hostname: "", Endpoint: "nope:8080"}, // skipped
|
||||
{Hostname: "nope.com", Endpoint: ""}, // skipped
|
||||
{Hostname: "valid.com", Endpoint: "10.0.0.1:8080"}, // kept
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress even with partial rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_FileLoader(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "file-ing",
|
||||
File: &config.FileLoader{Path: "/tmp/ingress.txt"},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_HTTPLoader(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "http-ing",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/ingress"},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_RedisLoader(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "redis-ing",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "ingress-key",
|
||||
Type: "hash",
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngress_RedisSetLoader(t *testing.T) {
|
||||
ing := ParseIngress(&config.IngressConfig{
|
||||
Name: "redis-set-ing",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "ingress-set",
|
||||
Type: "set",
|
||||
},
|
||||
})
|
||||
if ing == nil {
|
||||
t.Fatal("expected non-nil ingress")
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,21 @@ import (
|
||||
xrate "github.com/go-gost/x/limiter/rate"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
traffic_plugin "github.com/go-gost/x/limiter/traffic/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseTrafficLimiter converts a LimiterConfig into a traffic.TrafficLimiter.
|
||||
// It resolves plugin backends (HTTP or gRPC), inline rate limits, and optional
|
||||
// file, Redis, and HTTP hot-reload support.
|
||||
func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter) {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -34,6 +41,7 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
|
||||
case "http":
|
||||
return traffic_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
@@ -89,6 +97,8 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
|
||||
return xtraffic.NewTrafficLimiter(opts...)
|
||||
}
|
||||
|
||||
// ParseConnLimiter converts a LimiterConfig into a conn.ConnLimiter with
|
||||
// inline limits and optional file, Redis, and HTTP hot-reload support.
|
||||
func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
@@ -137,6 +147,8 @@ func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
|
||||
return xconn.NewConnLimiter(opts...)
|
||||
}
|
||||
|
||||
// ParseRateLimiter converts a LimiterConfig into a rate.RateLimiter with
|
||||
// inline limits and optional file, Redis, and HTTP hot-reload support.
|
||||
func ParseRateLimiter(cfg *config.LimiterConfig) (lim rate.RateLimiter) {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_Nil(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(nil)
|
||||
if lim != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_WithLimits(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "traffic-limiter",
|
||||
Limits: []string{"100KB", "200KB"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_PluginHTTP(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "http-lim",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_PluginGRPC(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "grpc-lim",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "limiter.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_PluginDefaultType(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "default-lim",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_FileLoader(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "file-lim",
|
||||
File: &config.FileLoader{Path: "/tmp/limiter.txt"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_HTTPLoader(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "http-lim",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/limits"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_RedisLoader(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "redis-lim",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "limiter-key",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficLimiter_RedisListLoader(t *testing.T) {
|
||||
lim := ParseTrafficLimiter(&config.LimiterConfig{
|
||||
Name: "redis-list-lim",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "limiter-list",
|
||||
Type: "list",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil traffic limiter")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ConnLimiter ---
|
||||
|
||||
func TestParseConnLimiter_Nil(t *testing.T) {
|
||||
lim := ParseConnLimiter(nil)
|
||||
if lim != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConnLimiter_WithLimits(t *testing.T) {
|
||||
lim := ParseConnLimiter(&config.LimiterConfig{
|
||||
Name: "conn-limiter",
|
||||
Limits: []string{"10", "50"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil conn limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConnLimiter_RedisLoader(t *testing.T) {
|
||||
lim := ParseConnLimiter(&config.LimiterConfig{
|
||||
Name: "redis-conn-lim",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "conn-key",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil conn limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConnLimiter_RedisListLoader(t *testing.T) {
|
||||
lim := ParseConnLimiter(&config.LimiterConfig{
|
||||
Name: "redis-list-conn-lim",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "conn-list",
|
||||
Type: "list",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil conn limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConnLimiter_FileLoader(t *testing.T) {
|
||||
lim := ParseConnLimiter(&config.LimiterConfig{
|
||||
Name: "file-conn-lim",
|
||||
File: &config.FileLoader{Path: "/tmp/conn_limiter.txt"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil conn limiter")
|
||||
}
|
||||
}
|
||||
|
||||
// --- RateLimiter ---
|
||||
|
||||
func TestParseRateLimiter_Nil(t *testing.T) {
|
||||
lim := ParseRateLimiter(nil)
|
||||
if lim != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRateLimiter_WithLimits(t *testing.T) {
|
||||
lim := ParseRateLimiter(&config.LimiterConfig{
|
||||
Name: "rate-limiter",
|
||||
Limits: []string{"100", "200"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil rate limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRateLimiter_RedisLoader(t *testing.T) {
|
||||
lim := ParseRateLimiter(&config.LimiterConfig{
|
||||
Name: "redis-rate-lim",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "rate-key",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil rate limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRateLimiter_RedisListLoader(t *testing.T) {
|
||||
lim := ParseRateLimiter(&config.LimiterConfig{
|
||||
Name: "redis-list-rate-lim",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "rate-list",
|
||||
Type: "list",
|
||||
},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil rate limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRateLimiter_FileLoader(t *testing.T) {
|
||||
lim := ParseRateLimiter(&config.LimiterConfig{
|
||||
Name: "file-rate-lim",
|
||||
File: &config.FileLoader{Path: "/tmp/rate_limiter.txt"},
|
||||
})
|
||||
if lim == nil {
|
||||
t.Fatal("expected non-nil rate limiter")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package logger
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -12,6 +11,9 @@ import (
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// ParseLogger converts a LoggerConfig into a logger.Logger. It configures
|
||||
// output destination (stderr, stdout, file, or discard), log level, format,
|
||||
// and optional log rotation via lumberjack.
|
||||
func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
if cfg == nil || cfg.Log == nil {
|
||||
return nil
|
||||
@@ -22,39 +24,58 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
xlogger.LevelOption(logger.LogLevel(cfg.Log.Level)),
|
||||
}
|
||||
|
||||
opts = append(opts, xlogger.OutputOption(outputWriter(cfg.Log)))
|
||||
|
||||
return xlogger.NewLogger(opts...)
|
||||
}
|
||||
|
||||
// defaultLogRotation is what a file log gets when its config leaves the
|
||||
// rotation out. It matches what an unconfigured logger has always carried, so
|
||||
// a log: section that names an output but no rotation is not a way to ask for
|
||||
// an unbounded file.
|
||||
//
|
||||
// The reason this is a default rather than a warning: an unrotated debug log
|
||||
// is not untidy, it stops the device. A spoke's log section with no rotation:
|
||||
// key grew to 111MB with no backups, on a phone.
|
||||
func defaultLogRotation() *config.LogRotationConfig {
|
||||
return &config.LogRotationConfig{
|
||||
MaxSize: 10,
|
||||
MaxAge: 7,
|
||||
MaxBackups: 10,
|
||||
LocalTime: true,
|
||||
}
|
||||
}
|
||||
|
||||
// outputWriter resolves the LogConfig's output to the writer that receives the
|
||||
// lines. Split out of ParseLogger so the rotation decision — the part that
|
||||
// decides whether a file log stops growing — is reachable without a logger.
|
||||
func outputWriter(cfg *config.LogConfig) io.Writer {
|
||||
var out io.Writer = os.Stderr
|
||||
switch cfg.Log.Output {
|
||||
switch cfg.Output {
|
||||
case "none", "null":
|
||||
return xlogger.Nop()
|
||||
out = io.Discard
|
||||
case "stdout":
|
||||
out = os.Stdout
|
||||
case "stderr", "":
|
||||
out = os.Stderr
|
||||
default:
|
||||
if cfg.Log.Rotation != nil {
|
||||
out = &lumberjack.Logger{
|
||||
Filename: cfg.Log.Output,
|
||||
MaxSize: cfg.Log.Rotation.MaxSize,
|
||||
MaxAge: cfg.Log.Rotation.MaxAge,
|
||||
MaxBackups: cfg.Log.Rotation.MaxBackups,
|
||||
LocalTime: cfg.Log.Rotation.LocalTime,
|
||||
Compress: cfg.Log.Rotation.Compress,
|
||||
}
|
||||
} else {
|
||||
os.MkdirAll(filepath.Dir(cfg.Log.Output), 0755)
|
||||
f, err := os.OpenFile(cfg.Log.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
logger.Default().Warn(err)
|
||||
} else {
|
||||
out = f
|
||||
}
|
||||
if cfg.Rotation == nil {
|
||||
cfg.Rotation = defaultLogRotation()
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
opts = append(opts, xlogger.OutputOption(out))
|
||||
|
||||
return xlogger.NewLogger(opts...)
|
||||
return out
|
||||
}
|
||||
|
||||
// List resolves one or more logger names from the registry. It returns only
|
||||
// the loggers that were found, skipping any that are not registered.
|
||||
func List(name string, names ...string) []logger.Logger {
|
||||
var loggers []logger.Logger
|
||||
if adm := registry.LoggerRegistry().Get(name); adm != nil {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// probeEnv marks the child process TestParseLogger_UnopenableOutput spawns:
|
||||
// that test needs a process in which no default logger was ever installed,
|
||||
// which is the state the failure path used to dereference.
|
||||
const probeEnv = "X_LOGGER_UNOPENABLE_PROBE"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv(probeEnv) != "1" {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
}
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseLogger_Nil(t *testing.T) {
|
||||
lg := ParseLogger(nil)
|
||||
if lg != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_NilLog(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "test",
|
||||
Log: nil,
|
||||
})
|
||||
if lg != nil {
|
||||
t.Fatal("expected nil when Log is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_Levels(t *testing.T) {
|
||||
for _, level := range []string{"trace", "debug", "info", "warn", "error", "fatal"} {
|
||||
t.Run(level, func(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "test-" + level,
|
||||
Log: &config.LogConfig{
|
||||
Level: level,
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_OutputNone(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "null-logger",
|
||||
Log: &config.LogConfig{
|
||||
Output: "none",
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_OutputStdout(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "stdout-logger",
|
||||
Log: &config.LogConfig{
|
||||
Output: "stdout",
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_OutputStderr(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "stderr-logger",
|
||||
Log: &config.LogConfig{
|
||||
Output: "stderr",
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_OutputDefault(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "default-logger",
|
||||
Log: &config.LogConfig{
|
||||
Output: "",
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_WithFormat(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "format-logger",
|
||||
Log: &config.LogConfig{
|
||||
Format: "json",
|
||||
Level: "info",
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogger_FileOutput(t *testing.T) {
|
||||
lg := ParseLogger(&config.LoggerConfig{
|
||||
Name: "file-logger",
|
||||
Log: &config.LogConfig{
|
||||
Output: "/tmp/gost-test.log",
|
||||
Rotation: &config.LogRotationConfig{
|
||||
MaxSize: 10,
|
||||
MaxAge: 7,
|
||||
MaxBackups: 5,
|
||||
LocalTime: true,
|
||||
Compress: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if lg == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_EmptyLogger(t *testing.T) {
|
||||
got := List("nonexistent")
|
||||
if len(got) != 0 {
|
||||
t.Fatal("expected empty list for unregistered name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_WithNames(t *testing.T) {
|
||||
got := List("nonexistent", "also_nonexistent")
|
||||
if len(got) != 0 {
|
||||
t.Fatal("expected empty list for unregistered names")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLogger_UnopenableOutput: a log file that cannot be opened must leave
|
||||
// the process logging somewhere, not panic. The failure used to be reported
|
||||
// through logger.Default(), which is nil until a caller installs one — and this
|
||||
// call is usually the one doing the installing — so an unwritable path (an app
|
||||
// whose working directory is not writable, a typo'd directory) dereferenced a
|
||||
// nil logger and took the whole process down.
|
||||
//
|
||||
// The state that matters is process-wide and TestMain sets it for every other
|
||||
// case here, so the assertion runs in a child process: a fresh one is the only
|
||||
// place the default logger is still unset, exactly as it is at startup.
|
||||
func TestParseLogger_UnopenableOutput(t *testing.T) {
|
||||
if os.Getenv(probeEnv) == "1" {
|
||||
lg := ParseLogger(&config.LoggerConfig{Log: &config.LogConfig{
|
||||
Output: "/nonexistent-dir-for-test/wisper.log",
|
||||
Level: "info",
|
||||
}})
|
||||
if lg == nil {
|
||||
fmt.Fprintln(os.Stderr, "ParseLogger returned nil for an unopenable output")
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestParseLogger_UnopenableOutput", "-test.v")
|
||||
cmd.Env = append(os.Environ(), probeEnv+"=1")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("a log file that cannot be opened must not be fatal:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLoggerUnrotatedFileRotates is the defect that filled a phone's
|
||||
// storage. A log: section without a rotation: key used to get a plain
|
||||
// *os.File — nothing rotates it — and one debug-level spoke grew it to 111MB
|
||||
// with no backups, which on a phone is not untidy so much as fatal.
|
||||
//
|
||||
// The default rotation is what the unconfigured logger already carries, so
|
||||
// naming one should not be a way to opt out of it.
|
||||
func TestParseLoggerUnrotatedFileRotates(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "wisper.log")
|
||||
cfg := &config.LoggerConfig{
|
||||
Log: &config.LogConfig{
|
||||
Output: file,
|
||||
Level: "debug",
|
||||
Format: "json",
|
||||
// Rotation deliberately absent: the shape wisper.yaml on Android has.
|
||||
},
|
||||
}
|
||||
|
||||
w := outputWriter(cfg.Log)
|
||||
if w == nil {
|
||||
t.Fatal("outputWriter = nil")
|
||||
}
|
||||
if _, ok := w.(*lumberjack.Logger); !ok {
|
||||
t.Fatalf("outputWriter = %T, want a *lumberjack.Logger so the file rotates", w)
|
||||
}
|
||||
|
||||
lg := w.(*lumberjack.Logger)
|
||||
if lg.Filename != file {
|
||||
t.Errorf("Filename = %q, want %q", lg.Filename, file)
|
||||
}
|
||||
if lg.MaxSize <= 0 || lg.MaxBackups <= 0 {
|
||||
t.Errorf("rotation is off: MaxSize=%d MaxBackups=%d, want both positive", lg.MaxSize, lg.MaxBackups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLoggerExplicitRotationWins: a configured rotation is respected as
|
||||
// written, so the default is only a floor for what was left out.
|
||||
func TestParseLoggerExplicitRotationWins(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "wisper.log")
|
||||
cfg := &config.LoggerConfig{
|
||||
Log: &config.LogConfig{
|
||||
Output: file,
|
||||
Rotation: &config.LogRotationConfig{
|
||||
MaxSize: 33,
|
||||
MaxBackups: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lg, ok := outputWriter(cfg.Log).(*lumberjack.Logger)
|
||||
if !ok {
|
||||
t.Fatalf("outputWriter = %T, want *lumberjack.Logger", outputWriter(cfg.Log))
|
||||
}
|
||||
if lg.MaxSize != 33 || lg.MaxBackups != 5 {
|
||||
t.Errorf("MaxSize=%d MaxBackups=%d, want 33 and 5 as configured", lg.MaxSize, lg.MaxBackups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLoggerNotAFileStillGoesToTheSink: stderr/stdout/none are unaffected,
|
||||
// so defaulting the rotation cannot leak a file into a stream.
|
||||
func TestParseLoggerNotAFileStillGoesToTheSink(t *testing.T) {
|
||||
for _, out := range []string{"stderr", "stdout", "none", ""} {
|
||||
w := outputWriter(&config.LogConfig{Output: out})
|
||||
switch out {
|
||||
case "none":
|
||||
if w != io.Discard {
|
||||
t.Errorf("none: writer = %T, want io.Discard", w)
|
||||
}
|
||||
case "stdout":
|
||||
if w != os.Stdout {
|
||||
t.Errorf("stdout: writer = %T, want os.Stdout", w)
|
||||
}
|
||||
default:
|
||||
if w != os.Stderr {
|
||||
t.Errorf("%q: writer = %T, want os.Stderr", out, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+477
-84
@@ -4,13 +4,14 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/metadata"
|
||||
xauth "github.com/go-gost/x/auth"
|
||||
xbypass "github.com/go-gost/x/bypass"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
@@ -21,38 +22,346 @@ import (
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
mdx "github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/go-gost/x/routing"
|
||||
)
|
||||
|
||||
// DefaultMatcherBodySize is the default request body prefix (in bytes) exposed
|
||||
// to body matchers when a node has BodyRegexp/BodyJSON matchers but no
|
||||
// explicit bodySize.
|
||||
const DefaultMatcherBodySize = 1 << 20 // 1MB
|
||||
|
||||
// MaxMatcherBodySize is the hard cap for matcher body prefix. Values above
|
||||
// this are silently clamped. Protects against unbounded in-memory buffering
|
||||
// when a node opts in to body matching.
|
||||
const MaxMatcherBodySize = 10 << 20 // 10MB
|
||||
|
||||
// IsReference reports whether cfg contains only a non-empty name. Such a
|
||||
// config references a definition from the top-level nodes section.
|
||||
// Any additional field, even a zero-valued one set explicitly (e.g. an
|
||||
// empty but non-nil connector), makes the config inline.
|
||||
// NOTE: keep this in sync with NodeConfig — a new field must be added here,
|
||||
// otherwise a config setting only that field would misresolve as a reference.
|
||||
func IsReference(cfg *config.NodeConfig) bool {
|
||||
if cfg == nil || strings.TrimSpace(cfg.Name) == "" {
|
||||
return false
|
||||
}
|
||||
return cfg.Addr == "" && cfg.Network == "" && cfg.Bypass == "" &&
|
||||
len(cfg.Bypasses) == 0 && cfg.Resolver == "" && cfg.Hosts == "" &&
|
||||
cfg.Connector == nil && cfg.Dialer == nil &&
|
||||
cfg.Interface == "" && cfg.Netns == "" && cfg.SockOpts == nil &&
|
||||
cfg.Filter == nil && cfg.Matcher == nil && cfg.HTTP == nil &&
|
||||
cfg.TLS == nil && cfg.Probe == nil && len(cfg.Metadata) == 0
|
||||
}
|
||||
|
||||
// ResolveConfig resolves a name-only config through the global node registry
|
||||
// and returns an independent copy of the definition. Inline configs are
|
||||
// returned unchanged to preserve their existing parsing behavior.
|
||||
func ResolveConfig(cfg *config.NodeConfig) (*config.NodeConfig, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !IsReference(cfg) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(cfg.Name)
|
||||
cfg = registry.NodeRegistry().Get(name)
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("node %q not found", name)
|
||||
}
|
||||
return cloneConfig(cfg), nil
|
||||
}
|
||||
|
||||
// cloneConfig returns a deep copy of cfg so that parsing (hop-level
|
||||
// inheritance, connector/dialer type defaults, TLS ServerName defaulting)
|
||||
// only ever mutates the copy, never the shared global definition.
|
||||
// NOTE: keep this in sync with NodeConfig — a new reference-type field
|
||||
// must be cloned here, otherwise hops would share it with the registry.
|
||||
func cloneConfig(cfg *config.NodeConfig) *config.NodeConfig {
|
||||
c := *cfg
|
||||
c.Bypasses = append([]string(nil), cfg.Bypasses...)
|
||||
if cfg.Metadata != nil {
|
||||
c.Metadata = make(map[string]any, len(cfg.Metadata))
|
||||
for k, v := range cfg.Metadata {
|
||||
c.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
if cfg.Connector != nil {
|
||||
connector := *cfg.Connector
|
||||
connector.Auth = cloneAuth(cfg.Connector.Auth)
|
||||
connector.TLS = cloneTLSConfig(cfg.Connector.TLS)
|
||||
if cfg.Connector.Metadata != nil {
|
||||
connector.Metadata = make(map[string]any, len(cfg.Connector.Metadata))
|
||||
for k, v := range cfg.Connector.Metadata {
|
||||
connector.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
c.Connector = &connector
|
||||
}
|
||||
if cfg.Dialer != nil {
|
||||
dialer := *cfg.Dialer
|
||||
dialer.Auth = cloneAuth(cfg.Dialer.Auth)
|
||||
dialer.TLS = cloneTLSConfig(cfg.Dialer.TLS)
|
||||
if cfg.Dialer.Metadata != nil {
|
||||
dialer.Metadata = make(map[string]any, len(cfg.Dialer.Metadata))
|
||||
for k, v := range cfg.Dialer.Metadata {
|
||||
dialer.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
c.Dialer = &dialer
|
||||
}
|
||||
if cfg.SockOpts != nil {
|
||||
sockOpts := *cfg.SockOpts
|
||||
c.SockOpts = &sockOpts
|
||||
}
|
||||
if cfg.Filter != nil {
|
||||
filter := *cfg.Filter
|
||||
c.Filter = &filter
|
||||
}
|
||||
if cfg.Matcher != nil {
|
||||
matcher := *cfg.Matcher
|
||||
c.Matcher = &matcher
|
||||
}
|
||||
if cfg.HTTP != nil {
|
||||
http := *cfg.HTTP
|
||||
http.Header = cloneStringMap(cfg.HTTP.Header)
|
||||
http.RequestHeader = cloneStringMap(cfg.HTTP.RequestHeader)
|
||||
http.ResponseHeader = cloneStringMap(cfg.HTTP.ResponseHeader)
|
||||
http.Rewrite = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.Rewrite...)
|
||||
http.RewriteURL = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.RewriteURL...)
|
||||
http.RewriteBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteBody...)
|
||||
http.RewriteRequestBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteRequestBody...)
|
||||
http.RewriteResponseBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteResponseBody...)
|
||||
http.RewriteRequestHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteRequestHeader...)
|
||||
http.RewriteResponseHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteResponseHeader...)
|
||||
http.Auth = cloneAuth(cfg.HTTP.Auth)
|
||||
c.HTTP = &http
|
||||
}
|
||||
if cfg.TLS != nil {
|
||||
tls := *cfg.TLS
|
||||
if cfg.TLS.Options != nil {
|
||||
options := *cfg.TLS.Options
|
||||
options.CipherSuites = append([]string(nil), cfg.TLS.Options.CipherSuites...)
|
||||
options.ALPN = append([]string(nil), cfg.TLS.Options.ALPN...)
|
||||
tls.Options = &options
|
||||
}
|
||||
c.TLS = &tls
|
||||
}
|
||||
if cfg.Probe != nil {
|
||||
probe := *cfg.Probe
|
||||
probe.HTTPHeaders = cloneStringMap(cfg.Probe.HTTPHeaders)
|
||||
c.Probe = &probe
|
||||
}
|
||||
return &c
|
||||
}
|
||||
|
||||
func cloneAuth(cfg *config.AuthConfig) *config.AuthConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
auth := *cfg
|
||||
return &auth
|
||||
}
|
||||
|
||||
func cloneTLSConfig(cfg *config.TLSConfig) *config.TLSConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
tls := *cfg
|
||||
tls.ServerNames = append([]string(nil), cfg.ServerNames...)
|
||||
if cfg.Options != nil {
|
||||
options := *cfg.Options
|
||||
options.CipherSuites = append([]string(nil), cfg.Options.CipherSuites...)
|
||||
options.ALPN = append([]string(nil), cfg.Options.ALPN...)
|
||||
tls.Options = &options
|
||||
}
|
||||
return &tls
|
||||
}
|
||||
|
||||
func cloneStringMap(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
c := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
c[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// filterToMatcherRule converts a deprecated NodeFilterConfig (host/protocol/
|
||||
// path) into an equivalent matcher DSL rule. Empty fields are omitted; an
|
||||
// empty result means the node is unconditional. Path maps to PathPrefix
|
||||
// (the legacy filter is a prefix match, not exact).
|
||||
func filterToMatcherRule(filter *config.NodeFilterConfig) string {
|
||||
if filter == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
// Normalize the legacy host forms first: `*.example.com` and
|
||||
// `.example.com` both meant "apex AND subdomains" in the legacy filter.
|
||||
// Strip the wildcard so the leading-dot branch below handles both.
|
||||
host := filter.Host
|
||||
if strings.HasPrefix(host, "*") {
|
||||
host = host[1:]
|
||||
if !strings.HasPrefix(host, ".") {
|
||||
host = "." + host
|
||||
}
|
||||
}
|
||||
if host != "" {
|
||||
// A leading-dot host matches the apex AND its subdomains in the
|
||||
// legacy filter (HasSuffix on the dot-stripped form), but matcher
|
||||
// Host(.x) only matches subdomains. Emit both to preserve apex
|
||||
// coverage. Parenthesize the OR when other conjuncts follow, since
|
||||
// && binds tighter than || in the matcher DSL.
|
||||
if strings.HasPrefix(host, ".") {
|
||||
h := "Host(`" + host[1:] + "`) || Host(`" + host + "`)"
|
||||
if filter.Protocol != "" || filter.Path != "" {
|
||||
h = "(" + h + ")"
|
||||
}
|
||||
parts = append(parts, h)
|
||||
} else {
|
||||
parts = append(parts, "Host(`"+host+"`)")
|
||||
}
|
||||
}
|
||||
if prot := filter.Protocol; prot != "" {
|
||||
parts = append(parts, "Proto(`"+prot+"`)")
|
||||
}
|
||||
if path := filter.Path; path != "" {
|
||||
parts = append(parts, "PathPrefix(`"+path+"`)")
|
||||
}
|
||||
return strings.Join(parts, " && ")
|
||||
}
|
||||
|
||||
// ParseNode converts a NodeConfig into a *chain.Node. It resolves the
|
||||
// connector and dialer from their registries, applies TLS settings, extracts
|
||||
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
|
||||
// bypass rules, node filters, HTTP settings, and TLS node settings. The hop
|
||||
// parameter is used only for logging context.
|
||||
|
||||
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
|
||||
var out []chain.HTTPBodyRewriteSettings
|
||||
for _, v := range vs {
|
||||
var pattern *regexp.Regexp
|
||||
var rewriteType string
|
||||
|
||||
js, ok := strings.CutPrefix(v.Match, "json:")
|
||||
del := false
|
||||
if !ok {
|
||||
// json-:<path> deletes the matched JSON field instead of replacing it.
|
||||
if js, ok = strings.CutPrefix(v.Match, "json-:"); ok {
|
||||
del = true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
// json:<path>[=<value-regex>] or json-:<path>[=<value-regex>]
|
||||
path, valRegex, _ := strings.Cut(js, "=")
|
||||
if valRegex == "" {
|
||||
valRegex = ".*"
|
||||
}
|
||||
var err error
|
||||
pattern, err = regexp.Compile(valRegex)
|
||||
if err != nil {
|
||||
log.Warnf("invalid JSON value regex %q for path %q: %v", valRegex, path, err)
|
||||
continue
|
||||
}
|
||||
if del {
|
||||
rewriteType = "json-:" + path
|
||||
} else {
|
||||
rewriteType = "json:" + path
|
||||
}
|
||||
} else {
|
||||
if v.Match != "" {
|
||||
pattern, _ = regexp.Compile(v.Match)
|
||||
}
|
||||
rewriteType = v.Type
|
||||
}
|
||||
|
||||
rw := chain.HTTPBodyRewriteSettings{
|
||||
Type: rewriteType,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
MaxChunkSize: v.MaxChunkSize,
|
||||
}
|
||||
if v.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
|
||||
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
|
||||
}
|
||||
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
|
||||
}
|
||||
if pattern != nil || rw.Rewriter != nil {
|
||||
out = append(out, rw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHeaderRewrites(vs []config.HTTPHeaderRewriteConfig, log logger.Logger) []chain.HTTPHeaderRewriteSettings {
|
||||
var out []chain.HTTPHeaderRewriteSettings
|
||||
for _, v := range vs {
|
||||
var name, pattern *regexp.Regexp
|
||||
if v.Name != "" {
|
||||
name, _ = regexp.Compile(v.Name)
|
||||
}
|
||||
if v.Match != "" {
|
||||
pattern, _ = regexp.Compile(v.Match)
|
||||
}
|
||||
|
||||
rw := chain.HTTPHeaderRewriteSettings{
|
||||
Name: name,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
}
|
||||
if v.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
|
||||
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
|
||||
}
|
||||
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
|
||||
}
|
||||
// Gate on the config string, not the compiled regexp: regexp.Compile("")
|
||||
// returns a non-nil match-all regexp, so an empty Name with no rewriter
|
||||
// would otherwise become a silent no-op rule.
|
||||
if v.Name != "" || rw.Rewriter != nil {
|
||||
out = append(out, rw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cfg.Connector == nil {
|
||||
cfg.Connector = &config.ConnectorConfig{
|
||||
Type: "http",
|
||||
}
|
||||
connCfg := cfg.Connector
|
||||
if connCfg == nil {
|
||||
connCfg = &config.ConnectorConfig{}
|
||||
}
|
||||
if connCfg.Type == "" {
|
||||
connCfg.Type = "http"
|
||||
}
|
||||
|
||||
if cfg.Dialer == nil {
|
||||
cfg.Dialer = &config.DialerConfig{
|
||||
Type: "tcp",
|
||||
}
|
||||
dialCfg := cfg.Dialer
|
||||
if dialCfg == nil {
|
||||
dialCfg = &config.DialerConfig{}
|
||||
}
|
||||
if dialCfg.Type == "" {
|
||||
dialCfg.Type = "tcp"
|
||||
}
|
||||
|
||||
nodeLogger := log.WithFields(map[string]any{
|
||||
"hop": hop,
|
||||
"kind": "node",
|
||||
"node": cfg.Name,
|
||||
"connector": cfg.Connector.Type,
|
||||
"dialer": cfg.Dialer.Type,
|
||||
"connector": connCfg.Type,
|
||||
"dialer": dialCfg.Type,
|
||||
})
|
||||
|
||||
serverName, _, _ := net.SplitHostPort(cfg.Addr)
|
||||
|
||||
tlsCfg := cfg.Connector.TLS
|
||||
tlsCfg := connCfg.TLS
|
||||
if tlsCfg == nil {
|
||||
tlsCfg = &config.TLSConfig{}
|
||||
}
|
||||
@@ -65,34 +374,26 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nm metadata.Metadata
|
||||
if cfg.Metadata != nil {
|
||||
nm = mdx.NewMetadata(cfg.Metadata)
|
||||
}
|
||||
|
||||
connectorLogger := nodeLogger.WithFields(map[string]any{
|
||||
"kind": "connector",
|
||||
})
|
||||
var cr connector.Connector
|
||||
if rf := registry.ConnectorRegistry().Get(cfg.Connector.Type); rf != nil {
|
||||
if rf := registry.ConnectorRegistry().Get(connCfg.Type); rf != nil {
|
||||
cr = rf(
|
||||
connector.AuthOption(auth_parser.Info(cfg.Connector.Auth)),
|
||||
connector.AuthOption(auth_parser.Info(connCfg.Auth)),
|
||||
connector.TLSConfigOption(tlsConfig),
|
||||
connector.LoggerOption(connectorLogger),
|
||||
)
|
||||
} else {
|
||||
return nil, fmt.Errorf("unregistered connector: %s", cfg.Connector.Type)
|
||||
return nil, fmt.Errorf("unregistered connector: %s", connCfg.Type)
|
||||
}
|
||||
|
||||
if cfg.Connector.Metadata == nil {
|
||||
cfg.Connector.Metadata = make(map[string]any)
|
||||
}
|
||||
if err := cr.Init(mdx.NewMetadata(cfg.Connector.Metadata)); err != nil {
|
||||
if err := cr.Init(mdx.NewMetadata(connCfg.Metadata)); err != nil {
|
||||
connectorLogger.Error("init: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsCfg = cfg.Dialer.TLS
|
||||
tlsCfg = dialCfg.TLS
|
||||
if tlsCfg == nil {
|
||||
tlsCfg = &config.TLSConfig{}
|
||||
}
|
||||
@@ -105,96 +406,118 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ppv int
|
||||
if nm != nil {
|
||||
ppv = mdutil.GetInt(nm, parsing.MDKeyProxyProtocol)
|
||||
}
|
||||
md := mdx.NewMetadata(cfg.Metadata)
|
||||
|
||||
dialerLogger := nodeLogger.WithFields(map[string]any{
|
||||
"kind": "dialer",
|
||||
})
|
||||
|
||||
var d dialer.Dialer
|
||||
if rf := registry.DialerRegistry().Get(cfg.Dialer.Type); rf != nil {
|
||||
if rf := registry.DialerRegistry().Get(dialCfg.Type); rf != nil {
|
||||
d = rf(
|
||||
dialer.AuthOption(auth_parser.Info(cfg.Dialer.Auth)),
|
||||
dialer.AuthOption(auth_parser.Info(dialCfg.Auth)),
|
||||
dialer.TLSConfigOption(tlsConfig),
|
||||
dialer.LoggerOption(dialerLogger),
|
||||
dialer.ProxyProtocolOption(ppv),
|
||||
dialer.ProxyProtocolOption(mdutil.GetInt(md, parsing.MDKeyProxyProtocol)),
|
||||
)
|
||||
} else {
|
||||
return nil, fmt.Errorf("unregistered dialer: %s", cfg.Dialer.Type)
|
||||
return nil, fmt.Errorf("unregistered dialer: %s", dialCfg.Type)
|
||||
}
|
||||
|
||||
if cfg.Dialer.Metadata == nil {
|
||||
cfg.Dialer.Metadata = make(map[string]any)
|
||||
// p2p: wrap the dialer so the node's base transport (the path to this
|
||||
// node) goes through a tunnel opened by the named p2p plugin instead of
|
||||
// a direct network dial. The inner dialer's protocol (tls/ws/mux) runs
|
||||
// unchanged on top of the tunnel. Only whitelisted dialers qualify:
|
||||
// the wrapper's contract is "inner dials one plain TCP stream via
|
||||
// options.Dialer". The wrap happens before Init so the chain inits
|
||||
// exactly once, through the wrapper.
|
||||
if name := mdutil.GetString(md, parsing.MDKeyP2P); name != "" {
|
||||
provider := registry.P2PRegistry().Get(name)
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("unregistered p2p: %s", name)
|
||||
}
|
||||
if !xp2p.SupportedDialer(dialCfg.Type) {
|
||||
return nil, fmt.Errorf("dialer %q does not support p2p", dialCfg.Type)
|
||||
}
|
||||
d = xp2p.NewTunnelDialer(d, provider,
|
||||
xp2p.WithTunnelNetwork(mdutil.GetString(md, parsing.MDKeyP2PNetwork)))
|
||||
}
|
||||
if err := d.Init(mdx.NewMetadata(cfg.Dialer.Metadata)); err != nil {
|
||||
|
||||
if err := d.Init(mdx.NewMetadata(dialCfg.Metadata)); err != nil {
|
||||
dialerLogger.Error("init: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sockOpts *chain.SockOpts
|
||||
if cfg.SockOpts != nil {
|
||||
if v := mdutil.GetInt(md, parsing.MDKeySoMark); v != 0 {
|
||||
sockOpts = &chain.SockOpts{
|
||||
Mark: cfg.SockOpts.Mark,
|
||||
Mark: v,
|
||||
}
|
||||
}
|
||||
|
||||
tr := xchain.NewTransport(d, cr,
|
||||
chain.AddrTransportOption(cfg.Addr),
|
||||
chain.InterfaceTransportOption(cfg.Interface),
|
||||
chain.NetnsTransportOption(cfg.Netns),
|
||||
chain.InterfaceTransportOption(mdutil.GetString(md, parsing.MDKeyInterface)),
|
||||
chain.NetnsTransportOption(mdutil.GetString(md, parsing.MDKeyNetns)),
|
||||
chain.SockOptsTransportOption(sockOpts),
|
||||
)
|
||||
|
||||
opts := []chain.NodeOption{
|
||||
chain.TransportNodeOption(tr),
|
||||
chain.BypassNodeOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
chain.ResoloverNodeOption(registry.ResolverRegistry().Get(cfg.Resolver)),
|
||||
chain.ResolverNodeOption(registry.ResolverRegistry().Get(cfg.Resolver)),
|
||||
chain.HostMapperNodeOption(registry.HostsRegistry().Get(cfg.Hosts)),
|
||||
chain.MetadataNodeOption(nm),
|
||||
chain.MetadataNodeOption(md),
|
||||
chain.NetworkNodeOption(cfg.Network),
|
||||
}
|
||||
|
||||
if filter := cfg.Filter; filter != nil {
|
||||
// convert *.example.com to .example.com
|
||||
// convert *example.com to example.com
|
||||
host := filter.Host
|
||||
if strings.HasPrefix(host, "*") {
|
||||
host = host[1:]
|
||||
if !strings.HasPrefix(host, ".") {
|
||||
host = "." + host
|
||||
}
|
||||
}
|
||||
var (
|
||||
rule string
|
||||
priority int
|
||||
bodySize int
|
||||
)
|
||||
|
||||
settings := &chain.NodeFilterSettings{
|
||||
Protocol: filter.Protocol,
|
||||
Host: host,
|
||||
Path: filter.Path,
|
||||
}
|
||||
opts = append(opts, chain.NodeFilterOption(settings))
|
||||
// Deprecated filter: normalize into an equivalent matcher DSL rule so the
|
||||
// runtime keeps a single filtering logic. Explicit -1 priority opts out of
|
||||
// auto-priority and the priority short-circuit, preserving the legacy
|
||||
// filter behavior (always through the selector).
|
||||
if filter := cfg.Filter; filter != nil {
|
||||
rule = filterToMatcherRule(filter)
|
||||
priority = -1
|
||||
bodySize = DefaultMatcherBodySize
|
||||
}
|
||||
|
||||
if cfg.Matcher != nil {
|
||||
priority := cfg.Matcher.Priority
|
||||
|
||||
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
priority = cfg.Matcher.Priority
|
||||
rule = cfg.Matcher.Rule
|
||||
bodySize = cfg.Matcher.BodySize
|
||||
}
|
||||
|
||||
if rule = strings.TrimSpace(rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, rule)
|
||||
// Priority 0 means "use default": automatically set to the
|
||||
// rule length so longer (more specific) rules outrank shorter
|
||||
// ones. Use a negative priority to opt out of this behavior
|
||||
// and always go through the selector.
|
||||
if priority == 0 {
|
||||
priority = len(rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
if bodySize <= 0 {
|
||||
bodySize = DefaultMatcherBodySize
|
||||
} else if bodySize > MaxMatcherBodySize {
|
||||
bodySize = MaxMatcherBodySize
|
||||
}
|
||||
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
|
||||
if cfg.HTTP != nil {
|
||||
settings := &chain.HTTPNodeSettings{
|
||||
Host: cfg.HTTP.Host,
|
||||
@@ -205,6 +528,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
settings.RequestHeader = cfg.HTTP.Header
|
||||
}
|
||||
|
||||
if v := cfg.HTTP.HostPattern; v != "" {
|
||||
settings.HostPattern, _ = regexp.Compile(v)
|
||||
}
|
||||
|
||||
if auth := cfg.HTTP.Auth; auth != nil && auth.Username != "" {
|
||||
settings.Auther = xauth.NewAuthenticator(
|
||||
xauth.AuthsOption(map[string]string{auth.Username: auth.Password}),
|
||||
@@ -219,7 +546,7 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
rewriteURL := cfg.HTTP.RewriteURL
|
||||
if rewriteURL == nil {
|
||||
rewriteURL = cfg.HTTP.Rewrite
|
||||
}
|
||||
}
|
||||
for _, v := range rewriteURL {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteURL = append(settings.RewriteURL, chain.HTTPURLRewriteSetting{
|
||||
@@ -228,15 +555,16 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
|
||||
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
|
||||
settings.RewriteRequestHeader = parseHeaderRewrites(cfg.HTTP.RewriteRequestHeader, log)
|
||||
settings.RewriteResponseHeader = parseHeaderRewrites(cfg.HTTP.RewriteResponseHeader, log)
|
||||
|
||||
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
|
||||
settings.FailCodes = parseFailCodes(v, nodeLogger)
|
||||
}
|
||||
|
||||
opts = append(opts, chain.HTTPNodeOption(settings))
|
||||
}
|
||||
|
||||
@@ -253,5 +581,70 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
}
|
||||
opts = append(opts, chain.TLSNodeOption(tlsCfg))
|
||||
}
|
||||
return chain.NewNode(cfg.Name, cfg.Addr, opts...), nil
|
||||
|
||||
node := chain.NewNode(cfg.Name, cfg.Addr, opts...)
|
||||
if cfg.Probe != nil {
|
||||
if pc := ParseProbeConfig(cfg.Probe); pc != nil {
|
||||
xchain.StartNodeProbe(node, pc, nodeLogger)
|
||||
}
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ParseProbeConfig converts a config.ProbeConfig into a chain.ProbeConfig.
|
||||
// Returns nil when the config is invalid (e.g. empty addr).
|
||||
func ParseProbeConfig(cfg *config.ProbeConfig) *chain.ProbeConfig {
|
||||
if cfg == nil || (cfg.Addr == "" && cfg.Type != "cmd") {
|
||||
return nil
|
||||
}
|
||||
if cfg.Type == "cmd" && cfg.Command == "" {
|
||||
return nil
|
||||
}
|
||||
pt := chain.ProbeTypeTCP
|
||||
switch cfg.Type {
|
||||
case "http":
|
||||
pt = chain.ProbeTypeHTTP
|
||||
case "cmd":
|
||||
pt = chain.ProbeTypeCmd
|
||||
}
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &chain.ProbeConfig{
|
||||
Type: pt,
|
||||
Addr: cfg.Addr,
|
||||
Interval: interval,
|
||||
Timeout: timeout,
|
||||
HTTPPath: cfg.HTTPPath,
|
||||
HTTPHost: cfg.HTTPHost,
|
||||
HTTPHeaders: cfg.HTTPHeaders,
|
||||
ExpectedStatus: cfg.ExpectedStatus,
|
||||
Command: cfg.Command,
|
||||
}
|
||||
}
|
||||
|
||||
// parseFailCodes parses a comma-separated status code list, e.g. "429,5xx".
|
||||
// Tokens ending in "xx" become hundred-level wildcards (5xx → 5, matching
|
||||
// 500-599). Invalid tokens are logged and skipped.
|
||||
func parseFailCodes(s string, log logger.Logger) chain.FailCodes {
|
||||
var codes chain.FailCodes
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) == 3 && strings.HasSuffix(part, "xx") {
|
||||
if prefix, err := strconv.Atoi(part[:1]); err == nil && prefix > 0 {
|
||||
codes = append(codes, prefix) // < 100 → wildcard
|
||||
continue
|
||||
}
|
||||
} else if code, err := strconv.Atoi(part); err == nil && code >= 100 {
|
||||
codes = append(codes, code)
|
||||
continue
|
||||
}
|
||||
log.Warnf("failCodes: invalid token %q", part)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/go-gost/x/registry"
|
||||
|
||||
// Register connector and dialer implementations needed for node parsing.
|
||||
_ "github.com/go-gost/x/connector/http"
|
||||
_ "github.com/go-gost/x/dialer/tcp"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func testLogger() logger.Logger {
|
||||
return xlogger.NewLogger(xlogger.OutputOption(io.Discard))
|
||||
}
|
||||
|
||||
func TestParseNode_Nil(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_Defaults(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "node1",
|
||||
Addr: "example.com:8080",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_DefaultsEmptyName(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "",
|
||||
Addr: "example.com:8080",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_DefaultsEmptyAddr(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "no-addr",
|
||||
Addr: "",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_ExplicitConnector(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "explicit-conn",
|
||||
Addr: "example.com:8080",
|
||||
Connector: &config.ConnectorConfig{
|
||||
Type: "http",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_UnregisteredConnector(t *testing.T) {
|
||||
_, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "bad-conn",
|
||||
Addr: "example.com:8080",
|
||||
Connector: &config.ConnectorConfig{
|
||||
Type: "nonexistent-connector-type",
|
||||
},
|
||||
}, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unregistered connector type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_UnregisteredDialer(t *testing.T) {
|
||||
_, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "bad-dial",
|
||||
Addr: "example.com:8080",
|
||||
Dialer: &config.DialerConfig{
|
||||
Type: "nonexistent-dialer-type",
|
||||
},
|
||||
}, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unregistered dialer type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_ExplicitDialer(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "explicit-dial",
|
||||
Addr: "example.com:8080",
|
||||
Dialer: &config.DialerConfig{
|
||||
Type: "tcp",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithBypass(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "bypass-node",
|
||||
Addr: "example.com:8080",
|
||||
Bypass: "bp1",
|
||||
Bypasses: []string{"bp2", "bp3"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithResolver(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "resolver-node",
|
||||
Addr: "example.com:8080",
|
||||
Resolver: "my-resolver",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHosts(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "hosts-node",
|
||||
Addr: "example.com:8080",
|
||||
Hosts: "my-hosts",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithNetwork(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "network-node",
|
||||
Addr: "example.com:8080",
|
||||
Network: "tcp",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMetadata(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "meta-node",
|
||||
Addr: "example.com:8080",
|
||||
Metadata: map[string]any{
|
||||
parsing.MDKeySoMark: 1234,
|
||||
parsing.MDKeyInterface: "eth0",
|
||||
parsing.MDKeyNetns: "myns",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithSockOpts(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "sockopts-node",
|
||||
Addr: "example.com:8080",
|
||||
SockOpts: &config.SockOptsConfig{
|
||||
Mark: 9999,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithFilter(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "filter-node",
|
||||
Addr: "example.com:8080",
|
||||
Filter: &config.NodeFilterConfig{
|
||||
Protocol: "http",
|
||||
Host: "*.example.com",
|
||||
Path: "/api",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithFilterStarBare(t *testing.T) {
|
||||
// "*example.com" should become ".example.com"
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "star-bare-node",
|
||||
Addr: "example.com:8080",
|
||||
Filter: &config.NodeFilterConfig{
|
||||
Host: "*example.com",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcher(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "matcher-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
Rule: "dstport==80",
|
||||
Priority: 10,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherAutoPriority(t *testing.T) {
|
||||
// Priority auto-set to len(rule) when zero
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "auto-prio-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
Rule: "dstport==443",
|
||||
Priority: 0,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherBodySize(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "body-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
// Escaped predicate form; in YAML users write the backtick
|
||||
// equivalent: BodyRegexp(`"model":"gpt-4"`).
|
||||
Rule: `BodyRegexp("\"model\":\"gpt-4\"")`,
|
||||
BodySize: 65536,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
if got := n.Options().MatcherBodySize; got != 65536 {
|
||||
t.Errorf("MatcherBodySize = %d, want 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherBodySize_Clamped(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "body-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
Rule: `BodyRegexp("foo")`,
|
||||
BodySize: 10 * 1024 * 1024, // 10MB, above the 1MB cap
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
if got := n.Options().MatcherBodySize; got != MaxMatcherBodySize {
|
||||
t.Errorf("MatcherBodySize = %d, want %d (clamped)", got, MaxMatcherBodySize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHTTPNode(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "http-node",
|
||||
Addr: "example.com:8080",
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
Host: "custom.example.com",
|
||||
RequestHeader: map[string]string{"X-Custom": "value"},
|
||||
ResponseHeader: map[string]string{"X-Response": "value"},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHTTPAuth(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "http-auth-node",
|
||||
Addr: "example.com:8080",
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
Auth: &config.AuthConfig{
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHTTPDeprecatedHeader(t *testing.T) {
|
||||
// Deprecated Header field should populate RequestHeader
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "http-deprecated-node",
|
||||
Addr: "example.com:8080",
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
Header: map[string]string{"X-Old": "value"},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHeaderRewrite(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "hdr-node",
|
||||
Addr: "example.com:8080",
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
RewriteRequestHeader: []config.HTTPHeaderRewriteConfig{
|
||||
{Name: "(?i)^referer$", Match: ".*", Replacement: ""},
|
||||
},
|
||||
RewriteResponseHeader: []config.HTTPHeaderRewriteConfig{
|
||||
{Name: "(?i)^location$", Match: "https://github.com", Replacement: "https://127.0.0.1:8080"},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
settings := n.Options().HTTP
|
||||
if settings == nil {
|
||||
t.Fatal("expected HTTP settings")
|
||||
}
|
||||
if len(settings.RewriteRequestHeader) != 1 || len(settings.RewriteResponseHeader) != 1 {
|
||||
t.Fatalf("rewrite header counts = %d/%d, want 1/1", len(settings.RewriteRequestHeader), len(settings.RewriteResponseHeader))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHeaderRewrites_EmptyNameNoRewriter(t *testing.T) {
|
||||
// Empty Name with no Rewriter must not be appended (silent no-op guard).
|
||||
out := parseHeaderRewrites([]config.HTTPHeaderRewriteConfig{
|
||||
{Name: "", Match: ".*", Replacement: ""},
|
||||
}, testLogger())
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("out = %d rules, want 0", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithTLSNode(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "tls-node",
|
||||
Addr: "example.com:8080",
|
||||
TLS: &config.TLSNodeConfig{
|
||||
ServerName: "secure.example.com",
|
||||
Secure: true,
|
||||
Options: &config.TLSOptions{
|
||||
MinVersion: "1.2",
|
||||
MaxVersion: "1.3",
|
||||
CipherSuites: []string{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
|
||||
ALPN: []string{"h2", "http/1.1"},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_TLSServerNameFromAddr(t *testing.T) {
|
||||
// When connector/dialer TLS ServerName is empty, it defaults from addr hostname.
|
||||
// The server name is set on the connector's TLS config (internal), so the best
|
||||
// verification is that ParseNode does not error and returns a node.
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "tls-servername-node",
|
||||
Addr: "tls.example.com:443",
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_ConnectorAuth(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "conn-auth-node",
|
||||
Addr: "example.com:8080",
|
||||
Connector: &config.ConnectorConfig{
|
||||
Type: "http",
|
||||
Auth: &config.AuthConfig{
|
||||
Username: "connuser",
|
||||
Password: "connpass",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_DialerAuth(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "dial-auth-node",
|
||||
Addr: "example.com:8080",
|
||||
Dialer: &config.DialerConfig{
|
||||
Type: "tcp",
|
||||
Auth: &config.AuthConfig{
|
||||
Username: "dialuser",
|
||||
Password: "dialpass",
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_InvalidMatcherRule(t *testing.T) {
|
||||
// An invalid matcher rule sets priority to -1
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "invalid-matcher-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
Rule: "",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node even with empty matcher rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_DeprecatedFilterFields(t *testing.T) {
|
||||
// Deprecated filter fields: Protocol/Host/Path via Filter
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "deprecated-node",
|
||||
Addr: "example.com:8080",
|
||||
Filter: &config.NodeFilterConfig{
|
||||
Protocol: "socks5",
|
||||
Host: "*.example.com",
|
||||
Path: "/proxy",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterToMatcherRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter *config.NodeFilterConfig
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"all empty", &config.NodeFilterConfig{}, ""},
|
||||
{"host only", &config.NodeFilterConfig{Host: "example.com"}, "Host(`example.com`)"},
|
||||
{"dot host", &config.NodeFilterConfig{Host: ".example.com"},
|
||||
"Host(`example.com`) || Host(`.example.com`)"},
|
||||
{"star host", &config.NodeFilterConfig{Host: "*.example.com"},
|
||||
"Host(`example.com`) || Host(`.example.com`)"},
|
||||
{"protocol only", &config.NodeFilterConfig{Protocol: "ssh"}, "Proto(`ssh`)"},
|
||||
{"path only", &config.NodeFilterConfig{Path: "/api"}, "PathPrefix(`/api`)"},
|
||||
{"combined", &config.NodeFilterConfig{
|
||||
Host: ".example.com",
|
||||
Protocol: "http",
|
||||
Path: "/api",
|
||||
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
|
||||
{"star combined", &config.NodeFilterConfig{
|
||||
Host: "*.example.com",
|
||||
Protocol: "http",
|
||||
Path: "/api",
|
||||
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := filterToMatcherRule(tt.filter); got != tt.want {
|
||||
t.Errorf("filterToMatcherRule(%v) = %q, want %q", tt.filter, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.NodeConfig
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty name", &config.NodeConfig{}, false},
|
||||
{"blank name", &config.NodeConfig{Name: " "}, false},
|
||||
{"name only", &config.NodeConfig{Name: "shared"}, true},
|
||||
{"name and addr", &config.NodeConfig{Name: "shared", Addr: "example.com:80"}, false},
|
||||
{"name and network", &config.NodeConfig{Name: "shared", Network: "tcp"}, false},
|
||||
{"name and empty-but-non-nil connector", &config.NodeConfig{Name: "shared", Connector: &config.ConnectorConfig{}}, false},
|
||||
{"name and empty bypasses slice", &config.NodeConfig{Name: "shared", Bypasses: []string{}}, true},
|
||||
{"name and matcher", &config.NodeConfig{Name: "shared", Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsReference(tt.cfg); got != tt.want {
|
||||
t.Errorf("IsReference(%+v) = %v, want %v", tt.cfg, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfig_TrimsName(t *testing.T) {
|
||||
const name = "spaced-global-node"
|
||||
registry.NodeRegistry().Register(name, &config.NodeConfig{Name: name, Addr: "example.com:8080"})
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
|
||||
|
||||
got, err := ResolveConfig(&config.NodeConfig{Name: " " + name + " "})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if got == nil || got.Addr != "example.com:8080" {
|
||||
t.Fatalf("resolved = %+v, want addr %q", got, "example.com:8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfig_DeepCopyIsolation(t *testing.T) {
|
||||
const name = "rich-global-node"
|
||||
global := &config.NodeConfig{
|
||||
Name: name,
|
||||
Addr: "example.com:8080",
|
||||
Metadata: map[string]any{"k": "v"},
|
||||
Connector: &config.ConnectorConfig{
|
||||
Type: "http",
|
||||
Auth: &config.AuthConfig{Username: "u"},
|
||||
Metadata: map[string]any{"ck": "cv"},
|
||||
TLS: &config.TLSConfig{ServerName: "sni.example", Options: &config.TLSOptions{ALPN: []string{"h2"}}},
|
||||
},
|
||||
Dialer: &config.DialerConfig{
|
||||
Type: "tcp",
|
||||
Metadata: map[string]any{"dk": "dv"},
|
||||
},
|
||||
Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"},
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
Host: "h",
|
||||
RequestHeader: map[string]string{"A": "b"},
|
||||
Auth: &config.AuthConfig{Username: "hu"},
|
||||
},
|
||||
TLS: &config.TLSNodeConfig{ServerName: "tls.example", Options: &config.TLSOptions{MinVersion: "1.2"}},
|
||||
Probe: &config.ProbeConfig{Type: "tcp", Addr: "example.com:80", HTTPHeaders: map[string]string{"H": "v"}},
|
||||
}
|
||||
registry.NodeRegistry().Register(name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
|
||||
|
||||
got, err := ResolveConfig(&config.NodeConfig{Name: name})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
|
||||
// Mutate every nested value on the copy; the global must not change.
|
||||
got.Metadata["k"] = "mut"
|
||||
got.Connector.Auth.Username = "mut"
|
||||
got.Connector.Metadata["ck"] = "mut"
|
||||
got.Connector.TLS.Options.ALPN[0] = "mut"
|
||||
got.Dialer.Metadata["dk"] = "mut"
|
||||
got.Matcher.Rule = "mut"
|
||||
got.HTTP.RequestHeader["A"] = "mut"
|
||||
got.HTTP.Auth.Username = "mut"
|
||||
got.TLS.Options.MinVersion = "mut"
|
||||
got.Probe.HTTPHeaders["H"] = "mut"
|
||||
|
||||
if global.Metadata["k"] != "v" ||
|
||||
global.Connector.Auth.Username != "u" ||
|
||||
global.Connector.Metadata["ck"] != "cv" ||
|
||||
global.Connector.TLS.Options.ALPN[0] != "h2" ||
|
||||
global.Dialer.Metadata["dk"] != "dv" ||
|
||||
global.Matcher.Rule != "Host(`x`)" ||
|
||||
global.HTTP.RequestHeader["A"] != "b" ||
|
||||
global.HTTP.Auth.Username != "hu" ||
|
||||
global.TLS.Options.MinVersion != "1.2" ||
|
||||
global.Probe.HTTPHeaders["H"] != "v" {
|
||||
t.Fatalf("global definition was mutated via the resolved copy: %+v", global)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
coreRouting "github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/x/routing"
|
||||
)
|
||||
|
||||
func TestFilterToMatcherRule_Precedence(t *testing.T) {
|
||||
for _, fc := range []struct{ name, host string }{
|
||||
{"dot", ".example.com"},
|
||||
{"star", "*.example.com"},
|
||||
} {
|
||||
t.Run(fc.name, func(t *testing.T) {
|
||||
rule := filterToMatcherRule(&config.NodeFilterConfig{
|
||||
Host: fc.host, Protocol: "http", Path: "/api",
|
||||
})
|
||||
m, err := routing.NewMatcher(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", rule, err)
|
||||
}
|
||||
if m.Match(&coreRouting.Request{Host: "example.com", Protocol: "https", Path: "/other"}) {
|
||||
t.Errorf("rule %q matched apex+https+/other (should reject)", rule)
|
||||
}
|
||||
if !m.Match(&coreRouting.Request{Host: "example.com", Protocol: "http", Path: "/api/v1"}) {
|
||||
t.Errorf("rule %q rejected apex+http+/api/v1 (should accept)", rule)
|
||||
}
|
||||
if !m.Match(&coreRouting.Request{Host: "www.example.com", Protocol: "http", Path: "/api"}) {
|
||||
t.Errorf("rule %q rejected www+http+/api (should accept)", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,25 @@ import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
observer_plugin "github.com/go-gost/x/observer/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseObserver converts an ObserverConfig into an observer.Observer. It only
|
||||
// supports plugin backends (HTTP or gRPC); returns nil when cfg or cfg.Plugin
|
||||
// is nil.
|
||||
func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
|
||||
if cfg == nil || cfg.Plugin == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -26,6 +34,7 @@ func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
|
||||
case "http":
|
||||
return observer_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package observer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseObserver_Nil(t *testing.T) {
|
||||
obs := ParseObserver(nil)
|
||||
if obs != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseObserver_NilPlugin(t *testing.T) {
|
||||
obs := ParseObserver(&config.ObserverConfig{
|
||||
Name: "no-plugin",
|
||||
Plugin: nil,
|
||||
})
|
||||
if obs != nil {
|
||||
t.Fatal("expected nil when plugin is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseObserver_PluginHTTP(t *testing.T) {
|
||||
obs := ParseObserver(&config.ObserverConfig{
|
||||
Name: "http-obs",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if obs == nil {
|
||||
t.Fatal("expected non-nil observer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseObserver_PluginGRPC(t *testing.T) {
|
||||
obs := ParseObserver(&config.ObserverConfig{
|
||||
Name: "grpc-obs",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "observer.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if obs == nil {
|
||||
t.Fatal("expected non-nil observer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseObserver_PluginDefaultType(t *testing.T) {
|
||||
obs := ParseObserver(&config.ObserverConfig{
|
||||
Name: "default-obs",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if obs == nil {
|
||||
t.Fatal("expected non-nil observer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package p2p converts a [config.P2PConfig] into a p2p.Tunnel.
|
||||
package p2p
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
p2p_plugin "github.com/go-gost/x/p2p/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseP2P converts a P2PConfig into a p2p.Tunnel backed by an
|
||||
// external gRPC plugin. Returns nil when cfg is nil or no plugin is
|
||||
// configured. The HTTP plugin variant is not implemented; configuring it
|
||||
// logs a deprecation notice and falls back to gRPC.
|
||||
func ParseP2P(cfg *config.P2PConfig) (p xp2p.Tunnel) {
|
||||
if cfg == nil || cfg.Plugin == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
ServerName: cfg.Plugin.TLS.ServerName,
|
||||
InsecureSkipVerify: !cfg.Plugin.TLS.Secure,
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(cfg.Plugin.Type) {
|
||||
case "http":
|
||||
logger.Default().Warn("p2p: http plugin type is deprecated/unimplemented, falling back to grpc")
|
||||
fallthrough
|
||||
default:
|
||||
return p2p_plugin.NewGRPCPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
)
|
||||
}
|
||||
}
|
||||
+92
-15
@@ -1,32 +1,109 @@
|
||||
// Package parsing converts user-facing configuration structs into registered
|
||||
// runtime components. Each sub-package (service, hop, chain, auth, …) owns the
|
||||
// parsing logic for its corresponding core interface.
|
||||
//
|
||||
// Metadata keys defined below bridge the gap between named config fields and
|
||||
// generic key-value metadata. When a value is not represented by an explicit
|
||||
// field on a config struct it flows through the metadata system instead and
|
||||
// these keys are used to look it up at component Init time.
|
||||
package parsing
|
||||
|
||||
const (
|
||||
// MDKeyProxyProtocol sets the HAProxy proxy-protocol version on a listener.
|
||||
MDKeyProxyProtocol = "proxyProtocol"
|
||||
MDKeyInterface = "interface"
|
||||
MDKeySoMark = "so_mark"
|
||||
MDKeyHash = "hash"
|
||||
MDKeyPreUp = "preUp"
|
||||
MDKeyPreDown = "preDown"
|
||||
MDKeyPostUp = "postUp"
|
||||
MDKeyPostDown = "postDown"
|
||||
MDKeyIgnoreChain = "ignoreChain"
|
||||
MDKeyEnableStats = "enableStats"
|
||||
|
||||
MDKeyRecorderDirection = "direction"
|
||||
// MDKeyInterface binds the outbound connection to a specific network interface.
|
||||
MDKeyInterface = "interface"
|
||||
|
||||
// MDKeySoMark sets the SO_MARK socket option on outbound connections.
|
||||
MDKeySoMark = "so_mark"
|
||||
|
||||
// MDKeyHash selects the hash source used by hash-based selectors.
|
||||
MDKeyHash = "hash"
|
||||
|
||||
// MDKeyPreUp is a shell command executed when a service starts listening.
|
||||
MDKeyPreUp = "preUp"
|
||||
|
||||
// MDKeyPreDown is a shell command executed immediately before a service
|
||||
// stops listening.
|
||||
MDKeyPreDown = "preDown"
|
||||
|
||||
// MDKeyPostUp is a shell command executed after a service has successfully
|
||||
// started listening.
|
||||
MDKeyPostUp = "postUp"
|
||||
|
||||
// MDKeyPostDown is a shell command executed after a service has stopped
|
||||
// listening.
|
||||
MDKeyPostDown = "postDown"
|
||||
|
||||
// MDKeyIgnoreChain disables chain routing for a service. When set the
|
||||
// listener/handler will not prepend a chain, which is useful for services
|
||||
// that handle routing themselves (e.g. DNS, TUN).
|
||||
MDKeyIgnoreChain = "ignoreChain"
|
||||
|
||||
// MDKeyEnableStats enables per-service connection statistics collection.
|
||||
MDKeyEnableStats = "enableStats"
|
||||
|
||||
// MDKeyRecorderDirection toggles whether traffic direction (client→server or
|
||||
// server→client) is recorded alongside the payload.
|
||||
MDKeyRecorderDirection = "direction"
|
||||
|
||||
// MDKeyRecorderTimestampFormat sets the timestamp layout used in recorder
|
||||
// output (Go time.Format layout).
|
||||
MDKeyRecorderTimestampFormat = "timeStampFormat"
|
||||
MDKeyRecorderHexdump = "hexdump"
|
||||
MDKeyRecorderHTTPBody = "http.body"
|
||||
|
||||
// MDKeyRecorderHexdump enables hexadecimal dump output in recorders.
|
||||
MDKeyRecorderHexdump = "hexdump"
|
||||
|
||||
// MDKeyRecorderHTTPBody enables capturing the HTTP request/response body in
|
||||
// HTTP-based recorders.
|
||||
MDKeyRecorderHTTPBody = "http.body"
|
||||
|
||||
// MDKeyRecorderHTTPMaxBodySize limits the maximum body size (in bytes) that
|
||||
// an HTTP recorder will capture.
|
||||
MDKeyRecorderHTTPMaxBodySize = "http.maxBodySize"
|
||||
|
||||
// MDKeyLimiterRefreshInterval sets how often a limiter reloads its
|
||||
// configuration from an external source.
|
||||
MDKeyLimiterRefreshInterval = "limiter.refreshInterval"
|
||||
|
||||
// MDKeyLimiterCleanupInterval sets how often a limiter purges expired
|
||||
// entries from its internal state.
|
||||
MDKeyLimiterCleanupInterval = "limiter.cleanupInterval"
|
||||
MDKeyLimiterScope = "limiter.scope"
|
||||
|
||||
// MDKeyLimiterScope controls whether a cached traffic limiter's scope is
|
||||
// per-service ("service") or per-client ("client").
|
||||
MDKeyLimiterScope = "limiter.scope"
|
||||
|
||||
// MDKeyObserverResetTraffic requests a traffic counter reset each time the
|
||||
// observer reports.
|
||||
MDKeyObserverResetTraffic = "observer.resetTraffic"
|
||||
MDKeyObserverPeriod = "observer.period"
|
||||
|
||||
MDKeyNetns = "netns"
|
||||
// MDKeyObserverPeriod sets the interval between observer reports.
|
||||
MDKeyObserverPeriod = "observer.period"
|
||||
|
||||
// MDKeyNetns is the network namespace name or path used for the listener
|
||||
// side (inbound).
|
||||
MDKeyNetns = "netns"
|
||||
|
||||
// MDKeyNetnsOut is the network namespace name or path used for the handler
|
||||
// (outbound) side.
|
||||
MDKeyNetnsOut = "netns.out"
|
||||
|
||||
// MDKeyDialTimeout sets the dial timeout for outbound connections.
|
||||
MDKeyDialTimeout = "dialTimeout"
|
||||
|
||||
// MDKeyLabels holds static key/value labels attached to a service's
|
||||
// records and logs.
|
||||
MDKeyLabels = "labels"
|
||||
|
||||
// MDKeyP2P selects the p2p tunnel provider for a chain node's base
|
||||
// transport. The value is the name of a registered p2ps: component.
|
||||
MDKeyP2P = "p2p"
|
||||
|
||||
// MDKeyP2PNetwork overrides the tunnel network a p2p-wrapped node asks the
|
||||
// provider for (see xp2p.WithTunnelNetwork). Empty derives it from the inner
|
||||
// dialer. A tun link sets it to "ip": datagram-shaped like udp, but
|
||||
// session-scoped on the host, so the link ends on a lost peer session.
|
||||
MDKeyP2PNetwork = "p2p.network"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
)
|
||||
|
||||
func TestMergeConfig_NilBoth(t *testing.T) {
|
||||
got := mergeConfig(nil, nil)
|
||||
if got != nil {
|
||||
t.Fatal("expected nil for two nil inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig_NilFirst(t *testing.T) {
|
||||
cfg2 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "svc"}},
|
||||
}
|
||||
got := mergeConfig(nil, cfg2)
|
||||
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
||||
t.Fatal("expected cfg2 to be returned when cfg1 is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig_NilSecond(t *testing.T) {
|
||||
cfg1 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "svc"}},
|
||||
}
|
||||
got := mergeConfig(cfg1, nil)
|
||||
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
||||
t.Fatal("expected cfg1 to be returned when cfg2 is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
cfg1 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "s1"}},
|
||||
Chains: []*config.ChainConfig{{Name: "c1"}},
|
||||
Hops: []*config.HopConfig{{Name: "h1"}},
|
||||
Nodes: []*config.NodeConfig{{Name: "n1", Addr: "n1.example:80"}},
|
||||
Authers: []*config.AutherConfig{{Name: "a1"}},
|
||||
Admissions: []*config.AdmissionConfig{{Name: "ad1"}},
|
||||
Bypasses: []*config.BypassConfig{{Name: "b1"}},
|
||||
Resolvers: []*config.ResolverConfig{{Name: "r1"}},
|
||||
Hosts: []*config.HostsConfig{{Name: "hosts1"}},
|
||||
Ingresses: []*config.IngressConfig{{Name: "i1"}},
|
||||
SDs: []*config.SDConfig{{Name: "sd1"}},
|
||||
Recorders: []*config.RecorderConfig{{Name: "rec1"}},
|
||||
Limiters: []*config.LimiterConfig{{Name: "l1"}},
|
||||
Quotas: []*config.QuotaConfig{{Name: "q1"}},
|
||||
CLimiters: []*config.LimiterConfig{{Name: "cl1"}},
|
||||
RLimiters: []*config.LimiterConfig{{Name: "rl1"}},
|
||||
Loggers: []*config.LoggerConfig{{Name: "log1"}},
|
||||
Routers: []*config.RouterConfig{{Name: "rt1"}},
|
||||
Observers: []*config.ObserverConfig{{Name: "obs1"}},
|
||||
}
|
||||
cfg2 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "s2"}},
|
||||
Chains: []*config.ChainConfig{{Name: "c2"}},
|
||||
Hops: []*config.HopConfig{{Name: "h2"}},
|
||||
Nodes: []*config.NodeConfig{{Name: "n2", Addr: "n2.example:80"}},
|
||||
Authers: []*config.AutherConfig{{Name: "a2"}},
|
||||
Admissions: []*config.AdmissionConfig{{Name: "ad2"}},
|
||||
Bypasses: []*config.BypassConfig{{Name: "b2"}},
|
||||
Resolvers: []*config.ResolverConfig{{Name: "r2"}},
|
||||
Hosts: []*config.HostsConfig{{Name: "hosts2"}},
|
||||
Ingresses: []*config.IngressConfig{{Name: "i2"}},
|
||||
SDs: []*config.SDConfig{{Name: "sd2"}},
|
||||
Recorders: []*config.RecorderConfig{{Name: "rec2"}},
|
||||
Limiters: []*config.LimiterConfig{{Name: "l2"}},
|
||||
Quotas: []*config.QuotaConfig{{Name: "q2"}},
|
||||
CLimiters: []*config.LimiterConfig{{Name: "cl2"}},
|
||||
RLimiters: []*config.LimiterConfig{{Name: "rl2"}},
|
||||
Loggers: []*config.LoggerConfig{{Name: "log2"}},
|
||||
Routers: []*config.RouterConfig{{Name: "rt2"}},
|
||||
Observers: []*config.ObserverConfig{{Name: "obs2"}},
|
||||
}
|
||||
|
||||
got := mergeConfig(cfg1, cfg2)
|
||||
|
||||
if len(got.Services) != 2 || got.Services[0].Name != "s1" || got.Services[1].Name != "s2" {
|
||||
t.Fatal("services not properly appended")
|
||||
}
|
||||
if len(got.Chains) != 2 {
|
||||
t.Fatal("chains not properly appended")
|
||||
}
|
||||
if len(got.Hops) != 2 {
|
||||
t.Fatal("hops not properly appended")
|
||||
}
|
||||
if len(got.Nodes) != 2 {
|
||||
t.Fatal("nodes not properly appended")
|
||||
}
|
||||
if len(got.Authers) != 2 {
|
||||
t.Fatal("authers not properly appended")
|
||||
}
|
||||
if len(got.Admissions) != 2 {
|
||||
t.Fatal("admissions not properly appended")
|
||||
}
|
||||
if len(got.Bypasses) != 2 {
|
||||
t.Fatal("bypasses not properly appended")
|
||||
}
|
||||
if len(got.Resolvers) != 2 {
|
||||
t.Fatal("resolvers not properly appended")
|
||||
}
|
||||
if len(got.Hosts) != 2 {
|
||||
t.Fatal("hosts not properly appended")
|
||||
}
|
||||
if len(got.Ingresses) != 2 {
|
||||
t.Fatal("ingresses not properly appended")
|
||||
}
|
||||
if len(got.SDs) != 2 {
|
||||
t.Fatal("SDs not properly appended")
|
||||
}
|
||||
if len(got.Recorders) != 2 {
|
||||
t.Fatal("recorders not properly appended")
|
||||
}
|
||||
if len(got.Limiters) != 2 {
|
||||
t.Fatal("limiters not properly appended")
|
||||
}
|
||||
if len(got.Quotas) != 2 {
|
||||
t.Fatal("quotas not properly appended")
|
||||
}
|
||||
if len(got.CLimiters) != 2 {
|
||||
t.Fatal("climiters not properly appended")
|
||||
}
|
||||
if len(got.RLimiters) != 2 {
|
||||
t.Fatal("rlimiters not properly appended")
|
||||
}
|
||||
if len(got.Loggers) != 2 {
|
||||
t.Fatal("loggers not properly appended")
|
||||
}
|
||||
if len(got.Routers) != 2 {
|
||||
t.Fatal("routers not properly appended")
|
||||
}
|
||||
if len(got.Observers) != 2 {
|
||||
t.Fatal("observers not properly appended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig_ScalarOverrides(t *testing.T) {
|
||||
cfg1 := &config.Config{
|
||||
TLS: &config.TLSConfig{ServerName: "old"},
|
||||
Log: &config.LogConfig{Level: "info"},
|
||||
API: &config.APIConfig{Addr: ":8080"},
|
||||
Metrics: &config.MetricsConfig{Addr: ":9090"},
|
||||
Profiling: &config.ProfilingConfig{Addr: ":6060"},
|
||||
}
|
||||
cfg2 := &config.Config{
|
||||
TLS: &config.TLSConfig{ServerName: "new"},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
API: &config.APIConfig{Addr: ":8081"},
|
||||
Metrics: &config.MetricsConfig{Addr: ":9091"},
|
||||
Profiling: &config.ProfilingConfig{Addr: ":6061"},
|
||||
}
|
||||
|
||||
got := mergeConfig(cfg1, cfg2)
|
||||
|
||||
if got.TLS.ServerName != "new" {
|
||||
t.Fatalf("TLS not overridden: got %q, want %q", got.TLS.ServerName, "new")
|
||||
}
|
||||
if got.Log.Level != "debug" {
|
||||
t.Fatalf("Log not overridden: got %q, want %q", got.Log.Level, "debug")
|
||||
}
|
||||
if got.API.Addr != ":8081" {
|
||||
t.Fatalf("API not overridden: got %q, want %q", got.API.Addr, ":8081")
|
||||
}
|
||||
if got.Metrics.Addr != ":9091" {
|
||||
t.Fatalf("Metrics not overridden: got %q, want %q", got.Metrics.Addr, ":9091")
|
||||
}
|
||||
if got.Profiling.Addr != ":6061" {
|
||||
t.Fatalf("Profiling not overridden: got %q, want %q", got.Profiling.Addr, ":6061")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) {
|
||||
cfg1 := &config.Config{
|
||||
TLS: &config.TLSConfig{ServerName: "keep"},
|
||||
Log: &config.LogConfig{Level: "warn"},
|
||||
API: &config.APIConfig{Addr: ":8080"},
|
||||
Metrics: &config.MetricsConfig{Addr: ":9090"},
|
||||
Profiling: &config.ProfilingConfig{Addr: ":6060"},
|
||||
}
|
||||
cfg2 := &config.Config{} // all scalars nil
|
||||
|
||||
got := mergeConfig(cfg1, cfg2)
|
||||
|
||||
if got.TLS.ServerName != "keep" {
|
||||
t.Fatal("TLS should be kept from cfg1")
|
||||
}
|
||||
if got.Log.Level != "warn" {
|
||||
t.Fatal("Log should be kept from cfg1")
|
||||
}
|
||||
if got.API.Addr != ":8080" {
|
||||
t.Fatal("API should be kept from cfg1")
|
||||
}
|
||||
if got.Metrics.Addr != ":9090" {
|
||||
t.Fatal("Metrics should be kept from cfg1")
|
||||
}
|
||||
if got.Profiling.Addr != ":6060" {
|
||||
t.Fatal("Profiling should be kept from cfg1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
Init(Args{
|
||||
CfgFiles: []string{"test.yml"},
|
||||
Services: []string{"s1"},
|
||||
Nodes: []string{"n1"},
|
||||
Debug: true,
|
||||
Trace: false,
|
||||
ApiAddr: ":8080",
|
||||
MetricsAddr: ":9090",
|
||||
})
|
||||
|
||||
if len(defaultParser.args.CfgFiles) != 1 || defaultParser.args.CfgFiles[0] != "test.yml" {
|
||||
t.Fatalf("CfgFiles not set: got %v", defaultParser.args.CfgFiles)
|
||||
}
|
||||
if len(defaultParser.args.Services) != 1 || defaultParser.args.Services[0] != "s1" {
|
||||
t.Fatal("Services not set")
|
||||
}
|
||||
if len(defaultParser.args.Nodes) != 1 || defaultParser.args.Nodes[0] != "n1" {
|
||||
t.Fatal("Nodes not set")
|
||||
}
|
||||
if !defaultParser.args.Debug {
|
||||
t.Fatal("Debug not set")
|
||||
}
|
||||
if defaultParser.args.Trace {
|
||||
t.Fatal("Trace should be false")
|
||||
}
|
||||
if defaultParser.args.ApiAddr != ":8080" {
|
||||
t.Fatal("ApiAddr not set")
|
||||
}
|
||||
if defaultParser.args.MetricsAddr != ":9090" {
|
||||
t.Fatal("MetricsAddr not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MultiFile(t *testing.T) {
|
||||
// Create two temporary config files.
|
||||
cfg1 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "s1", Addr: ":8080"}},
|
||||
}
|
||||
cfg2 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "s2", Addr: ":8081"}},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
Quotas: []*config.QuotaConfig{{Name: "q1"}},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir, "cfg1.yml")
|
||||
file2 := filepath.Join(tmpDir, "cfg2.yml")
|
||||
|
||||
for _, entry := range []struct {
|
||||
path string
|
||||
cfg *config.Config
|
||||
}{
|
||||
{file1, cfg1},
|
||||
{file2, cfg2},
|
||||
} {
|
||||
f, err := os.Create(entry.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := entry.cfg.Write(f, "yaml"); err != nil {
|
||||
f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
Init(Args{
|
||||
CfgFiles: []string{file1, file2},
|
||||
})
|
||||
|
||||
got, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(): %v", err)
|
||||
}
|
||||
|
||||
if len(got.Services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(got.Services))
|
||||
}
|
||||
if got.Log == nil || got.Log.Level != "debug" {
|
||||
t.Fatal("Log.Level not set from cfg2")
|
||||
}
|
||||
if len(got.Quotas) != 1 || got.Quotas[0].Name != "q1" {
|
||||
t.Fatal("Quotas not merged from cfg2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTPURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"http://example.com/config.yaml", true},
|
||||
{"https://example.com/config.json", true},
|
||||
{"ftp://example.com/config.yaml", false},
|
||||
{"config.yaml", false},
|
||||
{"/path/to/config.yml", false},
|
||||
{"http://[::1]:8080/cfg", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isHTTPURL(tt.input); got != tt.expected {
|
||||
t.Errorf("isHTTPURL(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
contentType string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"", "http://example.com/config.json", "json"},
|
||||
{"", "http://example.com/config.yml", "yaml"},
|
||||
{"", "http://example.com/config.yaml", "yaml"},
|
||||
{"", "http://example.com/config", "yaml"}, // default
|
||||
{"application/json", "http://example.com/config", "json"},
|
||||
{"application/yaml", "http://example.com/config", "yaml"},
|
||||
{"application/x-yaml", "http://example.com/config", "yaml"},
|
||||
{"text/yaml", "http://example.com/config", "yaml"},
|
||||
{"text/x-yaml", "http://example.com/config", "yaml"},
|
||||
{"text/plain; charset=utf-8", "http://example.com/config.json", "json"}, // content-type ignored, extension wins
|
||||
{"text/plain", "http://example.com/config", "yaml"}, // unknown -> default
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := detectFormat(tt.contentType, tt.url); got != tt.want {
|
||||
t.Errorf("detectFormat(%q, %q) = %q, want %q", tt.contentType, tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"http://user:pass@example.com/path", "http://example.com/path"},
|
||||
{"https://example.com/path", "https://example.com/path"},
|
||||
{"not a url", "not%20a%20url"},
|
||||
{"http://user@example.com/path", "http://example.com/path"}, // user with no password
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := sanitizeURL(tt.input); got != tt.expected {
|
||||
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_YAML(t *testing.T) {
|
||||
// Write a YAML config and serve it via httptest.
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "svc", Addr: ":8080"}},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := cfg.Write(&buf, "yaml"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
got := &config.Config{}
|
||||
if err := readConfigFromURL(ts.URL, got); err != nil {
|
||||
t.Fatalf("readConfigFromURL: %v", err)
|
||||
}
|
||||
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
||||
t.Fatalf("unexpected services: %+v", got.Services)
|
||||
}
|
||||
if got.Log == nil || got.Log.Level != "debug" {
|
||||
t.Fatalf("unexpected log: %+v", got.Log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_JSON(t *testing.T) {
|
||||
// Remote serving JSON with explicit Content-Type.
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "json-svc", Addr: ":9090"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := cfg.Write(&buf, "json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
got := &config.Config{}
|
||||
if err := readConfigFromURL(ts.URL, got); err != nil {
|
||||
t.Fatalf("readConfigFromURL: %v", err)
|
||||
}
|
||||
if got.Services[0].Name != "json-svc" {
|
||||
t.Fatalf("unexpected service name: %s", got.Services[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_Non200(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
err := readConfigFromURL(ts.URL, &config.Config{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_OverSize(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(make([]byte, maxConfigSize+1))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
err := readConfigFromURL(ts.URL, &config.Config{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for oversized response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileAndURL(t *testing.T) {
|
||||
// Create a local file.
|
||||
fileCfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "file-svc", Addr: ":8080"}},
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
f, err := os.Create(filepath.Join(tmpDir, "local.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fileCfg.Write(f, "yaml"); err != nil {
|
||||
f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Serve a second config over HTTP.
|
||||
urlCfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "url-svc", Addr: ":8081"}},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := urlCfg.Write(&buf, "yaml"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
Init(Args{
|
||||
CfgFiles: []string{filepath.Join(tmpDir, "local.yml"), ts.URL},
|
||||
})
|
||||
|
||||
got, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(): %v", err)
|
||||
}
|
||||
if len(got.Services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(got.Services))
|
||||
}
|
||||
if got.Log == nil || got.Log.Level != "debug" {
|
||||
t.Fatal("Log.Level not set from URL config")
|
||||
}
|
||||
}
|
||||
+184
-25
@@ -1,9 +1,18 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -16,23 +25,45 @@ var (
|
||||
defaultParser = &parser{}
|
||||
)
|
||||
|
||||
// Init stores the parsed CLI arguments so that subsequent calls to Parse can
|
||||
// merge them with config-file and environment-variable sources.
|
||||
func Init(args Args) {
|
||||
defaultParser = &parser{
|
||||
args: args,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse reads configuration from the sources specified during Init (config
|
||||
// file, CLI services/nodes, and environment variables) and returns a merged
|
||||
// Config ready for loading.
|
||||
func Parse() (*config.Config, error) {
|
||||
return defaultParser.Parse()
|
||||
}
|
||||
|
||||
// Args holds the raw CLI flags and positional arguments that Init will merge
|
||||
// into the final configuration.
|
||||
type Args struct {
|
||||
CfgFile string
|
||||
Services []string
|
||||
Nodes []string
|
||||
Debug bool
|
||||
Trace bool
|
||||
ApiAddr string
|
||||
// CfgFiles is the list of YAML or JSON config sources: file paths,
|
||||
// HTTP(S) URLs, "-" for stdin, or an inline JSON string.
|
||||
// Multiple sources are merged left-to-right.
|
||||
CfgFiles []string
|
||||
|
||||
// Services is the list of service definitions from -L flags.
|
||||
Services []string
|
||||
|
||||
// Nodes is the list of chain-node definitions from -F flags.
|
||||
Nodes []string
|
||||
|
||||
// Debug toggles debug-level logging.
|
||||
Debug bool
|
||||
|
||||
// Trace toggles trace-level logging (supersedes Debug).
|
||||
Trace bool
|
||||
|
||||
// ApiAddr is the address for the HTTP API server.
|
||||
ApiAddr string
|
||||
|
||||
// MetricsAddr is the address for the Prometheus metrics endpoint.
|
||||
MetricsAddr string
|
||||
}
|
||||
|
||||
@@ -43,18 +74,16 @@ type parser struct {
|
||||
func (p *parser) Parse() (*config.Config, error) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
cfgFile := p.args.CfgFile
|
||||
if cfgFile != "" {
|
||||
for _, cfgFile := range p.args.CfgFiles {
|
||||
cfgFile = strings.TrimSpace(cfgFile)
|
||||
if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") {
|
||||
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.ReadFile(cfgFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfgFile == "" {
|
||||
continue
|
||||
}
|
||||
fcfg, err := readConfig(cfgFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = mergeConfig(cfg, fcfg)
|
||||
}
|
||||
|
||||
cmdCfg, err := cmd.BuildConfigFromCmd(p.args.Services, p.args.Nodes)
|
||||
@@ -63,31 +92,36 @@ func (p *parser) Parse() (*config.Config, error) {
|
||||
}
|
||||
cfg = mergeConfig(cfg, cmdCfg)
|
||||
|
||||
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil {
|
||||
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil &&
|
||||
len(p.args.CfgFiles) == 0 {
|
||||
if err := cfg.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("GOST_LOGGER_LEVEL"); v != "" {
|
||||
cfg.Log = &config.LogConfig{
|
||||
Level: v,
|
||||
if cfg.Log == nil {
|
||||
cfg.Log = &config.LogConfig{}
|
||||
}
|
||||
cfg.Log.Level = v
|
||||
}
|
||||
if v := os.Getenv("GOST_API"); v != "" {
|
||||
cfg.API = &config.APIConfig{
|
||||
Addr: v,
|
||||
if cfg.API == nil {
|
||||
cfg.API = &config.APIConfig{}
|
||||
}
|
||||
cfg.API.Addr = v
|
||||
}
|
||||
if v := os.Getenv("GOST_METRICS"); v != "" {
|
||||
cfg.Metrics = &config.MetricsConfig{
|
||||
Addr: v,
|
||||
if cfg.Metrics == nil {
|
||||
cfg.Metrics = &config.MetricsConfig{}
|
||||
}
|
||||
cfg.Metrics.Addr = v
|
||||
}
|
||||
if v := os.Getenv("GOST_PROFILING"); v != "" {
|
||||
cfg.Profiling = &config.ProfilingConfig{
|
||||
Addr: v,
|
||||
if cfg.Profiling == nil {
|
||||
cfg.Profiling = &config.ProfilingConfig{}
|
||||
}
|
||||
cfg.Profiling.Addr = v
|
||||
}
|
||||
|
||||
if p.args.Debug || p.args.Trace {
|
||||
@@ -154,6 +188,126 @@ func (p *parser) Parse() (*config.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// readConfig reads a single configuration source, which may be "-" (stdin),
|
||||
// an HTTP(S) URL, an inline JSON string starting with "{", or a file path.
|
||||
func readConfig(cfgFile string) (*config.Config, error) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
if cfgFile == "-" { // stdin
|
||||
br := bufio.NewReader(os.Stdin)
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b[0] == '{' {
|
||||
if err := cfg.Read(br, "json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.Read(br, "yaml"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
|
||||
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if isHTTPURL(cfgFile) { // URL
|
||||
if err := readConfigFromURL(cfgFile, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.ReadFile(cfgFile); err != nil { // file
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// isHTTPURL reports whether s is an HTTP or HTTPS URL.
|
||||
func isHTTPURL(s string) bool {
|
||||
u, err := url.Parse(s)
|
||||
return err == nil && u.Scheme != "" && u.Host != "" &&
|
||||
(u.Scheme == "http" || u.Scheme == "https")
|
||||
}
|
||||
|
||||
// maxConfigSize is the maximum response body size for remote config files.
|
||||
const maxConfigSize = 10 << 20 // 10 MiB
|
||||
|
||||
// sharedHTTPClient is reused across config URL fetches to benefit from
|
||||
// connection pooling.
|
||||
var sharedHTTPClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// readConfigFromURL fetches a configuration from an HTTP(S) URL and reads it
|
||||
// into cfg. The format (yaml or json) is detected from the Content-Type header
|
||||
// or the URL path extension.
|
||||
func readConfigFromURL(urlStr string, cfg *config.Config) error {
|
||||
resp, err := sharedHTTPClient.Get(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch config from %s: %w", sanitizeURL(urlStr), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body) // drain body for connection reuse; ignore error since we are already failing
|
||||
return fmt.Errorf("fetch config from %s: %s", sanitizeURL(urlStr), resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxConfigSize+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config from %s: %w", sanitizeURL(urlStr), err)
|
||||
}
|
||||
if len(data) > maxConfigSize {
|
||||
return fmt.Errorf("config from %s exceeds maximum size %d", sanitizeURL(urlStr), maxConfigSize)
|
||||
}
|
||||
|
||||
format := detectFormat(resp.Header.Get("Content-Type"), urlStr)
|
||||
|
||||
if err := cfg.Read(bytes.NewReader(data), format); err != nil {
|
||||
return fmt.Errorf("parse config from %s: %w", sanitizeURL(urlStr), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeURL returns the URL with any embedded userinfo stripped for safe
|
||||
// use in error messages.
|
||||
func sanitizeURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// detectFormat determines the config format from the HTTP Content-Type header
|
||||
// or the URL path extension. Returns "yaml" or "json".
|
||||
func detectFormat(contentType, urlStr string) string {
|
||||
if mediatype, _, err := mime.ParseMediaType(contentType); err == nil {
|
||||
switch mediatype {
|
||||
case "application/json":
|
||||
return "json"
|
||||
case "application/yaml", "application/x-yaml",
|
||||
"text/yaml", "text/x-yaml":
|
||||
return "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
if u, err := url.Parse(urlStr); err == nil {
|
||||
switch strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), ".")) {
|
||||
case "json":
|
||||
return "json"
|
||||
case "yaml", "yml":
|
||||
return "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
return "yaml"
|
||||
}
|
||||
|
||||
func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
if cfg1 == nil {
|
||||
return cfg2
|
||||
@@ -166,6 +320,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
Services: append(cfg1.Services, cfg2.Services...),
|
||||
Chains: append(cfg1.Chains, cfg2.Chains...),
|
||||
Hops: append(cfg1.Hops, cfg2.Hops...),
|
||||
Nodes: append(cfg1.Nodes, cfg2.Nodes...),
|
||||
Authers: append(cfg1.Authers, cfg2.Authers...),
|
||||
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
|
||||
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
|
||||
@@ -174,7 +329,11 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
|
||||
SDs: append(cfg1.SDs, cfg2.SDs...),
|
||||
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
|
||||
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
|
||||
P2Ps: append(cfg1.P2Ps, cfg2.P2Ps...),
|
||||
Caches: append(cfg1.Caches, cfg2.Caches...),
|
||||
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
|
||||
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
|
||||
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
|
||||
RLimiters: append(cfg1.RLimiters, cfg2.RLimiters...),
|
||||
Loggers: append(cfg1.Loggers, cfg2.Loggers...),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/units"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xquota "github.com/go-gost/x/limiter/quota"
|
||||
)
|
||||
|
||||
// ParseQuotaLimiter is best-effort: malformed fields are logged and treated as unset.
|
||||
func ParseQuotaLimiter(cfg *config.QuotaConfig) *xquota.Limiter {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log := logger.Default().WithFields(map[string]any{
|
||||
"kind": "quota",
|
||||
"quota": cfg.Name,
|
||||
})
|
||||
|
||||
var limit uint64
|
||||
if s := strings.TrimSpace(cfg.Limit); s != "" && s != "0" {
|
||||
if v, err := units.ParseBase2Bytes(s); err != nil {
|
||||
log.Warnf("quota: parse limit %q: %v", s, err)
|
||||
} else if v > 0 {
|
||||
limit = uint64(v)
|
||||
}
|
||||
}
|
||||
|
||||
var dir xquota.Direction
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.Direction)) {
|
||||
case "in":
|
||||
dir = xquota.DirectionIn
|
||||
case "out":
|
||||
dir = xquota.DirectionOut
|
||||
default:
|
||||
dir = xquota.DirectionTotal
|
||||
}
|
||||
|
||||
var flush time.Duration
|
||||
if s := strings.TrimSpace(cfg.Flush); s != "" {
|
||||
if d, err := time.ParseDuration(s); err != nil {
|
||||
log.Warnf("quota: parse flush %q: %v", s, err)
|
||||
} else {
|
||||
flush = d
|
||||
}
|
||||
}
|
||||
|
||||
return xquota.NewLimiter(cfg.Name, xquota.Options{
|
||||
Limit: limit,
|
||||
StartsAt: parseQuotaTime(cfg.StartsAt, log, "startsAt"),
|
||||
ExpiresAt: parseQuotaTime(cfg.ExpiresAt, log, "expiresAt"),
|
||||
Direction: dir,
|
||||
Flush: flush,
|
||||
Store: parseQuotaStore(cfg.Store, log),
|
||||
Logger: log,
|
||||
})
|
||||
}
|
||||
|
||||
func parseQuotaTime(s string, log logger.Logger, field string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
log.Warnf("quota: parse %s %q: %v", field, s, err)
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func parseQuotaStore(sc *config.QuotaStoreConfig, log logger.Logger) xquota.Store {
|
||||
const defaultFile = "gost-quota.json"
|
||||
|
||||
if sc == nil || sc.Type == "" || strings.EqualFold(sc.Type, "file") {
|
||||
path := defaultFile
|
||||
if sc != nil && strings.TrimSpace(sc.File) != "" {
|
||||
path = sc.File
|
||||
}
|
||||
return xquota.NewFileStore(path)
|
||||
}
|
||||
|
||||
if strings.EqualFold(sc.Type, "redis") {
|
||||
log.Warnf("quota: redis store not implemented; counter will not be persisted")
|
||||
var rc config.QuotaRedisConfig
|
||||
if sc.Redis != nil {
|
||||
rc = *sc.Redis
|
||||
}
|
||||
return xquota.NewRedisStore(xquota.RedisConfig{
|
||||
Addr: rc.Addr,
|
||||
Username: rc.Username,
|
||||
Password: rc.Password,
|
||||
DB: rc.DB,
|
||||
Key: rc.Key,
|
||||
})
|
||||
}
|
||||
|
||||
log.Warnf("quota: unknown store type %q; using file store %q", sc.Type, defaultFile)
|
||||
return xquota.NewFileStore(defaultFile)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
recorder_plugin "github.com/go-gost/x/recorder/plugin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
@@ -22,12 +23,20 @@ type discardCloser struct{}
|
||||
func (discardCloser) Write(p []byte) (n int, err error) { return len(p), nil }
|
||||
func (discardCloser) Close() error { return nil }
|
||||
|
||||
// ParseRecorder converts a RecorderConfig into a recorder.Recorder. It
|
||||
// supports plugin backends (HTTP or gRPC), file output (with optional
|
||||
// rotation), TCP syslog-style delivery, HTTP POST delivery, and Redis storage
|
||||
// (set, list, or sorted set). Returns nil when cfg is nil or no backend is
|
||||
// configured.
|
||||
func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -39,6 +48,7 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
|
||||
case "http":
|
||||
return recorder_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package recorder
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseRecorder_Nil(t *testing.T) {
|
||||
r := ParseRecorder(nil)
|
||||
if r != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_PluginHTTP(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "http-rec",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_PluginGRPC(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "grpc-rec",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "recorder.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_PluginDefaultType(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "default-rec",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_TCP(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "tcp-rec",
|
||||
TCP: &config.TCPRecorder{
|
||||
Addr: "127.0.0.1:9999",
|
||||
Timeout: 0,
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for TCP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_HTTP(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "http-rec",
|
||||
HTTP: &config.HTTPRecorder{
|
||||
URL: "http://localhost:8080/record",
|
||||
Timeout: 0,
|
||||
Header: map[string]string{"X-Test": "value"},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for HTTP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_File(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "file-rec",
|
||||
File: &config.FileRecorder{
|
||||
Path: "/tmp/recorder.log",
|
||||
Sep: "\n",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_FileWithRotation(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "file-rot-rec",
|
||||
File: &config.FileRecorder{
|
||||
Path: "/tmp/recorder-rot.log",
|
||||
Rotation: &config.LogRotationConfig{
|
||||
MaxSize: 100,
|
||||
MaxAge: 30,
|
||||
MaxBackups: 10,
|
||||
LocalTime: true,
|
||||
Compress: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for file with rotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_RedisSet(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "redis-set-rec",
|
||||
Redis: &config.RedisRecorder{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "recorder-set",
|
||||
Type: "set",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for redis set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_RedisList(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "redis-list-rec",
|
||||
Redis: &config.RedisRecorder{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "recorder-list",
|
||||
Type: "list",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for redis list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_RedisSortedSet(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "redis-sset-rec",
|
||||
Redis: &config.RedisRecorder{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "recorder-sset",
|
||||
Type: "sset",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil recorder for redis sorted set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_EmptyFileReturnsNil(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "empty-rec",
|
||||
})
|
||||
if r != nil {
|
||||
t.Fatal("expected nil recorder when no sub-config is provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_FileWithEmptyPathReturnsNil(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "empty-path-rec",
|
||||
File: &config.FileRecorder{
|
||||
Path: "",
|
||||
},
|
||||
})
|
||||
if r != nil {
|
||||
t.Fatal("expected nil recorder when file path is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecorder_TCPWithEmptyAddrReturnsNil(t *testing.T) {
|
||||
r := ParseRecorder(&config.RecorderConfig{
|
||||
Name: "empty-tcp-rec",
|
||||
TCP: &config.TCPRecorder{
|
||||
Addr: "",
|
||||
},
|
||||
})
|
||||
if r != nil {
|
||||
t.Fatal("expected nil recorder when TCP addr is empty")
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,25 @@ import (
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
xresolver "github.com/go-gost/x/resolver"
|
||||
resolver_plugin "github.com/go-gost/x/resolver/plugin"
|
||||
)
|
||||
|
||||
// ParseResolver converts a ResolverConfig into a resolver.Resolver. It
|
||||
// supports plugin backends (HTTP or gRPC) and inline nameserver definitions
|
||||
// with optional chain-based upstream routing, TTL, timeout, and
|
||||
// prefer/async/only settings. Returns nil with no error when cfg is nil.
|
||||
func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -31,6 +39,7 @@ func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
|
||||
case "http":
|
||||
return resolver_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
), nil
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestParseResolver_Nil(t *testing.T) {
|
||||
r, err := ParseResolver(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_PluginHTTP(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "http-res",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_PluginGRPC(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "grpc-res",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "resolver.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_PluginDefaultType(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "default-res",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_WithNameservers(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "ns-res",
|
||||
Nameservers: []*config.NameserverConfig{
|
||||
{Addr: "8.8.8.8", Prefer: "ipv4"},
|
||||
{Addr: "8.8.4.4", Prefer: "ipv4"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_EmptyNameservers(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "empty-ns",
|
||||
Nameservers: []*config.NameserverConfig{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver even with empty nameservers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_NameserverWithClientIP(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "clientip-res",
|
||||
Nameservers: []*config.NameserverConfig{
|
||||
{Addr: "8.8.8.8", ClientIP: "1.2.3.4"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolver_NameserverWithAllFields(t *testing.T) {
|
||||
r, err := ParseResolver(&config.ResolverConfig{
|
||||
Name: "full-res",
|
||||
Nameservers: []*config.NameserverConfig{
|
||||
{
|
||||
Addr: "8.8.8.8:53",
|
||||
Chain: "chain1",
|
||||
Prefer: "ipv4",
|
||||
ClientIP: "1.2.3.4",
|
||||
Hostname: "dns.example.com",
|
||||
TTL: 300,
|
||||
Timeout: 5,
|
||||
Async: true,
|
||||
Only: "A",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil resolver")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rewriter
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
rewriter_plugin "github.com/go-gost/x/rewriter/plugin"
|
||||
)
|
||||
|
||||
// ParseRewriter converts a RewriterConfig into a rewriter.Rewriter.
|
||||
// It currently supports plugin backends only (HTTP or gRPC).
|
||||
// Returns nil when cfg is nil or no backend is configured.
|
||||
func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
ServerName: cfg.Plugin.TLS.ServerName,
|
||||
InsecureSkipVerify: !cfg.Plugin.TLS.Secure,
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(cfg.Plugin.Type) {
|
||||
case "http":
|
||||
return rewriter_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
default:
|
||||
return rewriter_plugin.NewGRPCPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -10,16 +10,23 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
xrouter "github.com/go-gost/x/router"
|
||||
router_plugin "github.com/go-gost/x/router/plugin"
|
||||
)
|
||||
|
||||
// ParseRouter converts a RouterConfig into a router.Router. It supports plugin
|
||||
// backends (HTTP or gRPC) or inline static route definitions with optional
|
||||
// file, Redis, and HTTP hot-reload support.
|
||||
func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -31,6 +38,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
case "http":
|
||||
return router_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
@@ -51,7 +59,9 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
_, ipNet, _ := net.ParseCIDR(route.Net)
|
||||
dst := route.Dst
|
||||
if dst != "" {
|
||||
_, ipNet, _ = net.ParseCIDR(dst)
|
||||
if _, parsed, _ := net.ParseCIDR(dst); parsed != nil {
|
||||
ipNet = parsed
|
||||
}
|
||||
} else {
|
||||
if ipNet != nil {
|
||||
dst = ipNet.String()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user