实现分流器扩展

This commit is contained in:
2026-10-15 15:40:19 +08:00
parent e4bfcbc80a
commit b67a62b023
9 changed files with 338 additions and 3 deletions

View File

@@ -1,7 +1,23 @@
# 代理网关
## 使用方式
1. 复制 `.env.example` 为 `.env`,填写环境变量。
2. 执行 `docker compose up -d --build` 启动(需要支持 Docker host 网络)。
3. 通过 API 创建或更新代理服务,在**服务对象顶层**添加:
复制 `.env.example` 为 `.env`,并根据需要修改环境变量
```json
"bypasses": ["deny", "china"]
```
执行 `docker-compose up` 启动容器
启用后仅允许访问中国 IP 目标,黑名单优先;中国 IP 列表每 24 小时自动更新。
直接编辑项目根目录的 `deny.txt` 管理黑名单,每行一条,支持 `#` 注释,约 10 秒生效:
```text
.example.com
203.0.113.1
203.0.113.0/24
```
域名请用域名规则封禁,IP/CIDR 规则只匹配 IP 形式的目标。
若编辑器通过替换文件保存,单文件挂载可能仍指向旧文件;此时执行 `docker compose up -d --force-recreate gost` 重新挂载。

4
deny.txt Normal file
View File

@@ -0,0 +1,4 @@
# 每行一个要拒绝的目标;支持域名、IP、CIDR 和域名通配符。
# 示例(取消注释后生效):
# .example.com
# 203.0.113.0/24

View File

@@ -4,9 +4,21 @@ services:
gost:
image: gogost/gost:latest
network_mode: host
depends_on:
- gost-geo-plugin
volumes:
- ./gost/gost.yaml:/etc/gost/gost.yaml
- ./gost/logs:/var/log/gost
- ./deny.txt:/var/lib/gost/deny.txt:ro
restart: unless-stopped
gost-geo-plugin:
build: ./gost-geo-plugin
network_mode: host
command:
- --listen-addr=127.0.0.1:18080
- --list-url=https://china-operator-ip.yfgao.com/china.txt
- --refresh=24h
restart: unless-stopped
otel-collector:

View File

@@ -0,0 +1,12 @@
FROM golang:1.26.5-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
RUN go test ./... && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gost-geo-plugin .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /gost-geo-plugin /gost-geo-plugin
USER 65532:65532
ENTRYPOINT ["/gost-geo-plugin"]

16
gost-geo-plugin/go.mod Normal file
View File

@@ -0,0 +1,16 @@
module gost-geo-plugin
go 1.26.3
require (
github.com/go-gost/plugin v0.2.1
google.golang.org/grpc v1.79.2
)
require (
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

40
gost-geo-plugin/go.sum Normal file
View File

@@ -0,0 +1,40 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-gost/plugin v0.2.1 h1:zkkgCZd+/X+jmaDElP7z2fZVTFv+Pjq2DLmnnNWPMFA=
github.com/go-gost/plugin v0.2.1/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

130
gost-geo-plugin/main.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
"github.com/go-gost/plugin/bypass/proto"
"google.golang.org/grpc"
)
type server struct {
proto.UnimplementedBypassServer
mu sync.RWMutex
list []netip.Prefix
lookup func(context.Context, string, string) ([]netip.Addr, error)
}
func (s *server) Bypass(ctx context.Context, in *proto.BypassRequest) (*proto.BypassReply, error) {
// GOST 服务级插件返回 Ok=true 表示拒绝,外层 whitelist 参数不生效。
reply := &proto.BypassReply{Ok: true}
host := in.GetAddr()
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
var ips []netip.Addr
if ip, err := netip.ParseAddr(host); err == nil {
ips = []netip.Addr{ip}
} else {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var err error
ips, err = s.lookup(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return reply, nil
}
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, ip := range ips {
allowed := false
for _, prefix := range s.list {
if prefix.Contains(ip.Unmap()) {
allowed = true
break
}
}
// 任一解析结果不在白名单内就拒绝,避免混合地址被放行。
if !allowed {
return reply, nil
}
}
reply.Ok = false
return reply, nil
}
func (s *server) refresh(url string) error {
client := http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("list returned HTTP %d", resp.StatusCode)
}
var list []netip.Prefix
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line, _, _ := strings.Cut(scanner.Text(), "#")
line = strings.TrimSpace(line)
if line == "" {
continue
}
prefix, err := netip.ParsePrefix(line)
if err != nil {
return fmt.Errorf("invalid CIDR %q: %w", line, err)
}
list = append(list, prefix.Masked())
}
if err := scanner.Err(); err != nil {
return err
}
if len(list) == 0 {
return fmt.Errorf("empty CIDR list")
}
// 完整加载成功才替换,下载或解析失败继续使用旧列表。
s.mu.Lock()
s.list = list
s.mu.Unlock()
log.Printf("loaded %d CIDR rules", len(list))
return nil
}
func main() {
addr := flag.String("listen-addr", "127.0.0.1:18080", "gRPC listen address")
url := flag.String("list-url", "https://china-operator-ip.yfgao.com/china.txt", "CIDR list URL")
period := flag.Duration("refresh", 24*time.Hour, "CIDR refresh interval")
flag.Parse()
if *period <= 0 {
log.Fatal("refresh must be positive")
}
s := &server{lookup: net.DefaultResolver.LookupNetIP}
if err := s.refresh(*url); err != nil {
log.Fatal(err)
}
go func() {
for range time.Tick(*period) {
if err := s.refresh(*url); err != nil {
log.Printf("refresh failed, keeping previous list: %v", err)
}
}
}()
listener, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatal(err)
}
rpc := grpc.NewServer()
proto.RegisterBypassServer(rpc, s)
log.Printf("listening on %s", *addr)
log.Fatal(rpc.Serve(listener))
}

