Compare commits
368 Commits
2a75be91b0
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b1e38e2c0 | |||
| 74fef8838e | |||
| bf85966600 | |||
| a24d79fd45 | |||
| cb5d5c590b | |||
| 3993c95700 | |||
| 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 | |||
| 6caad5e37e | |||
| d819fd3041 | |||
| 8cacfad9eb | |||
| a71b9d79fd | |||
| 9a380d3fd8 | |||
| 4c99e11d81 | |||
| 1eaf08c9c1 | |||
| 0e59ccc64b | |||
| 04d1587a77 | |||
| 69a0aa8853 | |||
| e0915affda | |||
| d610e2dc01 | |||
| 2495b13053 | |||
| 11bca5bc3d | |||
| e4b1c3c2b5 | |||
| 75a106ee84 | |||
| 2132268780 | |||
| ef440e8b4a | |||
| fc12e33786 | |||
| 08ad260606 | |||
| 9cd03b6b59 | |||
| 355c72477a | |||
| 379d9a73ad | |||
| bf36bdc680 | |||
| aa7528fc1e | |||
| e480039691 | |||
| 489a762811 | |||
| eef3bd7f25 | |||
| 06d993023f | |||
| 914a4622fd | |||
| a5d4774682 | |||
| be8e0555e5 | |||
| 2693ba61c4 | |||
| fcb54bd576 | |||
| 5206d1bd58 | |||
| 98f5a5f7eb | |||
| ff6807815f | |||
| 8d962398ab | |||
| c1e6164a83 | |||
| 6f001a779a | |||
| 2e8a5d6f51 | |||
| c2aedbabd9 | |||
| 79b6b9138e | |||
| 3db20563d2 | |||
| a12870e766 | |||
| edb9a992e2 | |||
| f4d9a5f79b | |||
| 88ba01b3dd | |||
| d8126e8e30 | |||
| a97ac1f30a | |||
| 3b4fd643de | |||
| 3ca253537b | |||
| 41f527aa46 | |||
| 6b932e35bf | |||
| a0cbee8817 | |||
| 7e51404ae5 | |||
| 618d042001 | |||
| 24547b4332 | |||
| ea179a0ee6 | |||
| 51ca9f620a | |||
| 5bba004324 | |||
| 0ffed81601 | |||
| 27242d0b66 | |||
| 8f994ab632 | |||
| 9a56c8fba3 | |||
| c42a44abb6 | |||
| 3c8add4b82 | |||
| dfb6cb95d0 | |||
| e22aa91571 | |||
| dd179bc951 | |||
| 8934cb6b1c | |||
| e09ec08881 | |||
| e37213ac01 | |||
| a618c5556e | |||
| ce2da421fa | |||
| b39aa63f75 | |||
| f681f7aa03 | |||
| 69455ace9d | |||
| e7581f0a9e | |||
| 31c482c649 | |||
| 9405d1fb48 | |||
| 5a19a33f51 | |||
| ff20711c25 | |||
| bc0d6953bc | |||
| 22e522e933 | |||
| 5e8a8a4b4d | |||
| 1a776dc759 | |||
| 12ef82e41f | |||
| 3656ba9315 | |||
| c0a80400d2 |
@@ -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.
|
||||
|
||||
+167
-31
@@ -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,5 +375,35 @@ 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, network, addr string, opts ...admission.Option) bool {
|
||||
for _, admission := range p.admissions {
|
||||
if admission != nil && !admission.Admit(ctx, network, addr, opts...) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+57
-82
@@ -1,14 +1,14 @@
|
||||
// swagger generate spec -o swagger.yaml
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/service"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,56 +17,22 @@ 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 {
|
||||
accessLog bool
|
||||
pathPrefix string
|
||||
auther auth.Authenticator
|
||||
type Options struct {
|
||||
AccessLog bool
|
||||
PathPrefix string
|
||||
Auther auth.Authenticator
|
||||
}
|
||||
|
||||
type Option func(*options)
|
||||
|
||||
func PathPrefixOption(pathPrefix string) Option {
|
||||
return func(o *options) {
|
||||
o.pathPrefix = pathPrefix
|
||||
}
|
||||
}
|
||||
|
||||
func AccessLogOption(enable bool) Option {
|
||||
return func(o *options) {
|
||||
o.accessLog = enable
|
||||
}
|
||||
}
|
||||
|
||||
func AutherOption(auther auth.Authenticator) Option {
|
||||
return func(o *options) {
|
||||
o.auther = auther
|
||||
}
|
||||
}
|
||||
|
||||
type server struct {
|
||||
s *http.Server
|
||||
ln net.Listener
|
||||
cclose chan struct{}
|
||||
}
|
||||
|
||||
func NewService(addr string, opts ...Option) (service.Service, error) {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func Register(r *gin.Engine, opts *Options) {
|
||||
if opts == nil {
|
||||
opts = &Options{}
|
||||
}
|
||||
|
||||
var options options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(
|
||||
cors.New((cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
@@ -76,115 +42,124 @@ func NewService(addr string, opts ...Option) (service.Service, error) {
|
||||
})),
|
||||
gin.Recovery(),
|
||||
)
|
||||
if options.accessLog {
|
||||
if opts.AccessLog {
|
||||
r.Use(mwLogger())
|
||||
}
|
||||
|
||||
router := r.Group("")
|
||||
if options.pathPrefix != "" {
|
||||
router = router.Group(options.pathPrefix)
|
||||
if opts.PathPrefix != "" {
|
||||
router = router.Group(opts.PathPrefix)
|
||||
}
|
||||
|
||||
router.StaticFS("/docs", http.FS(swaggerDoc))
|
||||
|
||||
config := router.Group("/config")
|
||||
config.Use(mwBasicAuth(options.auther))
|
||||
registerConfig(config)
|
||||
config.Use(mwBasicAuth(opts.Auther))
|
||||
|
||||
return &server{
|
||||
s: &http.Server{
|
||||
Handler: r,
|
||||
},
|
||||
ln: ln,
|
||||
cclose: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) Serve() error {
|
||||
return s.s.Serve(s.ln)
|
||||
}
|
||||
|
||||
func (s *server) Addr() net.Addr {
|
||||
return s.ln.Addr()
|
||||
}
|
||||
|
||||
func (s *server) Close() error {
|
||||
return s.s.Close()
|
||||
}
|
||||
|
||||
func (s *server) IsClosed() bool {
|
||||
select {
|
||||
case <-s.cclose:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func registerConfig(config *gin.RouterGroup) {
|
||||
config.GET("", getConfig)
|
||||
config.POST("", saveConfig)
|
||||
|
||||
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)
|
||||
|
||||
+53
-26
@@ -17,6 +17,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 +101,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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/loader"
|
||||
"github.com/go-gost/x/config/parsing/parser"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters reloadConfigRequest
|
||||
type reloadConfigRequest struct{}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response reloadConfigResponse
|
||||
type reloadConfigResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func reloadConfig(ctx *gin.Context) {
|
||||
// swagger:route POST /config/reload Reload reloadConfigRequest
|
||||
//
|
||||
// Hot reload config.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: reloadConfigResponse
|
||||
|
||||
cfg, err := parser.Parse()
|
||||
if err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
config.Set(cfg)
|
||||
|
||||
if err := loader.Load(cfg); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
for _, svc := range registry.ServiceRegistry().GetAll() {
|
||||
svc := svc
|
||||
go func() {
|
||||
svc.Serve()
|
||||
}()
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/service"
|
||||
"github.com/go-gost/x/api"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
accessLog bool
|
||||
pathPrefix string
|
||||
auther auth.Authenticator
|
||||
}
|
||||
|
||||
type Option func(*options)
|
||||
|
||||
func PathPrefixOption(pathPrefix string) Option {
|
||||
return func(o *options) {
|
||||
o.pathPrefix = pathPrefix
|
||||
}
|
||||
}
|
||||
|
||||
func AccessLogOption(enable bool) Option {
|
||||
return func(o *options) {
|
||||
o.accessLog = enable
|
||||
}
|
||||
}
|
||||
|
||||
func AutherOption(auther auth.Authenticator) Option {
|
||||
return func(o *options) {
|
||||
o.auther = auther
|
||||
}
|
||||
}
|
||||
|
||||
type server struct {
|
||||
s *http.Server
|
||||
ln net.Listener
|
||||
cclose chan struct{}
|
||||
}
|
||||
|
||||
func NewService(network, addr string, opts ...Option) (service.Service, error) {
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
ln, err := net.Listen(network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var options options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
api.Register(r, &api.Options{
|
||||
AccessLog: options.accessLog,
|
||||
PathPrefix: options.pathPrefix,
|
||||
Auther: options.auther,
|
||||
})
|
||||
|
||||
return &server{
|
||||
s: &http.Server{
|
||||
Handler: r,
|
||||
},
|
||||
ln: ln,
|
||||
cclose: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) Serve() error {
|
||||
return s.s.Serve(s.ln)
|
||||
}
|
||||
|
||||
func (s *server) Addr() net.Addr {
|
||||
return s.ln.Addr()
|
||||
}
|
||||
|
||||
func (s *server) Close() error {
|
||||
return s.s.Close()
|
||||
}
|
||||
|
||||
func (s *server) IsClosed() bool {
|
||||
select {
|
||||
case <-s.cclose:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
+898
-8
File diff suppressed because it is too large
Load Diff
+85
-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,5 +266,34 @@ 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
|
||||
}
|
||||
|
||||
type authenticatorGroup struct {
|
||||
authers []auth.Authenticator
|
||||
}
|
||||
|
||||
func AuthenticatorGroup(authers ...auth.Authenticator) auth.Authenticator {
|
||||
return &authenticatorGroup{
|
||||
authers: authers,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *authenticatorGroup) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
|
||||
if len(p.authers) == 0 {
|
||||
return "", false
|
||||
}
|
||||
for _, auther := range p.authers {
|
||||
if auther == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if id, ok := auther.Authenticate(ctx, user, password, opts...); ok {
|
||||
return id, ok
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
+1100
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -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,11 +51,21 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
return "", false
|
||||
}
|
||||
|
||||
var options auth.Options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
var clientAddr string
|
||||
if v := xctx.SrcAddrFromContext(ctx); v != nil {
|
||||
clientAddr = v.String()
|
||||
}
|
||||
r, err := p.client.Authenticate(ctx,
|
||||
&proto.AuthenticateRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: string(ctxvalue.ClientAddrFromContext(ctx)),
|
||||
Client: clientAddr,
|
||||
})
|
||||
if err != nil {
|
||||
p.log.Error(err)
|
||||
|
||||
@@ -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)
|
||||
+16
-2
@@ -4,15 +4,17 @@ 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 {
|
||||
Service string `json:"service"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
@@ -53,10 +55,21 @@ 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,
|
||||
}
|
||||
v, err := json.Marshal(&rb)
|
||||
if err != nil {
|
||||
@@ -79,6 +92,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
|
||||
}
|
||||
+371
-97
@@ -1,8 +1,25 @@
|
||||
// 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): only matching addresses bypass the proxy;
|
||||
// all other addresses go through the proxy chain.
|
||||
package bypass
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -11,112 +28,247 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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: only matching addresses bypass the proxy.
|
||||
whitelist bool
|
||||
|
||||
// network restricts this bypass to a specific network protocol.
|
||||
// When set, the incoming network must match before address matchers
|
||||
// are evaluated. Recognized values include "tcp" and "udp".
|
||||
// An empty value disables the network check.
|
||||
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, only matching addresses bypass the proxy;
|
||||
// all others go through the proxy chain.
|
||||
func WhitelistOption(whitelist bool) Option {
|
||||
return func(opts *options) {
|
||||
opts.whitelist = whitelist
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkOption restricts this bypass to a specific network protocol.
|
||||
// When set, Contains returns false when the incoming network does not
|
||||
// match, regardless of address matchers. Accepts values like "tcp" or "udp".
|
||||
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
|
||||
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)), only matching
|
||||
// addresses bypass the proxy and all others go through the chain.
|
||||
//
|
||||
// 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()
|
||||
@@ -124,84 +276,117 @@ 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)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.ContainsAny(pattern, "*?") {
|
||||
wildcards = append(wildcards, pattern)
|
||||
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{}
|
||||
if err := r.Parse(pattern); err == nil {
|
||||
ipRanges = append(ipRanges, r)
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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...)
|
||||
}
|
||||
}
|
||||
@@ -209,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)
|
||||
}
|
||||
}
|
||||
@@ -225,61 +412,148 @@ 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, applying the
|
||||
// whitelist or blacklist mode to the pattern match result.
|
||||
func (p *localBypass) decide(network string, addr string) bypassDecision {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.options.network != "" && p.options.network != network {
|
||||
return decisionProxy
|
||||
}
|
||||
return b
|
||||
|
||||
if p.patterns == nil {
|
||||
if p.options.network != "" {
|
||||
if p.options.whitelist {
|
||||
return decisionProxy
|
||||
}
|
||||
return decisionBypass
|
||||
}
|
||||
|
||||
return decisionProxy
|
||||
}
|
||||
|
||||
matched := p.patterns.matchAny(addr)
|
||||
|
||||
if p.options.whitelist {
|
||||
// Whitelist mode: the pattern set specifies addresses that MUST use the proxy.
|
||||
// Matched addresses go through the proxy; unmatched addresses bypass.
|
||||
if matched {
|
||||
return decisionProxy
|
||||
}
|
||||
return decisionBypass
|
||||
}
|
||||
// Blacklist mode: the pattern set specifies addresses that should bypass the proxy.
|
||||
// Matched addresses bypass; unmatched addresses go through the proxy.
|
||||
if matched {
|
||||
return decisionBypass
|
||||
}
|
||||
return decisionProxy
|
||||
}
|
||||
|
||||
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.addrMatcher.Match(addr) {
|
||||
return true
|
||||
func (p *localBypass) Close() error {
|
||||
p.cancelFunc()
|
||||
if p.options.fileLoader != nil {
|
||||
p.options.fileLoader.Close()
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
if host == "" {
|
||||
host = addr
|
||||
if p.options.redisLoader != nil {
|
||||
p.options.redisLoader.Close()
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return bp.cidrMatcher.Match(host)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 always returns false for a group, since the group may contain
|
||||
// a mix of whitelist and blacklist rules.
|
||||
func (p *bypassGroup) IsWhitelist() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
+38
-1
@@ -1,3 +1,31 @@
|
||||
// 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 (
|
||||
@@ -45,6 +73,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 +90,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 +111,9 @@ func (c *Chain) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -117,6 +152,8 @@ type chainGroup struct {
|
||||
selector selector.Selector[chain.Chainer]
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
+1769
File diff suppressed because it is too large
Load Diff
+26
-4
@@ -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
|
||||
@@ -78,10 +82,16 @@ func (*defaultRoute) Bind(ctx context.Context, network, address string, opts ...
|
||||
ReadQueueSize: options.UDPDataQueueSize,
|
||||
ReadBufferSize: options.UDPDataBufferSize,
|
||||
TTL: options.UDPConnTTL,
|
||||
KeepAlive: true,
|
||||
Keepalive: true,
|
||||
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()
|
||||
}
|
||||
|
||||
+143
-74
@@ -5,18 +5,26 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
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 {
|
||||
@@ -41,20 +49,23 @@ func (r *Router) Options() *chain.RouterOptions {
|
||||
return &r.options
|
||||
}
|
||||
|
||||
func (r *Router) Dial(ctx context.Context, network, address string) (conn net.Conn, err error) {
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
r.record(ctx, recorder.RecorderServiceRouterDialAddress, []byte(host))
|
||||
|
||||
conn, err = r.dial(ctx, network, address)
|
||||
log := r.options.Logger.WithFields(map[string]any{
|
||||
"sid": xctx.SidFromContext(ctx),
|
||||
})
|
||||
|
||||
conn, err = r.dial(ctx, network, address, log, opts...)
|
||||
if err != nil {
|
||||
r.record(ctx, recorder.RecorderServiceRouterDialAddressError, []byte(host))
|
||||
return
|
||||
@@ -75,103 +86,161 @@ func (r *Router) record(ctx context.Context, name string, data []byte) error {
|
||||
|
||||
for _, rec := range r.options.Recorders {
|
||||
if rec.Record == name {
|
||||
err := rec.Recorder.Record(ctx, data)
|
||||
if err != nil {
|
||||
r.options.Logger.Errorf("record %s: %v", name, err)
|
||||
}
|
||||
return err
|
||||
return rec.Recorder.Record(ctx, data)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) dial(ctx context.Context, network, address string) (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
|
||||
}
|
||||
r.options.Logger.Debugf("dial %s/%s", address, network)
|
||||
|
||||
log.Debugf("dial %s/%s", address, network)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
var ipAddr string
|
||||
ipAddr, err = xnet.Resolve(ctx, "ip", address, r.options.Resolver, r.options.HostMapper, r.options.Logger)
|
||||
if err != nil {
|
||||
r.options.Logger.Error(err)
|
||||
break
|
||||
}
|
||||
|
||||
var route chain.Route
|
||||
if r.options.Chain != nil {
|
||||
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
|
||||
}
|
||||
|
||||
if r.options.Logger.IsLevelEnabled(logger.DebugLevel) {
|
||||
buf := bytes.Buffer{}
|
||||
for _, node := range routePath(route) {
|
||||
fmt.Fprintf(&buf, "%s@%s > ", node.Name, node.Addr)
|
||||
func() {
|
||||
ctx := ctx
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
fmt.Fprintf(&buf, "%s", ipAddr)
|
||||
r.options.Logger.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(r.options.Logger),
|
||||
)
|
||||
if err == nil {
|
||||
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)
|
||||
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
|
||||
}
|
||||
r.options.Logger.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) {
|
||||
if r.options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
count := r.options.Retries + 1
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
r.options.Logger.Debugf("bind on %s/%s", address, network)
|
||||
|
||||
log := r.options.Logger.WithFields(map[string]any{
|
||||
"sid": xctx.SidFromContext(ctx),
|
||||
})
|
||||
|
||||
log.Debugf("bind on %s/%s", address, network)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
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
|
||||
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
|
||||
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 r.options.Logger.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)
|
||||
r.options.Logger.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
|
||||
}
|
||||
r.options.Logger.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
|
||||
}
|
||||
|
||||
+341
-64
@@ -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 (
|
||||
@@ -10,20 +14,27 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mdutil "github.com/go-gost/core/metadata/util"
|
||||
"github.com/go-gost/x/config"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/limiter/conn"
|
||||
"github.com/go-gost/x/limiter/traffic"
|
||||
mdx "github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
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 != "" {
|
||||
@@ -104,7 +95,9 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
resolverCfg.Nameservers = append(
|
||||
resolverCfg.Nameservers,
|
||||
&config.NameserverConfig{
|
||||
Addr: rs,
|
||||
Addr: rs,
|
||||
Prefer: mdutil.GetString(md, "prefer"),
|
||||
Only: mdutil.GetString(md, "only"),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -135,15 +128,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 +211,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
|
||||
@@ -240,6 +274,7 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
&config.NameserverConfig{
|
||||
Addr: rs,
|
||||
Prefer: mdutil.GetString(md, "prefer"),
|
||||
Only: mdutil.GetString(md, "only"),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -279,7 +314,7 @@ func BuildConfigFromCmd(serviceList, nodeList []string) (*config.Config, error)
|
||||
}
|
||||
if in != "" || out != "" {
|
||||
limiter.Limits = append(limiter.Limits,
|
||||
fmt.Sprintf("%s %s %s", traffic.GlobalLimitKey, in, out))
|
||||
fmt.Sprintf("%s %s %s", traffic.ServiceLimitKey, in, out))
|
||||
}
|
||||
if cin != "" || cout != "" {
|
||||
limiter.Limits = append(limiter.Limits,
|
||||
@@ -317,6 +352,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 +379,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 +400,24 @@ 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)
|
||||
// For path-based protocols (Unix socket listeners and serial), use path as address
|
||||
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
|
||||
|
||||
var addrs []string
|
||||
if isPathBasedProtocol {
|
||||
path := url.EscapedPath()
|
||||
if url.Host != "" {
|
||||
addrs = append(addrs, url.Host+strings.TrimPrefix(path, "/"))
|
||||
} else if 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 +439,8 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
|
||||
var nodes []*config.ForwardNodeConfig
|
||||
// forward mode
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" {
|
||||
// For path-based protocols, path is address not forward target
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathBasedProtocol {
|
||||
i := 0
|
||||
for _, addr := range strings.Split(remotes, ",") {
|
||||
addrs := xnet.AddrPortRange(addr).Addrs()
|
||||
@@ -402,7 +460,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 +469,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 +504,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 +523,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 +553,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 +620,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,19 +643,8 @@ 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]
|
||||
}
|
||||
}
|
||||
md := mdx.NewMetadata(m)
|
||||
|
||||
node := &config.NodeConfig{
|
||||
Addr: url.Host,
|
||||
Metadata: m,
|
||||
}
|
||||
|
||||
if c := registry.ConnectorRegistry().Get(connector); c == nil {
|
||||
connector = "http"
|
||||
}
|
||||
@@ -547,10 +657,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 +706,59 @@ 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
|
||||
var nodeAddr string
|
||||
isPathBasedProtocol := dialer == "unix" || dialer == "serial"
|
||||
if isPathBasedProtocol {
|
||||
path := url.EscapedPath()
|
||||
if url.Host != "" {
|
||||
// Two slashes: host + path = relative path
|
||||
nodeAddr = url.Host + strings.TrimPrefix(path, "/")
|
||||
} else if path != "" {
|
||||
// Three slashes: path is absolute (keep leading slash)
|
||||
nodeAddr = path
|
||||
} else {
|
||||
nodeAddr = url.Host
|
||||
}
|
||||
} 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 +769,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 +793,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 +853,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 +886,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
+188
-61
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -115,12 +117,16 @@ 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 {
|
||||
MinVersion string `yaml:"minVersion,omitempty" json:"minVersion,omitempty"`
|
||||
MaxVersion string `yaml:"maxVersion,omitempty" json:"maxVersion,omitempty"`
|
||||
CipherSuites []string `yaml:"cipherSuites,omitempty" json:"cipherSuites,omitempty"`
|
||||
ALPN []string `yaml:"alpn,omitempty" json:"alpn,omitempty"`
|
||||
}
|
||||
|
||||
type PluginConfig struct {
|
||||
@@ -142,8 +148,9 @@ type AutherConfig struct {
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Username string `json:"username"`
|
||||
Username string `yaml:",omitempty" json:"username,omitempty"`
|
||||
Password string `yaml:",omitempty" json:"password,omitempty"`
|
||||
File string `yaml:",omitempty" json:"file,omitempty"`
|
||||
}
|
||||
|
||||
type SelectorConfig struct {
|
||||
@@ -154,7 +161,7 @@ type SelectorConfig struct {
|
||||
|
||||
type AdmissionConfig struct {
|
||||
Name string `json:"name"`
|
||||
// DEPRECATED by whitelist since beta.4
|
||||
// Deprecated: use whitelist instead
|
||||
Reverse bool `yaml:",omitempty" json:"reverse,omitempty"`
|
||||
Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"`
|
||||
Matchers []string `yaml:",omitempty" json:"matchers,omitempty"`
|
||||
@@ -167,9 +174,10 @@ type AdmissionConfig struct {
|
||||
|
||||
type BypassConfig struct {
|
||||
Name string `json:"name"`
|
||||
// DEPRECATED by whitelist since beta.4
|
||||
// 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"`
|
||||
@@ -185,6 +193,7 @@ type FileLoader struct {
|
||||
type RedisLoader 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 string `yaml:",omitempty" json:"type,omitempty"`
|
||||
@@ -250,7 +259,9 @@ type SDConfig struct {
|
||||
}
|
||||
|
||||
type RouterRouteConfig struct {
|
||||
Net string `json:"net"`
|
||||
// Deprecated: use dst instead
|
||||
Net string `yaml:",omitempty" json:"net,omitempty"`
|
||||
Dst string `yaml:",omitempty" json:"dst,omitempty"`
|
||||
Gateway string `json:"gateway"`
|
||||
}
|
||||
|
||||
@@ -274,8 +285,9 @@ type RecorderConfig struct {
|
||||
}
|
||||
|
||||
type FileRecorder struct {
|
||||
Path string `json:"path"`
|
||||
Sep string `yaml:",omitempty" json:"sep,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Sep string `yaml:",omitempty" json:"sep,omitempty"`
|
||||
Rotation *LogRotationConfig `yaml:",omitempty" json:"rotation,omitempty"`
|
||||
}
|
||||
|
||||
type TCPRecorder struct {
|
||||
@@ -284,13 +296,15 @@ type TCPRecorder struct {
|
||||
}
|
||||
|
||||
type HTTPRecorder struct {
|
||||
URL string `json:"url" yaml:"url"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
Timeout time.Duration `yaml:",omitempty" json:"timeout,omitempty"`
|
||||
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
|
||||
}
|
||||
|
||||
type RedisRecorder 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 string `yaml:",omitempty" json:"type,omitempty"`
|
||||
@@ -302,6 +316,11 @@ 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"`
|
||||
}
|
||||
|
||||
type LimiterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
|
||||
@@ -343,7 +362,7 @@ type HandlerConfig struct {
|
||||
}
|
||||
|
||||
type ForwarderConfig struct {
|
||||
// DEPRECATED by hop field
|
||||
// Deprecated: use hop instead
|
||||
Name string `yaml:",omitempty" json:"name,omitempty"`
|
||||
// the referenced hop name
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
@@ -357,18 +376,20 @@ type ForwardNodeConfig struct {
|
||||
Network string `yaml:",omitempty" json:"network,omitempty"`
|
||||
Bypass string `yaml:",omitempty" json:"bypass,omitempty"`
|
||||
Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"`
|
||||
// DEPRECATED by filter.protocol
|
||||
// Deprecated: use matcher instead
|
||||
Protocol string `yaml:",omitempty" json:"protocol,omitempty"`
|
||||
// DEPRECATED by filter.host
|
||||
// Deprecated: use matcher instead
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
// DEPRECATED by filter.path
|
||||
// Deprecated: use matcher instead
|
||||
Path string `yaml:",omitempty" json:"path,omitempty"`
|
||||
// DEPRECATED by http.auth
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
// Deprecated: use matcher instead
|
||||
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
// Deprecated: use http.auth instead
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPURLRewriteConfig struct {
|
||||
@@ -376,17 +397,57 @@ type HTTPURLRewriteConfig struct {
|
||||
Replacement string
|
||||
}
|
||||
|
||||
type HTTPBodyRewriteConfig struct {
|
||||
// filter by MIME types
|
||||
Type string
|
||||
Match string
|
||||
Replacement string
|
||||
// name of the rewriter plugin (via registry)
|
||||
Rewriter string
|
||||
}
|
||||
|
||||
type NodeFilterConfig struct {
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
Protocol string `yaml:",omitempty" json:"protocol,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type HTTPNodeConfig struct {
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
|
||||
// rewrite host header
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
// Deprecated: use requestHeader instead
|
||||
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
|
||||
// additional request header
|
||||
RequestHeader map[string]string `yaml:"requestHeader,omitempty" json:"requestHeader,omitempty"`
|
||||
// additional response header
|
||||
ResponseHeader map[string]string `yaml:"responseHeader,omitempty" json:"responseHeader,omitempty"`
|
||||
// Deprecated: use rewriteURL instead
|
||||
Rewrite []HTTPURLRewriteConfig `yaml:",omitempty" json:"rewrite,omitempty"`
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
// rewrite URL
|
||||
RewriteURL []HTTPURLRewriteConfig `yaml:"rewriteURL,omitempty" json:"rewriteURL,omitempty"`
|
||||
// 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"`
|
||||
|
||||
// HTTP basic auth
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
}
|
||||
|
||||
type TLSNodeConfig struct {
|
||||
@@ -416,9 +477,9 @@ type SockOptsConfig struct {
|
||||
type ServiceConfig struct {
|
||||
Name string `json:"name"`
|
||||
Addr string `yaml:",omitempty" json:"addr,omitempty"`
|
||||
// DEPRECATED by metadata.interface since beta.5
|
||||
// Deprecated: use metadata.interface instead
|
||||
Interface string `yaml:",omitempty" json:"interface,omitempty"`
|
||||
// DEPRECATED by metadata.so_mark since beta.5
|
||||
// Deprecated: use metadata.so_mark instead
|
||||
SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"`
|
||||
Admission string `yaml:",omitempty" json:"admission,omitempty"`
|
||||
Admissions []string `yaml:",omitempty" json:"admissions,omitempty"`
|
||||
@@ -427,13 +488,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"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
|
||||
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
@@ -442,10 +505,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 {
|
||||
@@ -461,6 +526,57 @@ 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"`
|
||||
@@ -473,40 +589,47 @@ type ChainGroupConfig struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Addr string `yaml:",omitempty" json:"addr,omitempty"`
|
||||
Network string `yaml:",omitempty" json:"network,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"`
|
||||
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"`
|
||||
Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Addr string `yaml:",omitempty" json:"addr,omitempty"`
|
||||
Network string `yaml:",omitempty" json:"network,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"`
|
||||
Connector *ConnectorConfig `yaml:",omitempty" json:"connector,omitempty"`
|
||||
Dialer *DialerConfig `yaml:",omitempty" json:"dialer,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"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -522,7 +645,9 @@ 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"`
|
||||
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"`
|
||||
@@ -542,7 +667,8 @@ func (c *Config) Load() error {
|
||||
return v.Unmarshal(c)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -552,6 +678,7 @@ func (c *Config) Read(r io.Reader) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
// 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 (
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/bypass"
|
||||
"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/router"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"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"
|
||||
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"
|
||||
ingress_parser "github.com/go-gost/x/config/parsing/ingress"
|
||||
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"
|
||||
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"
|
||||
"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{}
|
||||
}
|
||||
logger.SetDefault(logger_parser.ParseLogger(&config.LoggerConfig{Log: logCfg}))
|
||||
|
||||
tlsCfg, err := parsing.BuildDefaultTLSConfig(cfg.TLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsing.SetDefaultTLSConfig(tlsCfg)
|
||||
|
||||
if err := register(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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[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
|
||||
}
|
||||
}
|
||||
|
||||
// --- hops (references 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/metadata"
|
||||
reg "github.com/go-gost/core/registry"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
// --- mock registry for registerGroup tests ---
|
||||
|
||||
type mockRegistry[T any] struct {
|
||||
m map[string]T
|
||||
}
|
||||
|
||||
func newMockRegistry[T any]() *mockRegistry[T] {
|
||||
return &mockRegistry[T]{m: make(map[string]T)}
|
||||
}
|
||||
|
||||
func (r *mockRegistry[T]) Register(name string, v T) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := r.m[name]; ok {
|
||||
return errors.New("duplicate")
|
||||
}
|
||||
r.m[name] = v
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mockRegistry[T]) Unregister(name string) {
|
||||
delete(r.m, name)
|
||||
}
|
||||
|
||||
func (r *mockRegistry[T]) IsRegistered(name string) bool {
|
||||
_, ok := r.m[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *mockRegistry[T]) Get(name string) T {
|
||||
return r.m[name]
|
||||
}
|
||||
|
||||
func (r *mockRegistry[T]) GetAll() map[string]T {
|
||||
result := make(map[string]T)
|
||||
for k, v := range r.m {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Ensure mockRegistry satisfies reg.Registry[T].
|
||||
var _ reg.Registry[int] = (*mockRegistry[int])(nil)
|
||||
|
||||
// --- registerGroup tests ---
|
||||
|
||||
func TestRegisterGroup_EmptyEntries_EmptyRegistry(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
err := registerGroup([]named[int]{}, r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(r.GetAll()) != 0 {
|
||||
t.Fatalf("expected empty registry, got %d entries", len(r.GetAll()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGroup_EmptyEntries_ClearsExisting(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
r.Register("a", 1)
|
||||
r.Register("b", 2)
|
||||
|
||||
err := registerGroup([]named[int]{}, r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(r.GetAll()) != 0 {
|
||||
t.Fatalf("expected empty registry, got %d entries", len(r.GetAll()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGroup_SingleEntry(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
err := registerGroup([]named[int]{{"x", 42}}, r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered("x") {
|
||||
t.Fatal("expected 'x' to be registered")
|
||||
}
|
||||
if v := r.Get("x"); v != 42 {
|
||||
t.Fatalf("expected 42, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGroup_MultipleEntries(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
err := registerGroup([]named[int]{
|
||||
{"x", 1},
|
||||
{"y", 2},
|
||||
{"z", 3},
|
||||
}, r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
all := r.GetAll()
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(all))
|
||||
}
|
||||
for _, name := range []string{"x", "y", "z"} {
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatalf("expected '%s' to be registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGroup_ReplacesExisting(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
r.Register("old", 1)
|
||||
r.Register("keep", 2) // this one should be removed
|
||||
|
||||
err := registerGroup([]named[int]{
|
||||
{"old", 99},
|
||||
{"new", 100},
|
||||
}, r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v := r.Get("old"); v != 99 {
|
||||
t.Fatalf("expected 99, got %v", v)
|
||||
}
|
||||
if !r.IsRegistered("new") {
|
||||
t.Fatal("expected 'new' to be registered")
|
||||
}
|
||||
if r.IsRegistered("keep") {
|
||||
t.Fatal("expected 'keep' to be unregistered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGroup_DuplicateNameInEntries(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
err := registerGroup([]named[int]{
|
||||
{"dup", 1},
|
||||
{"dup", 2},
|
||||
}, r)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate name")
|
||||
}
|
||||
// First entry should be registered, second failed.
|
||||
if v := r.Get("dup"); v != 1 {
|
||||
t.Fatalf("expected first entry (1), got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGroup_ZeroValue(t *testing.T) {
|
||||
r := newMockRegistry[int]()
|
||||
// int zero value (0) is still registered
|
||||
err := registerGroup([]named[int]{{"zero", 0}}, r)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered("zero") {
|
||||
t.Fatal("expected 'zero' to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// --- register tests ---
|
||||
|
||||
func TestRegister_NilConfig(t *testing.T) {
|
||||
if err := register(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_EmptyConfig(t *testing.T) {
|
||||
if err := register(&config.Config{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Loggers(t *testing.T) {
|
||||
reg := registry.LoggerRegistry()
|
||||
name := "test-logger"
|
||||
t.Cleanup(func() { reg.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Loggers: []*config.LoggerConfig{
|
||||
{Name: name, Log: &config.LogConfig{Level: "info"}},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reg.IsRegistered(name) {
|
||||
t.Fatal("expected logger to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Authers(t *testing.T) {
|
||||
r := registry.AutherRegistry()
|
||||
name := "test-auther"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Authers: []*config.AutherConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected auther to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Admissions(t *testing.T) {
|
||||
r := registry.AdmissionRegistry()
|
||||
name := "test-admission"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Admissions: []*config.AdmissionConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected admission to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Bypasses(t *testing.T) {
|
||||
r := registry.BypassRegistry()
|
||||
name := "test-bypass"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Bypasses: []*config.BypassConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected bypass to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Resolvers(t *testing.T) {
|
||||
r := registry.ResolverRegistry()
|
||||
name := "test-resolver"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Resolvers: []*config.ResolverConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected resolver to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Hosts(t *testing.T) {
|
||||
r := registry.HostsRegistry()
|
||||
name := "test-hosts"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Hosts: []*config.HostsConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected host mapper to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Ingresses(t *testing.T) {
|
||||
r := registry.IngressRegistry()
|
||||
name := "test-ingress"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Ingresses: []*config.IngressConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected ingress to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Routers(t *testing.T) {
|
||||
r := registry.RouterRegistry()
|
||||
name := "test-router"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Routers: []*config.RouterConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected router to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_SDs(t *testing.T) {
|
||||
r := registry.SDRegistry()
|
||||
name := "test-sd"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
SDs: []*config.SDConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected SD to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Observers(t *testing.T) {
|
||||
r := registry.ObserverRegistry()
|
||||
name := "test-observer"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Observers: []*config.ObserverConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected observer to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Recorders(t *testing.T) {
|
||||
r := registry.RecorderRegistry()
|
||||
name := "test-recorder"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Recorders: []*config.RecorderConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected recorder to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_TrafficLimiters(t *testing.T) {
|
||||
r := registry.TrafficLimiterRegistry()
|
||||
name := "test-traffic-limiter"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Limiters: []*config.LimiterConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected traffic limiter to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_ConnLimiters(t *testing.T) {
|
||||
r := registry.ConnLimiterRegistry()
|
||||
name := "test-conn-limiter"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
CLimiters: []*config.LimiterConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected conn limiter to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_RateLimiters(t *testing.T) {
|
||||
r := registry.RateLimiterRegistry()
|
||||
name := "test-rate-limiter"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
RLimiters: []*config.LimiterConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected rate limiter to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Hops(t *testing.T) {
|
||||
r := registry.HopRegistry()
|
||||
name := "test-hop"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Hops: []*config.HopConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected hop to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Chains(t *testing.T) {
|
||||
r := registry.ChainRegistry()
|
||||
name := "test-chain"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Chains: []*config.ChainConfig{
|
||||
{Name: name},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected chain to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// stubListener satisfies listener.Listener for testing.
|
||||
type stubListener struct{}
|
||||
|
||||
func (l *stubListener) Init(md metadata.Metadata) error { return nil }
|
||||
func (l *stubListener) Accept() (net.Conn, error) { return nil, net.ErrClosed }
|
||||
func (l *stubListener) Addr() net.Addr { return &net.TCPAddr{IP: net.IPv4zero, Port: 0} }
|
||||
func (l *stubListener) Close() error { return nil }
|
||||
|
||||
// stubHandler satisfies handler.Handler for testing.
|
||||
type stubHandler struct{}
|
||||
|
||||
func (h *stubHandler) Init(md metadata.Metadata) error { return nil }
|
||||
func (h *stubHandler) Handle(_ context.Context, _ net.Conn, _ ...handler.HandleOption) error { return nil }
|
||||
|
||||
func TestRegister_Services(t *testing.T) {
|
||||
// ParseService needs "tcp" listener and "auto" handler in registries.
|
||||
// These are registered via init() in their respective packages but those
|
||||
// packages are not imported here, so the factories won't be found.
|
||||
// Register stub factories so the test can exercise the service section.
|
||||
//
|
||||
// This save/restore pattern assumes no other test or transitive import
|
||||
// has already registered "tcp" or "auto" factory functions. If those
|
||||
// packages are ever imported by this test file, origListener/origHandler
|
||||
// will be non-nil and the restore path will re-register the real
|
||||
// factories, which is the desired behavior.
|
||||
origListener := registry.ListenerRegistry().Get("tcp")
|
||||
origHandler := registry.HandlerRegistry().Get("auto")
|
||||
|
||||
registry.ListenerRegistry().Register("tcp", func(opts ...listener.Option) listener.Listener {
|
||||
return &stubListener{}
|
||||
})
|
||||
registry.HandlerRegistry().Register("auto", func(opts ...handler.Option) handler.Handler {
|
||||
return &stubHandler{}
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
registry.ListenerRegistry().Unregister("tcp")
|
||||
registry.HandlerRegistry().Unregister("auto")
|
||||
if origListener != nil {
|
||||
registry.ListenerRegistry().Register("tcp", origListener)
|
||||
}
|
||||
if origHandler != nil {
|
||||
registry.HandlerRegistry().Register("auto", origHandler)
|
||||
}
|
||||
})
|
||||
|
||||
r := registry.ServiceRegistry()
|
||||
name := "test-service"
|
||||
t.Cleanup(func() { r.Unregister(name) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{
|
||||
{Name: name, Addr: ":0"},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(name) {
|
||||
t.Fatal("expected service to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_MultipleSections(t *testing.T) {
|
||||
// Verify that multiple sections are all registered and in dependency order.
|
||||
bypassReg := registry.BypassRegistry()
|
||||
hopReg := registry.HopRegistry()
|
||||
chainReg := registry.ChainRegistry()
|
||||
|
||||
bypassName := "multi-bypass"
|
||||
hopName := "multi-hop"
|
||||
chainName := "multi-chain"
|
||||
|
||||
t.Cleanup(func() {
|
||||
bypassReg.Unregister(bypassName)
|
||||
hopReg.Unregister(hopName)
|
||||
chainReg.Unregister(chainName)
|
||||
})
|
||||
|
||||
cfg := &config.Config{
|
||||
Bypasses: []*config.BypassConfig{{Name: bypassName}},
|
||||
Hops: []*config.HopConfig{{Name: hopName}},
|
||||
Chains: []*config.ChainConfig{{Name: chainName}},
|
||||
}
|
||||
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bypassReg.IsRegistered(bypassName) {
|
||||
t.Fatal("expected bypass to be registered")
|
||||
}
|
||||
if !hopReg.IsRegistered(hopName) {
|
||||
t.Fatal("expected hop to be registered")
|
||||
}
|
||||
if !chainReg.IsRegistered(chainName) {
|
||||
t.Fatal("expected chain to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_ComponentOrder(t *testing.T) {
|
||||
// register() processes sections in dependency order: leaf components
|
||||
// (loggers, authers, resolvers, etc.) first, then hops, then chains,
|
||||
// then services. Hops look up loggers/resolvers from registries;
|
||||
// chains look up hops; services look up chains.
|
||||
//
|
||||
// This test directly calls registerGroup in the canonical dependency
|
||||
// sequence and verifies each layer can see the previous layer's
|
||||
// registrations.
|
||||
|
||||
// Use a sequence-tracking registry to verify registration order.
|
||||
type seqEntry struct {
|
||||
name string
|
||||
seq int
|
||||
}
|
||||
var seq int
|
||||
leafReg := newMockRegistry[seqEntry]()
|
||||
hopReg := newMockRegistry[seqEntry]()
|
||||
chainReg := newMockRegistry[seqEntry]()
|
||||
svcReg := newMockRegistry[seqEntry]()
|
||||
|
||||
seq++
|
||||
registerGroup([]named[seqEntry]{{"leaf", seqEntry{"logger", seq}}}, leafReg)
|
||||
|
||||
// Hops should see leaf registrations.
|
||||
if !leafReg.IsRegistered("leaf") {
|
||||
t.Fatal("expected leaf to be registered before hops")
|
||||
}
|
||||
seq++
|
||||
registerGroup([]named[seqEntry]{{"hop", seqEntry{"hop", seq}}}, hopReg)
|
||||
|
||||
// Chains should see hop registrations.
|
||||
if !hopReg.IsRegistered("hop") {
|
||||
t.Fatal("expected hop to be registered before chains")
|
||||
}
|
||||
seq++
|
||||
registerGroup([]named[seqEntry]{{"chain", seqEntry{"chain", seq}}}, chainReg)
|
||||
|
||||
// Services should see chain registrations.
|
||||
if !chainReg.IsRegistered("chain") {
|
||||
t.Fatal("expected chain to be registered before services")
|
||||
}
|
||||
seq++
|
||||
registerGroup([]named[seqEntry]{{"svc", seqEntry{"svc", seq}}}, svcReg)
|
||||
|
||||
// All should be registered.
|
||||
for _, r := range []struct {
|
||||
name string
|
||||
reg *mockRegistry[seqEntry]
|
||||
}{
|
||||
{"leaf", leafReg},
|
||||
{"hop", hopReg},
|
||||
{"chain", chainReg},
|
||||
{"svc", svcReg},
|
||||
} {
|
||||
if len(r.reg.GetAll()) != 1 {
|
||||
t.Fatalf("expected 1 entry in %s registry", r.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify registration sequence: leaf < hop < chain < svc.
|
||||
if leafReg.Get("leaf").seq >= hopReg.Get("hop").seq {
|
||||
t.Fatal("leaf must be registered before hop")
|
||||
}
|
||||
if hopReg.Get("hop").seq >= chainReg.Get("chain").seq {
|
||||
t.Fatal("hop must be registered before chain")
|
||||
}
|
||||
if chainReg.Get("chain").seq >= svcReg.Get("svc").seq {
|
||||
t.Fatal("chain must be registered before service")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load tests ---
|
||||
|
||||
func TestLoad_NilConfig(t *testing.T) {
|
||||
if err := Load(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EmptyConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
if err := Load(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithTLS(t *testing.T) {
|
||||
// BuildDefaultTLSConfig with nil TLS falls back to default cert/key files
|
||||
// which don't exist, but it then generates a self-signed cert, so it
|
||||
// should still succeed.
|
||||
cfg := &config.Config{
|
||||
TLS: &config.TLSConfig{
|
||||
Validity: 3600,
|
||||
},
|
||||
}
|
||||
if err := Load(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithLog(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Log: &config.LogConfig{
|
||||
Level: "error",
|
||||
},
|
||||
}
|
||||
if err := Load(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SetsDefaultLogger(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Log: &config.LogConfig{
|
||||
Level: "warn",
|
||||
Format: "json",
|
||||
},
|
||||
}
|
||||
if err := Load(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// After Load, logger.Default() should be non-nil.
|
||||
if logger.Default() == nil {
|
||||
t.Fatal("expected default logger to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// --- defaultLoader singleton tests ---
|
||||
|
||||
func TestDefaultLoader_IsSet(t *testing.T) {
|
||||
if defaultLoader == nil {
|
||||
t.Fatal("defaultLoader should not be nil")
|
||||
}
|
||||
if err := defaultLoader.Load(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- registerGroup edge cases ---
|
||||
|
||||
type countingRegistry[T any] struct {
|
||||
m map[string]T
|
||||
close int
|
||||
}
|
||||
|
||||
func newCountingRegistry[T any]() *countingRegistry[T] {
|
||||
return &countingRegistry[T]{m: make(map[string]T)}
|
||||
}
|
||||
|
||||
func (r *countingRegistry[T]) Register(name string, v T) error { r.m[name] = v; return nil }
|
||||
func (r *countingRegistry[T]) Unregister(name string) { delete(r.m, name); r.close++ }
|
||||
func (r *countingRegistry[T]) IsRegistered(name string) bool { _, ok := r.m[name]; return ok }
|
||||
func (r *countingRegistry[T]) Get(name string) T { return r.m[name] }
|
||||
func (r *countingRegistry[T]) GetAll() map[string]T { return r.m }
|
||||
|
||||
func TestRegisterGroup_UnregistersAllOldEntries(t *testing.T) {
|
||||
r := newCountingRegistry[int]()
|
||||
r.Register("a", 1)
|
||||
r.Register("b", 2)
|
||||
r.Register("c", 3)
|
||||
|
||||
_ = registerGroup([]named[int]{{"x", 10}}, r)
|
||||
|
||||
if r.close != 3 {
|
||||
t.Fatalf("expected 3 unregister calls, got %d", r.close)
|
||||
}
|
||||
if !r.IsRegistered("x") {
|
||||
t.Fatal("expected 'x' to be registered")
|
||||
}
|
||||
if r.IsRegistered("a") || r.IsRegistered("b") || r.IsRegistered("c") {
|
||||
t.Fatal("old entries should be unregistered")
|
||||
}
|
||||
}
|
||||
|
||||
// --- register error paths ---
|
||||
|
||||
func TestRegister_ServiceBadTLS(t *testing.T) {
|
||||
// A non-empty CertFile that doesn't exist causes
|
||||
// tls_util.LoadServerConfig to fail before reaching the
|
||||
// listener registry lookup.
|
||||
//
|
||||
// ParseService logs an error via the default logger when the
|
||||
// TLS cert can't be loaded. We suppress that log here since
|
||||
// the error is expected.
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
discardLogger := xlogger.NewLogger(xlogger.OutputOption(io.Discard))
|
||||
t.Cleanup(func() { logger.SetDefault(discardLogger) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{
|
||||
{
|
||||
Name: "bad-tls",
|
||||
Addr: ":0",
|
||||
Listener: &config.ListenerConfig{
|
||||
TLS: &config.TLSConfig{
|
||||
CertFile: "/nonexistent/cert.pem",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err == nil {
|
||||
t.Fatal("expected error for bad TLS config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_ServiceUnknownListener(t *testing.T) {
|
||||
// An unregistered listener type triggers the
|
||||
// "unknown listener" error in ParseService.
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{
|
||||
{
|
||||
Name: "unknown-ln",
|
||||
Addr: ":0",
|
||||
Listener: &config.ListenerConfig{
|
||||
Type: "nonexistent",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := register(cfg); err == nil {
|
||||
t.Fatal("expected error for unknown listener type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// bindingStubListener is a stubListener whose Init actually binds a TCP port
|
||||
// via net.Listen, reproducing the EADDRINUSE condition the close-old-before-
|
||||
// bind ordering in register() must prevent. Used only by reload-collision tests.
|
||||
type bindingStubListener struct {
|
||||
addr string
|
||||
ln net.Listener
|
||||
}
|
||||
|
||||
func (l *bindingStubListener) Init(md metadata.Metadata) error {
|
||||
ln, err := net.Listen("tcp", l.addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.ln = ln
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *bindingStubListener) Accept() (net.Conn, error) {
|
||||
if l.ln == nil {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
return l.ln.Accept()
|
||||
}
|
||||
|
||||
func (l *bindingStubListener) Addr() net.Addr {
|
||||
if l.ln != nil {
|
||||
return l.ln.Addr()
|
||||
}
|
||||
return &net.TCPAddr{IP: net.IPv4zero, Port: 0}
|
||||
}
|
||||
|
||||
func (l *bindingStubListener) Close() error {
|
||||
if l.ln == nil {
|
||||
return nil
|
||||
}
|
||||
return l.ln.Close()
|
||||
}
|
||||
|
||||
// freeTCPPort returns the number of a TCP port that is free at call time by
|
||||
// opening and immediately closing a listener on 127.0.0.1:0. There is a small
|
||||
// race window before the caller rebinds, which is acceptable for tests.
|
||||
func freeTCPPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen for free port: %v", err)
|
||||
}
|
||||
addr := l.Addr().(*net.TCPAddr)
|
||||
l.Close()
|
||||
return addr.Port
|
||||
}
|
||||
|
||||
// TestRegister_ServiceReloadNoCollision verifies that calling register() twice
|
||||
// with a service on a fixed port does NOT return EADDRINUSE on the second call
|
||||
// (issue #754). Before the close-old-before-bind ordering, the second
|
||||
// register() bound the new listener while the old one was still listening.
|
||||
func TestRegister_ServiceReloadNoCollision(t *testing.T) {
|
||||
port := freeTCPPort(t)
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
|
||||
// Register a binding listener factory under a test-specific name so we
|
||||
// don't interfere with the "tcp" save/restore in TestRegister_Services.
|
||||
const factoryName = "tcp-binding-test"
|
||||
origListener := registry.ListenerRegistry().Get(factoryName)
|
||||
registry.ListenerRegistry().Register(factoryName, func(opts ...listener.Option) listener.Listener {
|
||||
options := listener.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
return &bindingStubListener{addr: options.Addr}
|
||||
})
|
||||
// Reuse the inert "auto" handler stub.
|
||||
origHandler := registry.HandlerRegistry().Get("auto")
|
||||
registry.HandlerRegistry().Register("auto", func(opts ...handler.Option) handler.Handler {
|
||||
return &stubHandler{}
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
registry.ListenerRegistry().Unregister(factoryName)
|
||||
registry.HandlerRegistry().Unregister("auto")
|
||||
if origListener != nil {
|
||||
registry.ListenerRegistry().Register(factoryName, origListener)
|
||||
}
|
||||
if origHandler != nil {
|
||||
registry.HandlerRegistry().Register("auto", origHandler)
|
||||
}
|
||||
})
|
||||
|
||||
r := registry.ServiceRegistry()
|
||||
const svcName = "reload-svc"
|
||||
t.Cleanup(func() { r.Unregister(svcName) })
|
||||
|
||||
buildCfg := func() *config.Config {
|
||||
return &config.Config{
|
||||
Services: []*config.ServiceConfig{
|
||||
{
|
||||
Name: svcName,
|
||||
Addr: addr,
|
||||
Listener: &config.ListenerConfig{Type: factoryName},
|
||||
Handler: &config.HandlerConfig{Type: "auto"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// First load: registers the service and binds the port.
|
||||
if err := register(buildCfg()); err != nil {
|
||||
t.Fatalf("first register: unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(svcName) {
|
||||
t.Fatal("expected service registered after first load")
|
||||
}
|
||||
|
||||
// Second load simulates a SIGHUP reload. With the old ordering this bound
|
||||
// the new listener while the old one was still open and returned
|
||||
// "address already in use"; the fix closes old services first.
|
||||
if err := register(buildCfg()); err != nil {
|
||||
if strings.Contains(err.Error(), "address already in use") {
|
||||
t.Fatalf("second register (reload): port collision not avoided: %v", err)
|
||||
}
|
||||
t.Fatalf("second register (reload): unexpected error: %v", err)
|
||||
}
|
||||
if !r.IsRegistered(svcName) {
|
||||
t.Fatal("expected service registered after reload")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
"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
|
||||
@@ -31,6 +35,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),
|
||||
)
|
||||
@@ -59,6 +64,7 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
|
||||
opts = append(opts, xadmission.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -73,6 +79,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")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/logger"
|
||||
@@ -14,6 +18,9 @@ import (
|
||||
"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
|
||||
@@ -31,6 +38,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),
|
||||
)
|
||||
@@ -67,6 +75,7 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
|
||||
opts = append(opts, xauth.RedisLoaderOption(loader.RedisHashLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -80,6 +89,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
|
||||
@@ -96,8 +108,24 @@ 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 || cfg.Username == "" {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.File != "" {
|
||||
if f, _ := os.Open(cfg.File); f != nil {
|
||||
defer f.Close()
|
||||
if infos, _ := parseInfo(f, 1); len(infos) > 0 {
|
||||
return infos[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Username == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -107,6 +135,42 @@ func Info(cfg *config.AuthConfig) *url.Userinfo {
|
||||
return url.UserPassword(cfg.Username, cfg.Password)
|
||||
}
|
||||
|
||||
func parseInfo(r io.Reader, max int) (infos []*url.Userinfo, err error) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
// line := strings.Replace(scanner.Text(), "\t", " ", -1)
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if n := strings.IndexByte(line, '#'); n == 0 {
|
||||
continue
|
||||
}
|
||||
sp := strings.SplitN(line, " ", 2)
|
||||
if len(sp) == 1 {
|
||||
if k := strings.TrimSpace(sp[0]); k != "" {
|
||||
infos = append(infos, url.User(k))
|
||||
}
|
||||
}
|
||||
if len(sp) == 2 {
|
||||
if k := strings.TrimSpace(sp[0]); k != "" {
|
||||
infos = append(infos, url.UserPassword(k, strings.TrimSpace(sp[1])))
|
||||
}
|
||||
}
|
||||
|
||||
if max > 0 && len(infos) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
err = scanner.Err()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
"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
|
||||
@@ -31,6 +34,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 +50,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",
|
||||
@@ -59,6 +64,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
|
||||
opts = append(opts, xbypass.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -73,6 +79,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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+60
-12
@@ -4,11 +4,10 @@ import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/logger"
|
||||
mdutil "github.com/go-gost/core/metadata/util"
|
||||
xbypass "github.com/go-gost/x/bypass"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
|
||||
@@ -19,8 +18,13 @@ import (
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -38,6 +42,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 +55,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 +83,47 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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 +140,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
|
||||
}
|
||||
@@ -115,7 +162,7 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
xhop.NameOption(cfg.Name),
|
||||
xhop.NodeOption(nodes...),
|
||||
xhop.SelectorOption(sel),
|
||||
xhop.BypassOption(bypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
xhop.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
xhop.ReloadPeriodOption(cfg.Reload),
|
||||
xhop.LoggerOption(log.WithFields(map[string]any{
|
||||
"kind": "hop",
|
||||
@@ -130,6 +177,7 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
opts = append(opts, xhop.RedisLoaderOption(loader.RedisStringLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
|
||||
// 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_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))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
"github.com/go-gost/x/internal/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
|
||||
@@ -31,6 +34,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),
|
||||
)
|
||||
@@ -75,6 +79,7 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
|
||||
opts = append(opts, xhosts.RedisLoaderOption(loader.RedisListLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -82,6 +87,7 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
|
||||
opts = append(opts, xhosts.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"github.com/go-gost/x/internal/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
|
||||
@@ -30,6 +33,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),
|
||||
)
|
||||
@@ -70,6 +74,7 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
|
||||
opts = append(opts, xingress.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -77,6 +82,7 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
|
||||
opts = append(opts, xingress.RedisLoaderOption(loader.RedisHashLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
traffic_plugin "github.com/go-gost/x/limiter/traffic/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
|
||||
@@ -34,6 +37,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),
|
||||
)
|
||||
@@ -57,6 +61,7 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
|
||||
opts = append(opts, xtraffic.RedisLoaderOption(loader.RedisListLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -64,6 +69,7 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
|
||||
opts = append(opts, xtraffic.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -87,6 +93,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
|
||||
@@ -103,6 +111,7 @@ func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
|
||||
opts = append(opts, xconn.RedisLoaderOption(loader.RedisListLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -110,6 +119,7 @@ func ParseConnLimiter(cfg *config.LimiterConfig) (lim conn.ConnLimiter) {
|
||||
opts = append(opts, xconn.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -133,6 +143,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
|
||||
@@ -149,6 +161,7 @@ func ParseRateLimiter(cfg *config.LimiterConfig) (lim rate.RateLimiter) {
|
||||
opts = append(opts, xrate.RedisLoaderOption(loader.RedisListLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -156,6 +169,7 @@ func ParseRateLimiter(cfg *config.LimiterConfig) (lim rate.RateLimiter) {
|
||||
opts = append(opts, xrate.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,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
|
||||
@@ -25,7 +28,7 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
var out io.Writer = os.Stderr
|
||||
switch cfg.Log.Output {
|
||||
case "none", "null":
|
||||
return xlogger.Nop()
|
||||
out = io.Discard
|
||||
case "stdout":
|
||||
out = os.Stdout
|
||||
case "stderr", "":
|
||||
@@ -55,6 +58,8 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
return xlogger.NewLogger(opts...)
|
||||
}
|
||||
|
||||
// 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,142 @@
|
||||
package logger
|
||||
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
+104
-50
@@ -6,14 +6,12 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"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"
|
||||
mdutil "github.com/go-gost/core/metadata/util"
|
||||
xauth "github.com/go-gost/x/auth"
|
||||
xbypass "github.com/go-gost/x/bypass"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
@@ -21,37 +19,71 @@ import (
|
||||
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
|
||||
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"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/go-gost/x/routing"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
pattern, _ := regexp.Compile(v.Match)
|
||||
rw := chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
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)
|
||||
}
|
||||
if pattern != nil || 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{}
|
||||
}
|
||||
@@ -64,34 +96,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{}
|
||||
}
|
||||
@@ -104,55 +128,49 @@ 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)
|
||||
}
|
||||
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(bypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
chain.ResoloverNodeOption(registry.ResolverRegistry().Get(cfg.Resolver)),
|
||||
chain.BypassNodeOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
chain.ResolverNodeOption(registry.ResolverRegistry().Get(cfg.Resolver)),
|
||||
chain.HostMapperNodeOption(registry.HostsRegistry().Get(cfg.Hosts)),
|
||||
chain.MetadataNodeOption(nm),
|
||||
chain.MetadataNodeOption(md),
|
||||
chain.NetworkNodeOption(cfg.Network),
|
||||
}
|
||||
|
||||
@@ -175,10 +193,37 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
opts = append(opts, chain.NodeFilterOption(settings))
|
||||
}
|
||||
|
||||
if cfg.Matcher != nil {
|
||||
priority := cfg.Matcher.Priority
|
||||
|
||||
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
|
||||
// Priority 0 means "use default": automatically set to the
|
||||
// rule length so longer (more specific) rules outrank shorter
|
||||
// ones. Use a negative priority to opt out of this behavior
|
||||
// and always go through the selector.
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
}
|
||||
|
||||
if cfg.HTTP != nil {
|
||||
settings := &chain.HTTPNodeSettings{
|
||||
Host: cfg.HTTP.Host,
|
||||
Header: cfg.HTTP.Header,
|
||||
Host: cfg.HTTP.Host,
|
||||
RequestHeader: cfg.HTTP.RequestHeader,
|
||||
ResponseHeader: cfg.HTTP.ResponseHeader,
|
||||
}
|
||||
if settings.RequestHeader == nil {
|
||||
settings.RequestHeader = cfg.HTTP.Header
|
||||
}
|
||||
|
||||
if auth := cfg.HTTP.Auth; auth != nil && auth.Username != "" {
|
||||
@@ -191,14 +236,22 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
})),
|
||||
)
|
||||
}
|
||||
for _, v := range cfg.HTTP.Rewrite {
|
||||
|
||||
rewriteURL := cfg.HTTP.RewriteURL
|
||||
if rewriteURL == nil {
|
||||
rewriteURL = cfg.HTTP.Rewrite
|
||||
}
|
||||
for _, v := range rewriteURL {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.Rewrite = append(settings.Rewrite, chain.HTTPURLRewriteSetting{
|
||||
settings.RewriteURL = append(settings.RewriteURL, chain.HTTPURLRewriteSetting{
|
||||
Pattern: pattern,
|
||||
Replacement: 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)...)
|
||||
opts = append(opts, chain.HTTPNodeOption(settings))
|
||||
}
|
||||
|
||||
@@ -211,6 +264,7 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
tlsCfg.Options.MinVersion = o.MinVersion
|
||||
tlsCfg.Options.MaxVersion = o.MaxVersion
|
||||
tlsCfg.Options.CipherSuites = o.CipherSuites
|
||||
tlsCfg.Options.ALPN = o.ALPN
|
||||
}
|
||||
opts = append(opts, chain.TLSNodeOption(tlsCfg))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
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"
|
||||
|
||||
// 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_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_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")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
observer_plugin "github.com/go-gost/x/observer/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
|
||||
@@ -26,6 +29,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")
|
||||
}
|
||||
}
|
||||
+92
-11
@@ -1,18 +1,99 @@
|
||||
// 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"
|
||||
|
||||
// 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 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 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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
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"}},
|
||||
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"}},
|
||||
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.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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"
|
||||
"github.com/go-gost/x/config/cmd"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
args Args
|
||||
}
|
||||
|
||||
func (p *parser) Parse() (*config.Config, error) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
for _, cfgFile := range p.args.CfgFiles {
|
||||
cfgFile = strings.TrimSpace(cfgFile)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = mergeConfig(cfg, cmdCfg)
|
||||
|
||||
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 != "" {
|
||||
if cfg.Log == nil {
|
||||
cfg.Log = &config.LogConfig{}
|
||||
}
|
||||
cfg.Log.Level = v
|
||||
}
|
||||
if v := os.Getenv("GOST_API"); v != "" {
|
||||
if cfg.API == nil {
|
||||
cfg.API = &config.APIConfig{}
|
||||
}
|
||||
cfg.API.Addr = v
|
||||
}
|
||||
if v := os.Getenv("GOST_METRICS"); v != "" {
|
||||
if cfg.Metrics == nil {
|
||||
cfg.Metrics = &config.MetricsConfig{}
|
||||
}
|
||||
cfg.Metrics.Addr = v
|
||||
}
|
||||
if v := os.Getenv("GOST_PROFILING"); v != "" {
|
||||
if cfg.Profiling == nil {
|
||||
cfg.Profiling = &config.ProfilingConfig{}
|
||||
}
|
||||
cfg.Profiling.Addr = v
|
||||
}
|
||||
|
||||
if p.args.Debug || p.args.Trace {
|
||||
if cfg.Log == nil {
|
||||
cfg.Log = &config.LogConfig{}
|
||||
}
|
||||
|
||||
cfg.Log.Level = string(logger.DebugLevel)
|
||||
if p.args.Trace {
|
||||
cfg.Log.Level = string(logger.TraceLevel)
|
||||
}
|
||||
}
|
||||
|
||||
if p.args.ApiAddr != "" {
|
||||
cfg.API = &config.APIConfig{
|
||||
Addr: p.args.ApiAddr,
|
||||
}
|
||||
if url, _ := cmd.Norm(p.args.ApiAddr); url != nil {
|
||||
cfg.API.Addr = url.Host
|
||||
if url.User != nil {
|
||||
username := url.User.Username()
|
||||
password, _ := url.User.Password()
|
||||
cfg.API.Auth = &config.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
m := map[string]any{}
|
||||
for k, v := range url.Query() {
|
||||
if len(v) > 0 {
|
||||
m[k] = v[0]
|
||||
}
|
||||
}
|
||||
md := xmd.NewMetadata(m)
|
||||
cfg.API.PathPrefix = mdutil.GetString(md, "pathPrefix")
|
||||
cfg.API.AccessLog = mdutil.GetBool(md, "accesslog")
|
||||
}
|
||||
}
|
||||
if p.args.MetricsAddr != "" {
|
||||
cfg.Metrics = &config.MetricsConfig{
|
||||
Addr: p.args.MetricsAddr,
|
||||
}
|
||||
if url, _ := cmd.Norm(p.args.MetricsAddr); url != nil {
|
||||
cfg.Metrics.Addr = url.Host
|
||||
if url.User != nil {
|
||||
username := url.User.Username()
|
||||
password, _ := url.User.Password()
|
||||
cfg.Metrics.Auth = &config.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
m := map[string]any{}
|
||||
for k, v := range url.Query() {
|
||||
if len(v) > 0 {
|
||||
m[k] = v[0]
|
||||
}
|
||||
}
|
||||
md := xmd.NewMetadata(m)
|
||||
cfg.Metrics.Path = mdutil.GetString(md, "path")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if cfg2 == nil {
|
||||
return cfg1
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Services: append(cfg1.Services, cfg2.Services...),
|
||||
Chains: append(cfg1.Chains, cfg2.Chains...),
|
||||
Hops: append(cfg1.Hops, cfg2.Hops...),
|
||||
Authers: append(cfg1.Authers, cfg2.Authers...),
|
||||
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
|
||||
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
|
||||
Resolvers: append(cfg1.Resolvers, cfg2.Resolvers...),
|
||||
Hosts: append(cfg1.Hosts, cfg2.Hosts...),
|
||||
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
|
||||
SDs: append(cfg1.SDs, cfg2.SDs...),
|
||||
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
|
||||
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...),
|
||||
Routers: append(cfg1.Routers, cfg2.Routers...),
|
||||
Observers: append(cfg1.Observers, cfg2.Observers...),
|
||||
TLS: cfg1.TLS,
|
||||
Log: cfg1.Log,
|
||||
API: cfg1.API,
|
||||
Metrics: cfg1.Metrics,
|
||||
Profiling: cfg1.Profiling,
|
||||
}
|
||||
if cfg2.TLS != nil {
|
||||
cfg.TLS = cfg2.TLS
|
||||
}
|
||||
if cfg2.Log != nil {
|
||||
cfg.Log = cfg2.Log
|
||||
}
|
||||
if cfg2.API != nil {
|
||||
cfg.API = cfg2.API
|
||||
}
|
||||
if cfg2.Metrics != nil {
|
||||
cfg.Metrics = cfg2.Metrics
|
||||
}
|
||||
if cfg2.Profiling != nil {
|
||||
cfg.Profiling = cfg2.Profiling
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,15 +2,31 @@ package recorder
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
recorder_plugin "github.com/go-gost/x/recorder/plugin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
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
|
||||
@@ -28,6 +44,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),
|
||||
)
|
||||
@@ -41,17 +58,50 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
|
||||
}
|
||||
|
||||
if cfg.File != nil && cfg.File.Path != "" {
|
||||
return xrecorder.FileRecorder(cfg.File.Path,
|
||||
xrecorder.SepRecorderOption(cfg.File.Sep),
|
||||
var out io.WriteCloser = discardCloser{}
|
||||
|
||||
if cfg.File.Rotation != nil {
|
||||
out = &lumberjack.Logger{
|
||||
Filename: cfg.File.Path,
|
||||
MaxSize: cfg.File.Rotation.MaxSize,
|
||||
MaxAge: cfg.File.Rotation.MaxAge,
|
||||
MaxBackups: cfg.File.Rotation.MaxBackups,
|
||||
LocalTime: cfg.File.Rotation.LocalTime,
|
||||
Compress: cfg.File.Rotation.Compress,
|
||||
}
|
||||
} else {
|
||||
os.MkdirAll(filepath.Dir(cfg.File.Path), 0755)
|
||||
f, err := os.OpenFile(cfg.File.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
logger.Default().Warn(err)
|
||||
} else {
|
||||
out = f
|
||||
}
|
||||
}
|
||||
|
||||
return xrecorder.FileRecorder(out,
|
||||
xrecorder.RecorderFileRecorderOption(cfg.Name),
|
||||
xrecorder.SepFileRecorderOption(cfg.File.Sep),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.TCP != nil && cfg.TCP.Addr != "" {
|
||||
return xrecorder.TCPRecorder(cfg.TCP.Addr, xrecorder.TimeoutTCPRecorderOption(cfg.TCP.Timeout))
|
||||
return xrecorder.TCPRecorder(cfg.TCP.Addr,
|
||||
xrecorder.RecorderTCPRecorderOption(cfg.Name),
|
||||
xrecorder.TimeoutTCPRecorderOption(cfg.TCP.Timeout),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.HTTP != nil && cfg.HTTP.URL != "" {
|
||||
return xrecorder.HTTPRecorder(cfg.HTTP.URL, xrecorder.TimeoutHTTPRecorderOption(cfg.HTTP.Timeout))
|
||||
h := http.Header{}
|
||||
for k, v := range cfg.HTTP.Header {
|
||||
h.Add(k, v)
|
||||
}
|
||||
return xrecorder.HTTPRecorder(cfg.HTTP.URL,
|
||||
xrecorder.RecorderHTTPRecorderOption(cfg.Name),
|
||||
xrecorder.TimeoutHTTPRecorderOption(cfg.HTTP.Timeout),
|
||||
xrecorder.HeaderHTTPRecorderOption(h),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.Redis != nil &&
|
||||
@@ -60,20 +110,26 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
|
||||
switch cfg.Redis.Type {
|
||||
case "list": // redis list
|
||||
return xrecorder.RedisListRecorder(cfg.Redis.Addr,
|
||||
xrecorder.RecorderRedisRecorderOption(cfg.Name),
|
||||
xrecorder.DBRedisRecorderOption(cfg.Redis.DB),
|
||||
xrecorder.KeyRedisRecorderOption(cfg.Redis.Key),
|
||||
xrecorder.UsernameRedisRecorderOption(cfg.Redis.Username),
|
||||
xrecorder.PasswordRedisRecorderOption(cfg.Redis.Password),
|
||||
)
|
||||
case "sset": // sorted set
|
||||
return xrecorder.RedisSortedSetRecorder(cfg.Redis.Addr,
|
||||
xrecorder.RecorderRedisRecorderOption(cfg.Name),
|
||||
xrecorder.DBRedisRecorderOption(cfg.Redis.DB),
|
||||
xrecorder.KeyRedisRecorderOption(cfg.Redis.Key),
|
||||
xrecorder.UsernameRedisRecorderOption(cfg.Redis.Username),
|
||||
xrecorder.PasswordRedisRecorderOption(cfg.Redis.Password),
|
||||
)
|
||||
default: // redis set
|
||||
return xrecorder.RedisSetRecorder(cfg.Redis.Addr,
|
||||
xrecorder.RecorderRedisRecorderOption(cfg.Name),
|
||||
xrecorder.DBRedisRecorderOption(cfg.Redis.DB),
|
||||
xrecorder.KeyRedisRecorderOption(cfg.Redis.Key),
|
||||
xrecorder.UsernameRedisRecorderOption(cfg.Redis.Username),
|
||||
xrecorder.PasswordRedisRecorderOption(cfg.Redis.Password),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
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
|
||||
@@ -31,6 +35,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,48 @@
|
||||
package rewriter
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/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 {
|
||||
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
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
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
|
||||
@@ -31,6 +34,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),
|
||||
)
|
||||
@@ -45,18 +49,29 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
|
||||
var routes []*router.Route
|
||||
for _, route := range cfg.Routes {
|
||||
_, ipNet, _ := net.ParseCIDR(route.Net)
|
||||
if ipNet == nil {
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
gw := net.ParseIP(route.Gateway)
|
||||
if gw == nil {
|
||||
_, ipNet, _ := net.ParseCIDR(route.Net)
|
||||
dst := route.Dst
|
||||
if dst != "" {
|
||||
if _, parsed, _ := net.ParseCIDR(dst); parsed != nil {
|
||||
ipNet = parsed
|
||||
}
|
||||
} else {
|
||||
if ipNet != nil {
|
||||
dst = ipNet.String()
|
||||
}
|
||||
}
|
||||
|
||||
if dst == "" || route.Gateway == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
routes = append(routes, &router.Route{
|
||||
Net: ipNet,
|
||||
Gateway: gw,
|
||||
Dst: dst,
|
||||
Gateway: route.Gateway,
|
||||
})
|
||||
}
|
||||
opts := []xrouter.Option{
|
||||
@@ -76,6 +91,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
opts = append(opts, xrouter.RedisLoaderOption(loader.RedisListLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -83,6 +99,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
opts = append(opts, xrouter.RedisLoaderOption(loader.RedisSetLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
@@ -90,6 +107,7 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
opts = append(opts, xrouter.RedisLoaderOption(loader.RedisHashLoader(
|
||||
cfg.Redis.Addr,
|
||||
loader.DBRedisLoaderOption(cfg.Redis.DB),
|
||||
loader.UsernameRedisLoaderOption(cfg.Redis.Username),
|
||||
loader.PasswordRedisLoaderOption(cfg.Redis.Password),
|
||||
loader.KeyRedisLoaderOption(cfg.Redis.Key),
|
||||
)))
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestParseRouter_Nil(t *testing.T) {
|
||||
r := ParseRouter(nil)
|
||||
if r != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_PluginHTTP(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "http-rt",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_PluginGRPC(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "grpc-rt",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "router.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_PluginDefaultType(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "default-rt",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_WithCIDRRoutes(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "cidr-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
{Net: "192.168.1.0/24", Gateway: "10.0.0.1"},
|
||||
{Net: "10.0.0.0/8", Gateway: "10.0.0.2"},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router with CIDR routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_WithDstRoutes(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "dst-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
{Dst: "192.168.1.0/24", Gateway: "10.0.0.1"},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router with dst routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_MissingGatewaySkipped(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "skip-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
{Net: "192.168.1.0/24", Gateway: ""}, // skipped: no gateway
|
||||
{Net: "10.0.0.0/8", Gateway: "10.0.0.1"}, // kept
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router (with one valid route)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_MissingDstSkipped(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "no-dst-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
{Dst: "", Gateway: "10.0.0.1"}, // skipped: no dst
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router (all routes skipped)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_InvalidCIDRSkipped(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "invalid-cidr-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
{Net: "not-a-cidr", Gateway: "10.0.0.1"}, // invalid CIDR with empty Dst, skipped
|
||||
{Net: "192.168.1.0/24", Gateway: "10.0.0.2"}, // valid
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_NilRouteSkipped(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "nil-route-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
nil,
|
||||
{Net: "192.168.1.0/24", Gateway: "10.0.0.1"},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router when nil route is in list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_EmptyRoutes(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "empty-routes",
|
||||
Routes: []*config.RouterRouteConfig{},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router with empty routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_FileLoader(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "file-rt",
|
||||
File: &config.FileLoader{Path: "/tmp/routes.txt"},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_HTTPLoader(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "http-rt",
|
||||
HTTP: &config.HTTPLoader{URL: "http://localhost:8080/routes"},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_RedisHashLoader(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "redis-hash-rt",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "routes-hash",
|
||||
Type: "hash",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_RedisListLoader(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "redis-list-rt",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "routes-list",
|
||||
Type: "list",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_RedisSetLoader(t *testing.T) {
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "redis-set-rt",
|
||||
Redis: &config.RedisLoader{
|
||||
Addr: "127.0.0.1:6379",
|
||||
Key: "routes-set",
|
||||
Type: "set",
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRouter_DstSyncedFromNet(t *testing.T) {
|
||||
// When Dst is empty but Net is provided, Net is converted to Dst
|
||||
r := ParseRouter(&config.RouterConfig{
|
||||
Name: "net-to-dst-rt",
|
||||
Routes: []*config.RouterRouteConfig{
|
||||
{Net: "192.168.1.0/24", Gateway: "10.0.0.1"},
|
||||
},
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
route := r.GetRoute(context.Background(), "192.168.1.5")
|
||||
if route == nil {
|
||||
t.Fatal("expected non-nil route for IP in CIDR range")
|
||||
}
|
||||
if route.Dst != "192.168.1.0/24" {
|
||||
t.Fatalf("Dst = %q, want %q", route.Dst, "192.168.1.0/24")
|
||||
}
|
||||
if route.Gateway != "10.0.0.1" {
|
||||
t.Fatalf("Gateway = %q, want %q", route.Gateway, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
sd_plugin "github.com/go-gost/x/sd/plugin"
|
||||
)
|
||||
|
||||
// ParseSD converts an SDConfig into an sd.SD. It only supports plugin backends
|
||||
// (HTTP or gRPC); returns nil when cfg or cfg.Plugin is nil.
|
||||
func ParseSD(cfg *config.SDConfig) sd.SD {
|
||||
if cfg == nil || cfg.Plugin == nil {
|
||||
return nil
|
||||
@@ -26,6 +28,7 @@ func ParseSD(cfg *config.SDConfig) sd.SD {
|
||||
case "http":
|
||||
return sd_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 sd
|
||||
|
||||
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 TestParseSD_Nil(t *testing.T) {
|
||||
s := ParseSD(nil)
|
||||
if s != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSD_NilPlugin(t *testing.T) {
|
||||
s := ParseSD(&config.SDConfig{
|
||||
Name: "no-plugin",
|
||||
Plugin: nil,
|
||||
})
|
||||
if s != nil {
|
||||
t.Fatal("expected nil when plugin is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSD_PluginHTTP(t *testing.T) {
|
||||
s := ParseSD(&config.SDConfig{
|
||||
Name: "http-sd",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "http",
|
||||
Addr: "127.0.0.1:9000",
|
||||
},
|
||||
})
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil SD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSD_PluginGRPC(t *testing.T) {
|
||||
s := ParseSD(&config.SDConfig{
|
||||
Name: "grpc-sd",
|
||||
Plugin: &config.PluginConfig{
|
||||
Type: "grpc",
|
||||
Addr: "127.0.0.1:9001",
|
||||
Token: "secret",
|
||||
TLS: &config.TLSConfig{
|
||||
Secure: true,
|
||||
ServerName: "sd.local",
|
||||
},
|
||||
},
|
||||
})
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil SD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSD_PluginDefaultType(t *testing.T) {
|
||||
s := ParseSD(&config.SDConfig{
|
||||
Name: "default-sd",
|
||||
Plugin: &config.PluginConfig{
|
||||
Addr: "127.0.0.1:9002",
|
||||
},
|
||||
})
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil SD")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
xs "github.com/go-gost/x/selector"
|
||||
)
|
||||
|
||||
// ParseChainSelector creates a chain-level selector from a SelectorConfig. If
|
||||
// cfg is nil it returns nil. Strategy defaults to round-robin when unrecognized
|
||||
// or unset.
|
||||
func ParseChainSelector(cfg *config.SelectorConfig) selector.Selector[chain.Chainer] {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
@@ -32,6 +35,9 @@ func ParseChainSelector(cfg *config.SelectorConfig) selector.Selector[chain.Chai
|
||||
)
|
||||
}
|
||||
|
||||
// ParseNodeSelector creates a node-level selector from a SelectorConfig. If
|
||||
// cfg is nil it returns nil. Supported strategies: round/rr, random/rand,
|
||||
// fifo/ha, hash, and parallel. Defaults to round-robin.
|
||||
func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node] {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
@@ -47,6 +53,8 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
|
||||
strategy = xs.FIFOStrategy[*chain.Node]()
|
||||
case "hash":
|
||||
strategy = xs.HashStrategy[*chain.Node]()
|
||||
case "parallel":
|
||||
strategy = xs.ParallelStrategy[*chain.Node]()
|
||||
default:
|
||||
strategy = xs.RoundRobinStrategy[*chain.Node]()
|
||||
}
|
||||
@@ -58,6 +66,8 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
|
||||
)
|
||||
}
|
||||
|
||||
// DefaultNodeSelector returns a node selector using round-robin strategy with
|
||||
// default fail filter settings.
|
||||
func DefaultNodeSelector() selector.Selector[*chain.Node] {
|
||||
return xs.NewSelector(
|
||||
xs.RoundRobinStrategy[*chain.Node](),
|
||||
@@ -66,6 +76,8 @@ func DefaultNodeSelector() selector.Selector[*chain.Node] {
|
||||
)
|
||||
}
|
||||
|
||||
// DefaultChainSelector returns a chain selector using round-robin strategy
|
||||
// with default fail filter settings.
|
||||
func DefaultChainSelector() selector.Selector[chain.Chainer] {
|
||||
return xs.NewSelector(
|
||||
xs.RoundRobinStrategy[chain.Chainer](),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package selector
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
)
|
||||
|
||||
func TestParseChainSelector_Nil(t *testing.T) {
|
||||
sel := ParseChainSelector(nil)
|
||||
if sel != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_RoundRobin(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "round"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_RR(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "rr"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector for rr strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_Random(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "random"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_Rand(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "rand"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector for rand strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_FIFO(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "fifo"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_HA(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "ha"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector for ha strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_Hash(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "hash"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_DefaultStrategy(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{Strategy: "unknown"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector for unknown strategy (should default to round-robin)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChainSelector_WithMaxFails(t *testing.T) {
|
||||
sel := ParseChainSelector(&config.SelectorConfig{
|
||||
Strategy: "round",
|
||||
MaxFails: 3,
|
||||
FailTimeout: 0,
|
||||
})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_Nil(t *testing.T) {
|
||||
sel := ParseNodeSelector(nil)
|
||||
if sel != nil {
|
||||
t.Fatal("expected nil for nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_RoundRobin(t *testing.T) {
|
||||
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "round"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_Random(t *testing.T) {
|
||||
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "random"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_FIFO(t *testing.T) {
|
||||
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "fifo"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_Hash(t *testing.T) {
|
||||
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "hash"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_Parallel(t *testing.T) {
|
||||
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "parallel"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector for parallel strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeSelector_DefaultStrategy(t *testing.T) {
|
||||
sel := ParseNodeSelector(&config.SelectorConfig{Strategy: "unknown"})
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil selector for unknown strategy (should default to round-robin)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNodeSelector(t *testing.T) {
|
||||
sel := DefaultNodeSelector()
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil default node selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultChainSelector(t *testing.T) {
|
||||
sel := DefaultChainSelector()
|
||||
if sel == nil {
|
||||
t.Fatal("expected non-nil default chain selector")
|
||||
}
|
||||
}
|
||||
+134
-18
@@ -6,19 +6,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/logger"
|
||||
mdutil "github.com/go-gost/core/metadata/util"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/core/selector"
|
||||
"github.com/go-gost/core/service"
|
||||
xadmission "github.com/go-gost/x/admission"
|
||||
xauth "github.com/go-gost/x/auth"
|
||||
xbypass "github.com/go-gost/x/bypass"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
@@ -29,12 +30,39 @@ import (
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
|
||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xstats "github.com/go-gost/x/observer/stats"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
// plaintextListeners are listener types that never terminate TLS on their
|
||||
// accepted connections, so any certFile/keyFile/caFile configured for them is
|
||||
// silently ignored. This catches the common footgun where a user writes e.g.
|
||||
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
|
||||
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
|
||||
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
|
||||
// new plaintext listeners are added.
|
||||
var plaintextListeners = map[string]bool{
|
||||
"tcp": true, "udp": true,
|
||||
"ws": true, "mws": true,
|
||||
"redirect": true, "tproxy": true,
|
||||
"rtcp": true, "rudp": true,
|
||||
"unix": true, "runix": true, "serial": true, "stdio": true,
|
||||
}
|
||||
|
||||
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
|
||||
// It defaults the listener to "tcp" and the handler to "auto", resolves named
|
||||
// components from the registry (authers, admissions, bypasses, resolvers,
|
||||
// hosts, chains, hops, limiters, recorders, observers), composes TLS settings,
|
||||
// and applies metadata-driven options (proxy protocol, netns, sockopts,
|
||||
// pre/post hooks, stats, etc.). It returns an error if the listener or handler
|
||||
// type is unknown or if construction of any sub-component fails.
|
||||
func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
if cfg.Listener == nil {
|
||||
cfg.Listener = &config.ListenerConfig{}
|
||||
@@ -73,6 +101,13 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = parsing.DefaultTLSConfig().Clone()
|
||||
tls_util.SetTLSOptions(tlsConfig, tlsCfg.Options)
|
||||
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
|
||||
}
|
||||
|
||||
if (tlsCfg.CertFile != "" || tlsCfg.KeyFile != "" || tlsCfg.CAFile != "") && plaintextListeners[cfg.Listener.Type] {
|
||||
serviceLogger.Warnf("TLS certificate options are configured but the %q listener does not use TLS and will ignore them; the connection will be plaintext. Use a TLS-capable listener (e.g. mwss, wss, tls, quic, grpc, http2, http3) to enable TLS.",
|
||||
cfg.Listener.Type)
|
||||
}
|
||||
|
||||
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
|
||||
@@ -83,7 +118,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
var auther auth.Authenticator
|
||||
if len(authers) > 0 {
|
||||
auther = auth.AuthenticatorGroup(authers...)
|
||||
auther = xauth.AuthenticatorGroup(authers...)
|
||||
}
|
||||
|
||||
admissions := admission_parser.List(cfg.Admission, cfg.Admissions...)
|
||||
@@ -99,10 +134,16 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
ifce := cfg.Interface
|
||||
var preUp, preDown, postUp, postDown []string
|
||||
var ignoreChain bool
|
||||
var pStats *stats.Stats
|
||||
var observePeriod time.Duration
|
||||
var pStats stats.Stats
|
||||
var observerPeriod time.Duration
|
||||
var netnsIn, netnsOut string
|
||||
var dialTimeout time.Duration
|
||||
var labels map[string]string
|
||||
|
||||
var limiterRefreshInterval time.Duration
|
||||
var limiterCleanupInterval time.Duration
|
||||
var limiterScope string
|
||||
|
||||
if cfg.Metadata != nil {
|
||||
md := metadata.NewMetadata(cfg.Metadata)
|
||||
ppv = mdutil.GetInt(md, parsing.MDKeyProxyProtocol)
|
||||
@@ -121,12 +162,26 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain)
|
||||
|
||||
if mdutil.GetBool(md, parsing.MDKeyEnableStats) {
|
||||
pStats = &stats.Stats{}
|
||||
pStats = xstats.NewStats(mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic))
|
||||
}
|
||||
observePeriod = mdutil.GetDuration(md, "observePeriod")
|
||||
netnsIn = mdutil.GetString(md, "netns")
|
||||
netnsOut = mdutil.GetString(md, "netns.out")
|
||||
dialTimeout = mdutil.GetDuration(md, "dialTimeout")
|
||||
observerPeriod = mdutil.GetDuration(md, parsing.MDKeyObserverPeriod, "observePeriod")
|
||||
|
||||
netnsIn = mdutil.GetString(md, parsing.MDKeyNetns)
|
||||
netnsOut = mdutil.GetString(md, parsing.MDKeyNetnsOut)
|
||||
|
||||
dialTimeout = mdutil.GetDuration(md, parsing.MDKeyDialTimeout)
|
||||
|
||||
limiterRefreshInterval = mdutil.GetDuration(md, parsing.MDKeyLimiterRefreshInterval)
|
||||
limiterCleanupInterval = mdutil.GetDuration(md, parsing.MDKeyLimiterCleanupInterval)
|
||||
limiterScope = mdutil.GetString(md, parsing.MDKeyLimiterScope)
|
||||
|
||||
labels = mdutil.GetStringMapString(md, parsing.MDKeyLabels)
|
||||
}
|
||||
|
||||
if len(labels) > 0 {
|
||||
serviceLogger = serviceLogger.WithFields(map[string]any{
|
||||
"labels": labels,
|
||||
})
|
||||
}
|
||||
|
||||
listenerLogger := serviceLogger.WithFields(map[string]any{
|
||||
@@ -154,8 +209,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
listener.AutherOption(auther),
|
||||
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
|
||||
listener.TLSConfigOption(tlsConfig),
|
||||
listener.AdmissionOption(admission.AdmissionGroup(admissions...)),
|
||||
listener.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Limiter)),
|
||||
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
listener.TrafficLimiterOption(
|
||||
cache_limiter.NewCachedTrafficLimiter(
|
||||
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
|
||||
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
|
||||
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
|
||||
cache_limiter.ScopeOption(limiterScope),
|
||||
),
|
||||
),
|
||||
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
|
||||
listener.ServiceOption(cfg.Name),
|
||||
listener.ProxyProtocolOption(ppv),
|
||||
@@ -207,6 +269,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, qname := range cfg.Quotas {
|
||||
ln = quota_wrapper.WrapListener(ln, strings.TrimSpace(qname))
|
||||
}
|
||||
|
||||
handlerLogger := serviceLogger.WithFields(map[string]any{
|
||||
"kind": "handler",
|
||||
})
|
||||
@@ -222,6 +288,8 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = parsing.DefaultTLSConfig().Clone()
|
||||
tls_util.SetTLSOptions(tlsConfig, tlsCfg.Options)
|
||||
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
|
||||
}
|
||||
|
||||
authers = auth_parser.List(cfg.Handler.Auther, cfg.Handler.Authers...)
|
||||
@@ -233,23 +301,67 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
|
||||
auther = nil
|
||||
if len(authers) > 0 {
|
||||
auther = auth.AuthenticatorGroup(authers...)
|
||||
auther = xauth.AuthenticatorGroup(authers...)
|
||||
}
|
||||
|
||||
// Parse skipauth from handler metadata to whitelist client IPs that
|
||||
// should bypass authentication. Accepts both YAML arrays
|
||||
// (skipauth: ["10.0.0.0/8"]) and comma-separated URL query values
|
||||
// (?skipauth=10.0.0.0/8,192.168.1.1).
|
||||
if cfg.Handler.Metadata != nil {
|
||||
hmd := metadata.NewMetadata(cfg.Handler.Metadata)
|
||||
skipauth := mdutil.GetStrings(hmd, "skipauth")
|
||||
if len(skipauth) == 0 {
|
||||
if s := mdutil.GetString(hmd, "skipauth"); s != "" {
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
skipauth = append(skipauth, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(skipauth) > 0 {
|
||||
if auther != nil {
|
||||
auther = xauth.WhitelistedAuthenticator(auther, skipauth)
|
||||
handlerLogger.Debugf("skipauth whitelist applied: %v", skipauth)
|
||||
} else {
|
||||
handlerLogger.Warnf("skipauth configured but no auther set — authentication is already disabled for all clients")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var recorders []recorder.RecorderObject
|
||||
for _, r := range cfg.Recorders {
|
||||
md := metadata.NewMetadata(r.Metadata)
|
||||
rec := registry.RecorderRegistry().Get(r.Name)
|
||||
if r.Metadata != nil {
|
||||
rec = &xrecorder.MetadataRecorder{
|
||||
Recorder: rec,
|
||||
Metadata: r.Metadata,
|
||||
}
|
||||
}
|
||||
recorders = append(recorders, recorder.RecorderObject{
|
||||
Recorder: registry.RecorderRegistry().Get(r.Name),
|
||||
Recorder: rec,
|
||||
Record: r.Record,
|
||||
Options: &recorder.Options{
|
||||
Direction: mdutil.GetBool(md, parsing.MDKeyRecorderDirection),
|
||||
TimestampFormat: mdutil.GetString(md, parsing.MDKeyRecorderTimestampFormat),
|
||||
Hexdump: mdutil.GetBool(md, parsing.MDKeyRecorderHexdump),
|
||||
HTTPBody: mdutil.GetBool(md, parsing.MDKeyRecorderHTTPBody),
|
||||
MaxBodySize: mdutil.GetInt(md, parsing.MDKeyRecorderHTTPMaxBodySize),
|
||||
},
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
var rew rewriter.Rewriter
|
||||
if cfg.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(cfg.Rewriter) {
|
||||
serviceLogger.Warnf("rewriter %q not found in registry", cfg.Rewriter)
|
||||
}
|
||||
rew = registry.RewriterRegistry().Get(cfg.Rewriter)
|
||||
}
|
||||
|
||||
routerOpts = []chain.RouterOption{
|
||||
chain.RetriesRouterOption(cfg.Handler.Retries),
|
||||
chain.TimeoutRouterOption(dialTimeout),
|
||||
@@ -273,11 +385,13 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
handler.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
handler.AutherOption(auther),
|
||||
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
|
||||
handler.BypassOption(bypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
handler.TLSConfigOption(tlsConfig),
|
||||
handler.RateLimiterOption(registry.RateLimiterRegistry().Get(cfg.RLimiter)),
|
||||
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
|
||||
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
|
||||
handler.RecordersOption(recorders...),
|
||||
handler.RewriterOption(rew),
|
||||
handler.LoggerOption(handlerLogger),
|
||||
handler.ServiceOption(cfg.Name),
|
||||
handler.NetnsOption(netnsIn),
|
||||
@@ -304,7 +418,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
|
||||
s := xservice.NewService(cfg.Name, ln, h,
|
||||
xservice.AdmissionOption(admission.AdmissionGroup(admissions...)),
|
||||
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
xservice.PreUpOption(preUp),
|
||||
xservice.PreDownOption(preDown),
|
||||
xservice.PostUpOption(postUp),
|
||||
@@ -312,8 +426,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
xservice.RecordersOption(recorders...),
|
||||
xservice.StatsOption(pStats),
|
||||
xservice.ObserverOption(registry.ObserverRegistry().Get(cfg.Observer)),
|
||||
xservice.ObservePeriodOption(observePeriod),
|
||||
xservice.ObserverPeriodOption(observerPeriod),
|
||||
xservice.LoggerOption(serviceLogger),
|
||||
xservice.LabelsOption(labels),
|
||||
)
|
||||
|
||||
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
|
||||
@@ -369,6 +484,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
|
||||
Bypass: node.Bypass,
|
||||
Bypasses: node.Bypasses,
|
||||
Filter: filter,
|
||||
Matcher: node.Matcher,
|
||||
HTTP: httpCfg,
|
||||
TLS: node.TLS,
|
||||
Metadata: node.Metadata,
|
||||
|
||||
+118
-25
@@ -12,6 +12,9 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
@@ -20,47 +23,136 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultTLSConfig *tls.Config
|
||||
defaultTLSConfig atomic.Value
|
||||
)
|
||||
|
||||
func DefaultTLSConfig() *tls.Config {
|
||||
return defaultTLSConfig
|
||||
// testDefaultCertDir overrides the default certificate directory in tests.
|
||||
// It is set only from same-package test code and left empty in production.
|
||||
var testDefaultCertDir string
|
||||
|
||||
// defaultCertDir returns the directory used for persisting auto-generated
|
||||
// certificates. It uses testDefaultCertDir when set (for testing), otherwise
|
||||
// returns $HOME/.gost/.
|
||||
func defaultCertDir() string {
|
||||
if testDefaultCertDir != "" {
|
||||
return testDefaultCertDir
|
||||
}
|
||||
dir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".gost"
|
||||
}
|
||||
return filepath.Join(dir, ".gost")
|
||||
}
|
||||
|
||||
func BuildDefaultTLSConfig(cfg *config.TLSConfig) {
|
||||
// DefaultTLSConfig returns the global default TLS configuration used as a
|
||||
// fallback when a listener or handler does not specify its own TLS settings.
|
||||
// The returned config is a shared instance; callers that need to mutate it
|
||||
// should clone it first.
|
||||
func DefaultTLSConfig() *tls.Config {
|
||||
v, _ := defaultTLSConfig.Load().(*tls.Config)
|
||||
return v
|
||||
}
|
||||
|
||||
// SetDefaultTLSConfig replaces the global default TLS configuration. It is safe
|
||||
// to call from multiple goroutines.
|
||||
func SetDefaultTLSConfig(cfg *tls.Config) {
|
||||
defaultTLSConfig.Store(cfg)
|
||||
}
|
||||
|
||||
// BuildDefaultTLSConfig loads or generates the default TLS certificate and key
|
||||
// from the given config.
|
||||
//
|
||||
// When explicit certificate files are configured (CertFile, KeyFile, or CAFile
|
||||
// is non-empty) it loads them via tls_util.LoadDefaultConfig — the original
|
||||
// behaviour preserved for backward compatibility.
|
||||
//
|
||||
// When no certificate files are configured (all three empty), it tries sources
|
||||
// in this order:
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ (persisted)
|
||||
// 3. If none found, generates a new ECDSA P-256 CA certificate, persists it
|
||||
// to $HOME/.gost/, and uses it.
|
||||
//
|
||||
// If cfg is nil an empty TLSConfig is used (equivalent to no explicit files),
|
||||
// so the CWD → persisted → generate path is taken.
|
||||
func BuildDefaultTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
if cfg == nil {
|
||||
cfg = &config.TLSConfig{
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
CAFile: "ca.pem",
|
||||
}
|
||||
cfg = &config.TLSConfig{}
|
||||
}
|
||||
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
// generate random self-signed certificate.
|
||||
cert, err := genCertificate(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if cfg.CertFile != "" || cfg.KeyFile != "" || cfg.CAFile != "" {
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
log.Debug("load global TLS certificate files failed, use random generated certificate")
|
||||
} else {
|
||||
log.Debug("load global TLS certificate files OK")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
defaultTLSConfig = tlsConfig
|
||||
|
||||
tlsConfig, err := loadOrGeneratePersistentTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func genCertificate(validity time.Duration, org string, cn string) (cert tls.Certificate, err error) {
|
||||
rawCert, rawKey, err := generateKeyPair(validity, org, cn)
|
||||
if err != nil {
|
||||
return
|
||||
// loadOrGeneratePersistentTLSConfig tries loading an auto-generated CA
|
||||
// certificate from these locations (in order):
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under defaultCertDir() (persisted)
|
||||
//
|
||||
// If none is found it generates a new one, persists it to defaultCertDir()
|
||||
// (best-effort), and returns it.
|
||||
func loadOrGeneratePersistentTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
// 1. Try CWD cert.pem / key.pem first (backward compatible).
|
||||
cwdCert, cwdErr := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if cwdErr == nil {
|
||||
log.Debug("loaded default certificate from current working directory (cert.pem / key.pem)")
|
||||
return &tls.Config{Certificates: []tls.Certificate{cwdCert}}, nil
|
||||
}
|
||||
return tls.X509KeyPair(rawCert, rawKey)
|
||||
|
||||
// 2. Try persisted certificate in defaultCertDir().
|
||||
dir := defaultCertDir()
|
||||
certFile := filepath.Join(dir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(dir, "auto-ca-key.pem")
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err == nil {
|
||||
log.Debugf("loaded persisted default certificate from %s", dir)
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
log.Debug("generating new default certificate (no persisted certificate found)")
|
||||
|
||||
// 3. Generate a new certificate.
|
||||
rawCert, rawKey, err := generateKeyPair(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort persist to disk. If it fails the in-memory certificate is
|
||||
// still usable.
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Warnf("failed to create certificate directory %s: %v", dir, err)
|
||||
} else {
|
||||
if err := os.WriteFile(certFile, rawCert, 0644); err != nil {
|
||||
log.Warnf("failed to persist certificate: %v", err)
|
||||
} else if err := os.WriteFile(keyFile, rawKey, 0600); err != nil {
|
||||
log.Warnf("failed to persist private key: %v", err)
|
||||
} else {
|
||||
log.Debugf("persisted default certificate to %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err = tls.X509KeyPair(rawCert, rawKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, rawKey []byte, err error) {
|
||||
@@ -107,6 +199,7 @@ func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, ra
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if _, isRSA := priv.(*rsa.PrivateKey); isRSA {
|
||||
template.KeyUsage |= x509.KeyUsageKeyEncipherment
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package parsing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// writeTestCertKey writes a self-signed cert/key pair to the given file paths.
|
||||
// The cert is NOT persisted via the defaultCertDir mechanism — it simulates
|
||||
// user-provided cert.pem / key.pem in a working directory.
|
||||
func writeTestCertKey(t *testing.T, certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "cwd-test.local"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(certFile, certPEM, 0644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestSetDefaultTLSConfig(t *testing.T) {
|
||||
// Save and restore original
|
||||
orig := DefaultTLSConfig()
|
||||
defer SetDefaultTLSConfig(orig)
|
||||
|
||||
cfg := &tls.Config{ServerName: "test.local"}
|
||||
SetDefaultTLSConfig(cfg)
|
||||
|
||||
got := DefaultTLSConfig()
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil default TLS config")
|
||||
}
|
||||
if got.ServerName != "test.local" {
|
||||
t.Fatalf("ServerName = %q, want %q", got.ServerName, "test.local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTLSConfig_InitiallyNil(t *testing.T) {
|
||||
orig := DefaultTLSConfig()
|
||||
defer SetDefaultTLSConfig(orig)
|
||||
|
||||
// Store nil to reset
|
||||
SetDefaultTLSConfig(nil)
|
||||
|
||||
got := DefaultTLSConfig()
|
||||
if got != nil {
|
||||
t.Fatal("expected nil after storing nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
// Use a temp dir so the test is isolated and doesn't touch $HOME/.gost
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
// BuildDefaultTLSConfig with nil should generate a self-signed cert
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Verify that the certificate and key files were persisted.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg := &config.TLSConfig{
|
||||
Validity: 0, // uses default
|
||||
Organization: "TestOrg",
|
||||
CommonName: "test.example.com",
|
||||
}
|
||||
tlsCfg, err := BuildDefaultTLSConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
if len(cfg1.Certificates) == 0 || len(cfg2.Certificates) == 0 {
|
||||
t.Fatal("expected certificates in both configs")
|
||||
}
|
||||
|
||||
// Both calls should return the SAME persisted certificate.
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate from two calls (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_PersistsAndReloads(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// First call generates and persists.
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
|
||||
// Verify files exist on disk.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted after first call")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted after first call")
|
||||
}
|
||||
|
||||
// Second call loads the persisted cert.
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate on reload (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_FallsBackWhenWriteBlocked(t *testing.T) {
|
||||
// Point to a directory that can't be created (e.g. a file path).
|
||||
// We use a path under /proc on Linux which is a read-only filesystem.
|
||||
testDefaultCertDir = "/proc/doesnotexist/certdir"
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (should fall back to in-memory): %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("expected non-nil config even when write fails")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate from fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDefaultTLSConfig_CWDTakesPriority verifies that when cert.pem and
|
||||
// key.pem exist in the current working directory, they take precedence over
|
||||
// both the persisted certificate and auto-generation.
|
||||
func TestBuildDefaultTLSConfig_CWDTakesPriority(t *testing.T) {
|
||||
// Create a temp dir with cert.pem / key.pem.
|
||||
cwd := t.TempDir()
|
||||
writeTestCertKey(t, filepath.Join(cwd, "cert.pem"), filepath.Join(cwd, "key.pem"))
|
||||
|
||||
// Also set up a persisted cert directory.
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// Pre-populate persisted cert so we can prove CWD wins.
|
||||
_, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-populate persisted cert: %v", err)
|
||||
}
|
||||
|
||||
// Change to the CWD that has cert.pem / key.pem.
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// BuildDefaultTLSConfig should load CWD cert, not the persisted one.
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Load CWD cert directly and compare.
|
||||
cwdCert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if err != nil {
|
||||
t.Fatalf("load CWD cert: %v", err)
|
||||
}
|
||||
got := tlsCfg.Certificates[0].Certificate[0]
|
||||
want := cwdCert.Certificate[0]
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("CWD certificate was not used (persisted or auto-generated cert took priority)")
|
||||
}
|
||||
|
||||
// Load persisted cert to prove it's different from CWD cert.
|
||||
persistedCert, err := tls.LoadX509KeyPair(
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-cert.pem"),
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-key.pem"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted cert: %v", err)
|
||||
}
|
||||
if bytes.Equal(got, persistedCert.Certificate[0]) {
|
||||
t.Fatal("CWD cert should differ from persisted cert; test setup issue")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package direct
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type conn struct{}
|
||||
|
||||
func (c *conn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *conn) Read(b []byte) (n int, err error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (c *conn) Write(b []byte) (n int, err error) {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
func (c *conn) LocalAddr() net.Addr {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
|
||||
func (c *conn) RemoteAddr() net.Addr {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
|
||||
func (c *conn) SetDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *conn) SetReadDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *conn) SetWriteDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package forward
|
||||
// Package direct implements a direct (transparent) connector that establishes
|
||||
// connections to destination addresses using the dialer provided via connect
|
||||
// options. It also supports a "reject" action that returns a dead connection
|
||||
// (reads return io.EOF, writes return io.ErrClosedPipe) without dialing.
|
||||
package direct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"github.com/go-gost/core/connector"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -15,9 +21,11 @@ func init() {
|
||||
}
|
||||
|
||||
type directConnector struct {
|
||||
md metadata
|
||||
options connector.Options
|
||||
}
|
||||
|
||||
// NewConnector creates a direct connector with the given options.
|
||||
func NewConnector(opts ...connector.Option) connector.Connector {
|
||||
options := connector.Options{}
|
||||
for _, opt := range opts {
|
||||
@@ -30,7 +38,7 @@ func NewConnector(opts ...connector.Option) connector.Connector {
|
||||
}
|
||||
|
||||
func (c *directConnector) Init(md md.Metadata) (err error) {
|
||||
return nil
|
||||
return c.parseMetadata(md)
|
||||
}
|
||||
|
||||
func (c *directConnector) Connect(ctx context.Context, _ net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) {
|
||||
@@ -39,26 +47,37 @@ func (c *directConnector) Connect(ctx context.Context, _ net.Conn, network, addr
|
||||
opt(&cOpts)
|
||||
}
|
||||
|
||||
if c.md.action == "reject" {
|
||||
return &conn{}, nil
|
||||
}
|
||||
|
||||
if cOpts.Dialer == nil {
|
||||
return nil, errors.New("direct: missing dialer in connect options")
|
||||
}
|
||||
|
||||
conn, err := cOpts.Dialer.Dial(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var localAddr, remoteAddr string
|
||||
if addr := conn.LocalAddr(); addr != nil {
|
||||
localAddr = addr.String()
|
||||
}
|
||||
if addr := conn.RemoteAddr(); addr != nil {
|
||||
remoteAddr = addr.String()
|
||||
}
|
||||
if c.options.Logger != nil {
|
||||
var localAddr, remoteAddr string
|
||||
if addr := conn.LocalAddr(); addr != nil {
|
||||
localAddr = addr.String()
|
||||
}
|
||||
if addr := conn.RemoteAddr(); addr != nil {
|
||||
remoteAddr = addr.String()
|
||||
}
|
||||
|
||||
log := c.options.Logger.WithFields(map[string]any{
|
||||
"remote": remoteAddr,
|
||||
"local": localAddr,
|
||||
"network": network,
|
||||
"address": address,
|
||||
})
|
||||
log.Debugf("connect %s/%s", address, network)
|
||||
log := c.options.Logger.WithFields(map[string]any{
|
||||
"remote": remoteAddr,
|
||||
"local": localAddr,
|
||||
"network": network,
|
||||
"address": address,
|
||||
"sid": string(ctxvalue.SidFromContext(ctx)),
|
||||
})
|
||||
log.Debugf("connect %s/%s", address, network)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user