diff --git a/internal/config/config.default.yaml b/internal/config/config.default.yaml index 812ed9b..5357b5c 100644 --- a/internal/config/config.default.yaml +++ b/internal/config/config.default.yaml @@ -1,11 +1,12 @@ # rill 服务端配置 -version: 2 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 +version: 3 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 server: host: "0.0.0.0" # 监听地址,0.0.0.0 表示所有网卡 port: 8080 #web 服务端口,为 0 则不使用 tcp html sock: "web.sock" # unix socket 文件路径,留空表示不启用 mode: release # gin 运行模式: debug / release / test + trusted_proxies: [] # 可信代理/CDN 回源网段(IP 或 CIDR),仅这些来源的转发头会被采信;留空表示不信任任何代理 log: diff --git a/internal/config/config.go b/internal/config/config.go index 06134cf..3f0fed2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "log/slog" + "net" "os" "path/filepath" "strings" @@ -28,10 +29,11 @@ type Config struct { } type ServerConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - Sock string `yaml:"sock"` - Mode string `yaml:"mode"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Sock string `yaml:"sock"` + Mode string `yaml:"mode"` + TrustedProxies []string `yaml:"trusted_proxies"` } // TCPEnabled 是否启用 TCP 监听(port 为 0 表示不启用)。 @@ -98,10 +100,11 @@ type MySQLConfig struct { func defaultConfig() *Config { return &Config{ Server: ServerConfig{ - Host: "0.0.0.0", - Port: 8080, - Sock: "web.sock", - Mode: "release", + Host: "0.0.0.0", + Port: 8080, + Sock: "web.sock", + Mode: "release", + TrustedProxies: []string{}, }, Log: LogConfig{ Level: "info", @@ -197,6 +200,15 @@ func (c *Config) validate() error { if !c.Server.TCPEnabled() && !c.Server.SockEnabled() { return fmt.Errorf("server.port 与 server.sock 至少需要启用一个") } + for _, proxy := range c.Server.TrustedProxies { + value := strings.TrimSpace(proxy) + if value == "" || net.ParseIP(value) != nil { + continue + } + if _, _, err := net.ParseCIDR(value); err != nil { + return fmt.Errorf("server.trusted_proxies 无效: %q(需为 IP 或 CIDR)", proxy) + } + } if _, err := parseLogLevel(c.Log.Level); err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6bd5da2..652114d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -44,6 +44,7 @@ custom: `prefix: "/api"`, "auth:", `token_ttl: "24h"`, + "trusted_proxies", } { if !strings.Contains(out, want) { t.Errorf("补全结果缺少 %q\n---\n%s", want, out) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 508b4ea..4adbfed 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -15,7 +15,7 @@ import ( ) // ConfigVersion 当前配置结构版本,新增配置项时递增。 -const ConfigVersion = 2 +const ConfigVersion = 3 // upgradeResult 描述一次配置自动补全的结果。 type upgradeResult struct { diff --git a/internal/database/seed.go b/internal/database/seed.go index 418ffde..66b8fde 100644 --- a/internal/database/seed.go +++ b/internal/database/seed.go @@ -1,18 +1,16 @@ package database import ( - "crypto/rand" "fmt" "log/slog" - "math/big" "os" "path/filepath" - "strings" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "rill/internal/model" + "rill/internal/utils" ) const ( @@ -22,9 +20,6 @@ const ( adminPasswordFilename = "admin_password.txt" ) -// adminPasswordCharset 去掉了易混淆字符(0/O、1/l/I)。 -const adminPasswordCharset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" - // seedAdminUser 创建初始管理员用户并加入 admin 组,密码随机生成且仅在迁移时打印一次。 func seedAdminUser(tx *gorm.DB) error { var count int64 @@ -36,7 +31,7 @@ func seedAdminUser(tx *gorm.DB) error { return nil } - password, err := generatePassword(adminPasswordLen) + password, err := utils.RandomString(adminPasswordLen) if err != nil { return err } @@ -103,17 +98,3 @@ func writeAdminPasswordFile(path, password string) error { ) return os.WriteFile(path, []byte(content), 0o600) } - -func generatePassword(length int) (string, error) { - limit := big.NewInt(int64(len(adminPasswordCharset))) - var builder strings.Builder - builder.Grow(length) - for i := 0; i < length; i++ { - n, err := rand.Int(rand.Reader, limit) - if err != nil { - return "", fmt.Errorf("生成随机密码失败: %w", err) - } - builder.WriteByte(adminPasswordCharset[n.Int64()]) - } - return builder.String(), nil -} diff --git a/internal/utils/ip.go b/internal/utils/ip.go new file mode 100644 index 0000000..4fef10e --- /dev/null +++ b/internal/utils/ip.go @@ -0,0 +1,184 @@ +// Package utils 提供通用工具函数:客户端 IP 解析、随机字符串等。 +package utils + +import ( + "fmt" + "net" + "net/http" + "strings" + "sync" + + "github.com/gin-gonic/gin" +) + +// clientIPHeaders 按优先级排列的单值客户端 IP 请求头;Forwarded 与 X-Forwarded-For 因需要链式解析单独处理。 +var clientIPHeaders = []string{ + "CF-Connecting-IP", + "True-Client-IP", + "Ali-CDN-Real-IP", + "X-Real-IP", + "X-Client-IP", + "Fastly-Client-IP", +} + +var ( + trustedMu sync.RWMutex + trustedCIDRs []*net.IPNet +) + +// SetTrustedProxies 设置可信代理/CDN 回源网段,支持 IP 与 CIDR,启动时调用一次;并发安全。 +func SetTrustedProxies(cidrs []string) error { + parsed := make([]*net.IPNet, 0, len(cidrs)) + for _, raw := range cidrs { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if ip := net.ParseIP(value); ip != nil { + bits := 128 + if ip.To4() != nil { + bits = 32 + } + _, cidr, err := net.ParseCIDR(fmt.Sprintf("%s/%d", ip.String(), bits)) + if err != nil { + return fmt.Errorf("invalid trusted proxy %q: %w", raw, err) + } + parsed = append(parsed, cidr) + continue + } + if _, cidr, err := net.ParseCIDR(value); err == nil { + parsed = append(parsed, cidr) + continue + } + return fmt.Errorf("invalid trusted proxy %q", raw) + } + + trustedMu.Lock() + trustedCIDRs = parsed + trustedMu.Unlock() + return nil +} + +// ClientIP 依次枚举 CDN/代理请求头,仅在直连地址可信时采信;否则返回直连 IP。 +func ClientIP(c *gin.Context) string { + direct := RemoteIP(c) + if !isTrustedPeer(c, direct) { + return direct + } + + for _, header := range clientIPHeaders { + if ip := normalizeIP(c.GetHeader(header)); ip != "" { + return ip + } + } + if ip := pickClientIP(parseForwarded(c.GetHeader("Forwarded"))); ip != "" { + return ip + } + if ip := pickClientIP(parseIPList(c.GetHeader("X-Forwarded-For"))); ip != "" { + return ip + } + return direct +} + +// RemoteIP 返回直连地址(已去端口),不读取任何请求头。 +func RemoteIP(c *gin.Context) string { + return normalizeIP(c.Request.RemoteAddr) +} + +// pickClientIP 从右往左取第一个非可信代理 IP;全部可信时取最左值。 +func pickClientIP(chain []string) string { + for i := len(chain) - 1; i >= 0; i-- { + if !isTrustedStringIP(chain[i]) { + return chain[i] + } + } + if len(chain) > 0 { + return chain[0] + } + return "" +} + +func isTrustedPeer(c *gin.Context, direct string) bool { + if isUnixSocket(c) { + return true + } + ip := net.ParseIP(direct) + return ip != nil && isTrustedIP(ip) +} + +func isTrustedIP(ip net.IP) bool { + trustedMu.RLock() + defer trustedMu.RUnlock() + for _, cidr := range trustedCIDRs { + if cidr.Contains(ip) { + return true + } + } + return false +} + +func isTrustedStringIP(value string) bool { + ip := net.ParseIP(value) + return ip != nil && isTrustedIP(ip) +} + +func isUnixSocket(c *gin.Context) bool { + addr, ok := c.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) + return ok && strings.HasPrefix(addr.Network(), "unix") +} + +// normalizeIP 清理引号与端口后校验为合法 IP,非法返回空串。 +func normalizeIP(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + value = strings.Trim(value, `"`) + if host, _, err := net.SplitHostPort(value); err == nil { + value = host + } + value = strings.Trim(value, "[]") + ip := net.ParseIP(value) + if ip == nil { + return "" + } + return ip.String() +} + +func parseIPList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + ips := make([]string, 0, len(parts)) + for _, part := range parts { + if ip := normalizeIP(part); ip != "" { + ips = append(ips, ip) + } + } + return ips +} + +// parseForwarded 解析 RFC 7239 Forwarded 头中的 for= 参数,按出现顺序返回合法 IP。 +func parseForwarded(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + var ips []string + for _, element := range strings.Split(value, ",") { + for _, param := range strings.Split(element, ";") { + key, val, ok := strings.Cut(strings.TrimSpace(param), "=") + if !ok || !strings.EqualFold(strings.TrimSpace(key), "for") { + continue + } + candidate := strings.TrimSpace(val) + if candidate == "" || candidate == "_hidden" || strings.EqualFold(candidate, "unknown") { + continue + } + if ip := normalizeIP(candidate); ip != "" { + ips = append(ips, ip) + } + } + } + return ips +} diff --git a/internal/utils/ip_test.go b/internal/utils/ip_test.go new file mode 100644 index 0000000..ba49afa --- /dev/null +++ b/internal/utils/ip_test.go @@ -0,0 +1,178 @@ +package utils + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestMain(m *testing.M) { + gin.SetMode(gin.TestMode) + os.Exit(m.Run()) +} + +func newContext(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remoteAddr + for key, value := range headers { + req.Header.Set(key, value) + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + return c +} + +func setTrusted(t *testing.T, cidrs ...string) { + t.Helper() + if err := SetTrustedProxies(cidrs); err != nil { + t.Fatalf("SetTrustedProxies(%v) 失败: %v", cidrs, err) + } + t.Cleanup(func() { + _ = SetTrustedProxies(nil) + }) +} + +func TestClientIPUntrustedPeer(t *testing.T) { + setTrusted(t) + + c := newContext(t, "192.0.2.10:5555", map[string]string{ + "CF-Connecting-IP": "203.0.113.9", + "X-Real-IP": "203.0.113.8", + }) + if got := ClientIP(c); got != "192.0.2.10" { + t.Errorf("ClientIP = %q, 期望直连地址 192.0.2.10", got) + } +} + +func TestClientIPHeaderPriority(t *testing.T) { + setTrusted(t, "192.0.2.0/24") + + cases := []struct { + name string + headers map[string]string + want string + }{ + {"cloudflare", map[string]string{"CF-Connecting-IP": "203.0.113.1", "X-Real-IP": "203.0.113.2"}, "203.0.113.1"}, + {"true-client", map[string]string{"True-Client-IP": "203.0.113.3", "X-Real-IP": "203.0.113.2"}, "203.0.113.3"}, + {"ali-cdn", map[string]string{"Ali-CDN-Real-IP": "203.0.113.4"}, "203.0.113.4"}, + {"real-ip", map[string]string{"X-Real-IP": "203.0.113.5"}, "203.0.113.5"}, + {"client-ip", map[string]string{"X-Client-IP": "203.0.113.6"}, "203.0.113.6"}, + {"fastly", map[string]string{"Fastly-Client-IP": "203.0.113.7"}, "203.0.113.7"}, + } + for _, tc := range cases { + c := newContext(t, "192.0.2.10:5555", tc.headers) + if got := ClientIP(c); got != tc.want { + t.Errorf("%s: ClientIP = %q, 期望 %q", tc.name, got, tc.want) + } + } +} + +func TestClientIPInvalidValueFallsThrough(t *testing.T) { + setTrusted(t, "192.0.2.10") + + c := newContext(t, "192.0.2.10:5555", map[string]string{ + "CF-Connecting-IP": "not-an-ip", + "X-Real-IP": "203.0.113.5", + }) + if got := ClientIP(c); got != "203.0.113.5" { + t.Errorf("ClientIP = %q, 期望 203.0.113.5", got) + } +} + +func TestClientIPXForwardedFor(t *testing.T) { + setTrusted(t, "192.0.2.0/24", "10.0.0.0/8") + + c := newContext(t, "192.0.2.10:5555", map[string]string{ + "X-Forwarded-For": "198.51.100.1, 10.0.0.5", + }) + if got := ClientIP(c); got != "198.51.100.1" { + t.Errorf("应取最右侧非可信 IP, ClientIP = %q, 期望 198.51.100.1", got) + } + + c = newContext(t, "192.0.2.10:5555", map[string]string{ + "X-Forwarded-For": "10.0.0.1, 10.0.0.2", + }) + if got := ClientIP(c); got != "10.0.0.1" { + t.Errorf("全部可信时应取最左, ClientIP = %q, 期望 10.0.0.1", got) + } +} + +func TestClientIPForwarded(t *testing.T) { + setTrusted(t, "192.0.2.10") + + c := newContext(t, "192.0.2.10:5555", map[string]string{ + "Forwarded": `for=203.0.113.9;proto=https, for="[2001:db8::1]:4711"`, + }) + if got := ClientIP(c); got != "2001:db8::1" { + t.Errorf("ClientIP = %q, 期望 2001:db8::1", got) + } +} + +func TestClientIPNormalization(t *testing.T) { + setTrusted(t, "192.0.2.10") + + cases := []struct { + header string + value string + want string + }{ + {"CF-Connecting-IP", "192.0.2.99:443", "192.0.2.99"}, + {"X-Real-IP", `"198.51.100.7"`, "198.51.100.7"}, + {"X-Real-IP", " 198.51.100.8 ", "198.51.100.8"}, + {"X-Real-IP", "[2001:db8::2]:8080", "2001:db8::2"}, + {"X-Real-IP", "2001:db8::3", "2001:db8::3"}, + } + for _, tc := range cases { + c := newContext(t, "192.0.2.10:5555", map[string]string{tc.header: tc.value}) + if got := ClientIP(c); got != tc.want { + t.Errorf("%s=%q: ClientIP = %q, 期望 %q", tc.header, tc.value, got, tc.want) + } + } +} + +func TestClientIPUnixSocketTrusted(t *testing.T) { + setTrusted(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "@" + req = req.WithContext(context.WithValue(req.Context(), http.LocalAddrContextKey, + &net.UnixAddr{Name: "web.sock", Net: "unix"})) + req.Header.Set("CF-Connecting-IP", "203.0.113.9") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + if got := ClientIP(c); got != "203.0.113.9" { + t.Errorf("unix socket 应视为可信, ClientIP = %q, 期望 203.0.113.9", got) + } +} + +func TestSetTrustedProxies(t *testing.T) { + if err := SetTrustedProxies([]string{"192.0.2.0/24", "198.51.100.7", "2001:db8::/32", " "}); err != nil { + t.Fatalf("合法输入不应报错: %v", err) + } + t.Cleanup(func() { + _ = SetTrustedProxies(nil) + }) + + if err := SetTrustedProxies([]string{"not-a-cidr"}); err == nil { + t.Error("非法 CIDR 应报错") + } +} + +func TestRemoteIP(t *testing.T) { + if got := RemoteIP(newContext(t, "192.0.2.1:1234", nil)); got != "192.0.2.1" { + t.Errorf("RemoteIP = %q, 期望 192.0.2.1", got) + } + if got := RemoteIP(newContext(t, "@", nil)); got != "" { + t.Errorf("unix socket RemoteIP = %q, 期望空串", got) + } +} diff --git a/internal/utils/random.go b/internal/utils/random.go new file mode 100644 index 0000000..a0784d4 --- /dev/null +++ b/internal/utils/random.go @@ -0,0 +1,26 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" +) + +// randomCharset 去掉了易混淆字符(0/O、1/l/I)。 +const randomCharset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// RandomString 生成指定长度的随机字符串。 +func RandomString(length int) (string, error) { + limit := big.NewInt(int64(len(randomCharset))) + var builder strings.Builder + builder.Grow(length) + for i := 0; i < length; i++ { + n, err := rand.Int(rand.Reader, limit) + if err != nil { + return "", fmt.Errorf("生成随机字符串失败: %w", err) + } + builder.WriteByte(randomCharset[n.Int64()]) + } + return builder.String(), nil +} diff --git a/internal/utils/random_test.go b/internal/utils/random_test.go new file mode 100644 index 0000000..826d43a --- /dev/null +++ b/internal/utils/random_test.go @@ -0,0 +1,29 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestRandomString(t *testing.T) { + value, err := RandomString(16) + if err != nil { + t.Fatalf("生成随机字符串失败: %v", err) + } + if len(value) != 16 { + t.Errorf("长度 = %d, 期望 16", len(value)) + } + for _, r := range value { + if !strings.ContainsRune(randomCharset, r) { + t.Errorf("包含非法字符 %q", r) + } + } + + other, err := RandomString(16) + if err != nil { + t.Fatalf("生成随机字符串失败: %v", err) + } + if value == other { + t.Error("两次生成的字符串不应相同") + } +} diff --git a/main.go b/main.go index 3ff9f20..0ae01ad 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "rill/internal/api" "rill/internal/config" "rill/internal/database" + "rill/internal/utils" ) //go:generate go tool swag init -g main.go -o docs --parseInternal @@ -63,6 +64,15 @@ func main() { //启动gin服务 r := gin.New() + // 可信代理配置需同时应用于 gin(访问日志 IP)与 utils(业务取 IP) + if err := r.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil { + slog.Error("设置 gin 可信代理失败", "err", err) + os.Exit(1) + } + if err := utils.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil { + slog.Error("设置可信代理失败", "err", err) + os.Exit(1) + } r.Use(gin.Recovery()) if cfg.Log.AccessLog { r.Use(gin.Logger())