实现分流器扩展
This commit is contained in:
22
README.md
22
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` 重新挂载。
|
||||
|
||||
4
deny.txt
Normal file
4
deny.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
# 每行一个要拒绝的目标;支持域名、IP、CIDR 和域名通配符。
|
||||
# 示例(取消注释后生效):
|
||||
# .example.com
|
||||
# 203.0.113.0/24
|
||||
@@ -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:
|
||||
|
||||
12
gost-geo-plugin/Dockerfile
Normal file
12
gost-geo-plugin/Dockerfile
Normal 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
16
gost-geo-plugin/go.mod
Normal 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
40
gost-geo-plugin/go.sum
Normal 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
130
gost-geo-plugin/main.go
Normal 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))
|
||||
}
|
||||
91
gost-geo-plugin/main_test.go
Normal file
91
gost-geo-plugin/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user