View File

@@ -0,0 +1,91 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/go-gost/plugin/bypass/proto"
)
func TestBypass(t *testing.T) {
for _, tc := range []struct {
name string
addr string
ips []netip.Addr
err error
deny bool
}{
{name: "listed IPv4", addr: "203.0.113.1:443"},
{name: "foreign IPv4", addr: "198.51.100.1:443", deny: true},
{name: "listed IPv6", addr: "[2001:db8::1]:443"},
{name: "bare IPv6", addr: "2001:db8::1"},
{name: "foreign IPv6", addr: "[2001:db9::1]:443", deny: true},
{name: "mapped IPv4", addr: "[::ffff:203.0.113.1]:443"},
{name: "listed domain", addr: "allowed.test:443", ips: []netip.Addr{netip.MustParseAddr("203.0.113.2")}},
{name: "mixed answers", addr: "mixed.test:443", ips: []netip.Addr{netip.MustParseAddr("203.0.113.2"), netip.MustParseAddr("198.51.100.2")}, deny: true},
{name: "no addresses", addr: "empty.test:443", deny: true},
{name: "DNS failure", addr: "error.test:443", err: errors.New("DNS unavailable"), deny: true},
} {
t.Run(tc.name, func(t *testing.T) {
s := &server{
list: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("2001:db8::/32")},
lookup: func(ctx context.Context, _, _ string) ([]netip.Addr, error) {
if _, ok := ctx.Deadline(); !ok {
t.Error("DNS lookup has no deadline")
}
return tc.ips, tc.err
},
}
reply, err := s.Bypass(context.Background(), &proto.BypassRequest{Addr: tc.addr})
if err != nil || reply.Ok != tc.deny {
t.Fatalf("Bypass = %v, %v; want deny=%v", reply, err, tc.deny)
}
})
}
}
func TestRefresh(t *testing.T) {
status, body := 200, "# list\n203.0.113.7/24 # normalize\n2001:db8::/32\n"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer upstream.Close()
s := &server{}
if err := s.refresh(upstream.URL); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
status int
body string
}{
{"HTTP failure", 503, "unavailable"},
{"empty list", 200, "# empty\n"},
{"malformed list", 200, "198.51.100.0/24\n<html>error</html>"},
} {
t.Run(tc.name, func(t *testing.T) {
status, body = tc.status, tc.body
if err := s.refresh(upstream.URL); err == nil {
t.Fatal("accepted invalid list")
}
reply, _ := s.Bypass(context.Background(), &proto.BypassRequest{Addr: "203.0.113.1:443"})
if reply.Ok || len(s.list) != 2 {
t.Fatal("failed refresh discarded previous rules")
}
})
}
status, body = 200, "198.51.100.0/24\n"
if err := s.refresh(upstream.URL); err != nil {
t.Fatal(err)
}
old, _ := s.Bypass(context.Background(), &proto.BypassRequest{Addr: "203.0.113.1:443"})
updated, _ := s.Bypass(context.Background(), &proto.BypassRequest{Addr: "198.51.100.1:443"})
if !old.Ok || updated.Ok {
t.Fatal("refresh did not replace rules")
}
}

View File

@@ -15,3 +15,17 @@ limiters:
- name: limiter-8m
limits:
- "$ 8m 8m"
bypasses:
- name: china
# 插件自身实现白名单:非中国目标返回拦截。
plugin:
type: grpc
addr: 127.0.0.1:18080
- name: deny
# GOST 3.3.0 不替换空规则集;保留不可用目标,确保清空文件也能撤销旧规则。
matchers:
- 0.0.0.0/32
reload: 10s
file:
path: /var/lib/gost/deny.txt