From b67a62b023978553455b085fe0d8e1575eb37b01 Mon Sep 17 00:00:00 2001 From: luorijun Date: Thu, 15 Oct 2026 15:40:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=88=86=E6=B5=81=E5=99=A8?= =?UTF-8?q?=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++++- deny.txt | 4 ++ docker-compose.yaml | 12 ++++ gost-geo-plugin/Dockerfile | 12 ++++ gost-geo-plugin/go.mod | 16 +++++ gost-geo-plugin/go.sum | 40 +++++++++++ gost-geo-plugin/main.go | 130 +++++++++++++++++++++++++++++++++++ gost-geo-plugin/main_test.go | 91 ++++++++++++++++++++++++ gost/gost.yaml | 14 ++++ 9 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 deny.txt create mode 100644 gost-geo-plugin/Dockerfile create mode 100644 gost-geo-plugin/go.mod create mode 100644 gost-geo-plugin/go.sum create mode 100644 gost-geo-plugin/main.go create mode 100644 gost-geo-plugin/main_test.go diff --git a/README.md b/README.md index 524f6a2..9b4cb2a 100644 --- a/README.md +++ b/README.md @@ -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` 重新挂载。 diff --git a/deny.txt b/deny.txt new file mode 100644 index 0000000..a6600fa --- /dev/null +++ b/deny.txt @@ -0,0 +1,4 @@ +# 每行一个要拒绝的目标;支持域名、IP、CIDR 和域名通配符。 +# 示例(取消注释后生效): +# .example.com +# 203.0.113.0/24 diff --git a/docker-compose.yaml b/docker-compose.yaml index 738259f..dcbbba3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/gost-geo-plugin/Dockerfile b/gost-geo-plugin/Dockerfile new file mode 100644 index 0000000..e68fc16 --- /dev/null +++ b/gost-geo-plugin/Dockerfile @@ -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"] diff --git a/gost-geo-plugin/go.mod b/gost-geo-plugin/go.mod new file mode 100644 index 0000000..3079a0f --- /dev/null +++ b/gost-geo-plugin/go.mod @@ -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 +) diff --git a/gost-geo-plugin/go.sum b/gost-geo-plugin/go.sum new file mode 100644 index 0000000..12d8f85 --- /dev/null +++ b/gost-geo-plugin/go.sum @@ -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= diff --git a/gost-geo-plugin/main.go b/gost-geo-plugin/main.go new file mode 100644 index 0000000..27ce39b --- /dev/null +++ b/gost-geo-plugin/main.go @@ -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)) +} diff --git a/gost-geo-plugin/main_test.go b/gost-geo-plugin/main_test.go new file mode 100644 index 0000000..eb3b874 --- /dev/null +++ b/gost-geo-plugin/main_test.go @@ -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\nerror"}, + } { + 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") + } +} diff --git a/gost/gost.yaml b/gost/gost.yaml index d0ceebb..353f574 100644 --- a/gost/gost.yaml +++ b/gost/gost.yaml @@ -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