diff --git a/apps/ops-web/src/views/AuditLogsView.vue b/apps/ops-web/src/views/AuditLogsView.vue index d516566..76afd46 100644 --- a/apps/ops-web/src/views/AuditLogsView.vue +++ b/apps/ops-web/src/views/AuditLogsView.vue @@ -64,7 +64,13 @@ — - {{ record.ip || "—" }} + + {{ record.ip }} + + {{ formatIPRegion(record.ip_region) }} + + + — @@ -88,6 +94,7 @@ interface AuditRow { target_id: string | null; metadata: Record | null; ip: string | null; + ip_region: string | null; user_agent: string | null; request_id: string | null; created_at: string; @@ -112,7 +119,7 @@ const columns = [ { title: "动作", key: "action", width: 220 }, { title: "对象", key: "target", width: 200 }, { title: "元数据", key: "metadata" }, - { title: "IP", key: "ip", width: 140 }, + { title: "IP", key: "ip", width: 220 }, ]; const rows = ref([]); @@ -183,6 +190,14 @@ function formatMetadata(meta: Record): string { } } +function formatIPRegion(region: string): string { + const parts = region + .split("|") + .map((part) => part.trim()) + .filter((part) => part && part !== "0"); + return parts.length > 0 ? parts.join(" / ") : region; +} + function resolveActionColor(action: string): string { if (action.endsWith(".failure") || action.includes("disable")) return "red"; if (action.endsWith(".success") || action.includes("create")) return "green"; @@ -194,3 +209,12 @@ onMounted(() => { void reload(); }); + + diff --git a/deploy/ops-config.yaml b/deploy/ops-config.yaml index 22e3d9b..46fbc42 100644 --- a/deploy/ops-config.yaml +++ b/deploy/ops-config.yaml @@ -28,3 +28,7 @@ default_admin: admin_users: default_plan_code: free + +ip_region: + v4_xdb_path: /app/data/ip2region_v4.xdb + v6_xdb_path: /app/data/ip2region_v6.xdb diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index 29d6385..a6d52c4 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -48,7 +48,13 @@ func main() { adminUsersRepo := repository.NewAdminUserRepository(pool) auditsRepo := repository.NewAuditRepository(pool) - auditSvc := app.NewAuditService(auditsRepo, logger) + ipRegionResolver, err := app.NewIPRegionResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger) + if err != nil { + logger.Sugar().Warnf("ops-api ip2region disabled: %v", err) + } + defer ipRegionResolver.Close() + + auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver) issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL) authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer) accountSvc := app.NewAccountService(accountsRepo, auditSvc) diff --git a/server/configs/ops-config.yaml b/server/configs/ops-config.yaml index b18c1ac..4cbad90 100644 --- a/server/configs/ops-config.yaml +++ b/server/configs/ops-config.yaml @@ -28,3 +28,8 @@ default_admin: admin_users: default_plan_code: free + +ip_region: + # 可选外部 xdb 路径;文件不存在时自动使用二进制 embed 内置数据。 + v4_xdb_path: "" + v6_xdb_path: "" diff --git a/server/go.mod b/server/go.mod index b093667..5b27462 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,6 +13,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.5.5 github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3 + github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308 github.com/minio/minio-go/v7 v7.0.83 github.com/prometheus/client_golang v1.20.5 github.com/prometheus/common v0.60.1 diff --git a/server/go.sum b/server/go.sum index eb13bb4..3bebbe9 100644 --- a/server/go.sum +++ b/server/go.sum @@ -123,6 +123,8 @@ github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3 h1:DDPccSesc/SJu8Ci github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308 h1:Qbh9Tg+EWuHW2o3Ux0ifWve+IRYWFAzSUt/90I2iRcE= +github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= diff --git a/server/internal/ops/app/audit.go b/server/internal/ops/app/audit.go index 2c4c9ac..012af1d 100644 --- a/server/internal/ops/app/audit.go +++ b/server/internal/ops/app/audit.go @@ -10,15 +10,24 @@ import ( ) type AuditService struct { - repo *repository.AuditRepository - logger *zap.Logger + repo *repository.AuditRepository + logger *zap.Logger + ipRegions *IPRegionResolver } func NewAuditService(repo *repository.AuditRepository, logger *zap.Logger) *AuditService { return &AuditService{repo: repo, logger: logger} } +func (s *AuditService) WithIPRegionResolver(resolver *IPRegionResolver) *AuditService { + s.ipRegions = resolver + return s +} + func (s *AuditService) Append(ctx context.Context, e domain.AuditEvent) error { + if e.IPRegion == "" && s.ipRegions != nil { + e.IPRegion = s.ipRegions.Lookup(e.IP) + } if err := s.repo.Append(ctx, e); err != nil { s.logger.Warn("ops audit append failed", zap.String("action", e.Action), diff --git a/server/internal/ops/app/ip_region.go b/server/internal/ops/app/ip_region.go new file mode 100644 index 0000000..189a1dc --- /dev/null +++ b/server/internal/ops/app/ip_region.go @@ -0,0 +1,207 @@ +package app + +import ( + "embed" + "errors" + "fmt" + "net" + "os" + "strings" + + "go.uber.org/zap" + + "github.com/lionsoul2014/ip2region/binding/golang/xdb" +) + +//go:embed ipregiondata/ip2region_v4.xdb ipregiondata/ip2region_v6.xdb +var embeddedIPRegionFS embed.FS + +const ( + embeddedV4XDBPath = "ipregiondata/ip2region_v4.xdb" + embeddedV6XDBPath = "ipregiondata/ip2region_v6.xdb" +) + +type IPRegionResolver struct { + searcher ipRegionSearcher + logger *zap.Logger +} + +type ipRegionSearcher interface { + Search(ip any) (string, error) + Close() +} + +func NewIPRegionResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*IPRegionResolver, error) { + searcher, err := newIPRegionSearcher(v4XDBPath, v6XDBPath) + if err != nil { + return nil, err + } + return &IPRegionResolver{searcher: searcher, logger: logger}, nil +} + +func newIPRegionSearcher(v4XDBPath, v6XDBPath string) (*bufferIPRegionSearcher, error) { + v4Content, err := loadIPRegionContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4) + if err != nil { + return nil, fmt.Errorf("load ipv4 xdb: %w", err) + } + v6Content, err := loadIPRegionContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6) + if err != nil { + return nil, fmt.Errorf("load ipv6 xdb: %w", err) + } + + searcher := &bufferIPRegionSearcher{} + if len(v4Content) > 0 { + searcher.v4, err = xdb.NewWithBuffer(xdb.IPv4, v4Content) + if err != nil { + return nil, fmt.Errorf("create ipv4 searcher: %w", err) + } + } + if len(v6Content) > 0 { + searcher.v6, err = xdb.NewWithBuffer(xdb.IPv6, v6Content) + if err != nil { + return nil, fmt.Errorf("create ipv6 searcher: %w", err) + } + } + return searcher, nil +} + +func loadIPRegionContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) { + if externalPath != "" { + content, err := loadXDBContentFromFile(externalPath, version) + if err == nil { + return content, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + + content, err := xdb.LoadContentFromFS(embeddedIPRegionFS, embeddedPath) + if err != nil { + return nil, err + } + if err := verifyXDBVersion(content, version); err != nil { + return nil, fmt.Errorf("verify embedded xdb %s: %w", embeddedPath, err) + } + return content, nil +} + +func loadXDBContentFromFile(path string, version *xdb.Version) ([]byte, error) { + content, err := xdb.LoadContentFromFile(path) + if err != nil { + return nil, err + } + if err := verifyXDBVersion(content, version); err != nil { + return nil, fmt.Errorf("verify external xdb %s: %w", path, err) + } + return content, nil +} + +func verifyXDBVersion(content []byte, expected *xdb.Version) error { + if len(content) < xdb.HeaderInfoLength { + return fmt.Errorf("xdb content too short: got %d bytes", len(content)) + } + header, err := xdb.LoadHeaderFromBuff(content) + if err != nil { + return fmt.Errorf("load header: %w", err) + } + version, err := xdb.VersionFromHeader(header) + if err != nil { + return err + } + if version.Id != expected.Id { + return fmt.Errorf("ip version mismatch: got %s, want %s", version.Name, expected.Name) + } + return nil +} + +func (r *IPRegionResolver) Lookup(rawIP string) string { + ipText := strings.TrimSpace(rawIP) + if ipText == "" { + return "" + } + if host, _, err := net.SplitHostPort(ipText); err == nil { + ipText = host + } + + ip := net.ParseIP(ipText) + if ip == nil { + return "" + } + if ip.IsLoopback() { + return "本机" + } + if ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return "内网IP" + } + if r == nil || r.searcher == nil { + return "" + } + + region, err := r.searcher.Search(ip.String()) + if err != nil { + if r.logger != nil { + r.logger.Warn("ops audit ip2region lookup failed", + zap.String("ip", ip.String()), + zap.Error(err), + ) + } + return "" + } + return strings.TrimSpace(region) +} + +type bufferIPRegionSearcher struct { + v4 *xdb.Searcher + v6 *xdb.Searcher +} + +func (s *bufferIPRegionSearcher) Search(ip any) (string, error) { + ipBytes, err := parseIPBytes(ip) + if err != nil { + return "", err + } + switch len(ipBytes) { + case xdb.IPv4.Bytes: + if s == nil || s.v4 == nil { + return "", nil + } + return s.v4.Search(ipBytes) + case xdb.IPv6.Bytes: + if s == nil || s.v6 == nil { + return "", nil + } + return s.v6.Search(ipBytes) + default: + return "", fmt.Errorf("invalid byte ip address with len=%d", len(ipBytes)) + } +} + +func (s *bufferIPRegionSearcher) Close() { + if s == nil { + return + } + if s.v4 != nil { + s.v4.Close() + } + if s.v6 != nil { + s.v6.Close() + } +} + +func parseIPBytes(ip any) ([]byte, error) { + switch v := ip.(type) { + case string: + return xdb.ParseIP(v) + case []byte: + return v, nil + default: + return nil, fmt.Errorf("invalid ip value type %T", v) + } +} + +func (r *IPRegionResolver) Close() { + if r != nil && r.searcher != nil { + r.searcher.Close() + } +} diff --git a/server/internal/ops/app/ip_region_test.go b/server/internal/ops/app/ip_region_test.go new file mode 100644 index 0000000..1895612 --- /dev/null +++ b/server/internal/ops/app/ip_region_test.go @@ -0,0 +1,52 @@ +package app + +import "testing" + +func TestIPRegionResolverLookupSpecialAddresses(t *testing.T) { + resolver := &IPRegionResolver{} + + tests := []struct { + name string + ip string + want string + }{ + {name: "ipv4 loopback", ip: "127.0.0.1", want: "本机"}, + {name: "ipv6 loopback", ip: "::1", want: "本机"}, + {name: "private network", ip: "192.168.1.10", want: "内网IP"}, + {name: "host port", ip: "10.0.0.8:443", want: "内网IP"}, + {name: "invalid", ip: "not-an-ip", want: ""}, + {name: "public disabled", ip: "8.8.8.8", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolver.Lookup(tt.ip); got != tt.want { + t.Fatalf("Lookup(%q) = %q, want %q", tt.ip, got, tt.want) + } + }) + } +} + +func TestIPRegionResolverLookupFromXDB(t *testing.T) { + resolver, err := NewIPRegionResolver("", "", nil) + if err != nil { + t.Fatalf("NewIPRegionResolver() error = %v", err) + } + defer resolver.Close() + + if got := resolver.Lookup("8.8.8.8"); got == "" { + t.Fatal("Lookup(8.8.8.8) returned empty region") + } +} + +func TestIPRegionResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) { + resolver, err := NewIPRegionResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil) + if err != nil { + t.Fatalf("NewIPRegionResolver() error = %v", err) + } + defer resolver.Close() + + if got := resolver.Lookup("8.8.8.8"); got == "" { + t.Fatal("Lookup(8.8.8.8) returned empty region") + } +} diff --git a/server/internal/ops/app/ipregiondata/ip2region_v4.xdb b/server/internal/ops/app/ipregiondata/ip2region_v4.xdb new file mode 100644 index 0000000..794dca0 Binary files /dev/null and b/server/internal/ops/app/ipregiondata/ip2region_v4.xdb differ diff --git a/server/internal/ops/app/ipregiondata/ip2region_v6.xdb b/server/internal/ops/app/ipregiondata/ip2region_v6.xdb new file mode 100644 index 0000000..9815778 Binary files /dev/null and b/server/internal/ops/app/ipregiondata/ip2region_v6.xdb differ diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go index bb0255f..c78fa3c 100644 --- a/server/internal/ops/config/config.go +++ b/server/internal/ops/config/config.go @@ -18,6 +18,7 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` AdminUsers AdminUsersConfig `mapstructure:"admin_users"` + IPRegion IPRegionConfig `mapstructure:"ip_region"` } type JWTConfig struct { @@ -36,6 +37,11 @@ type AdminUsersConfig struct { DefaultPlanCode string `mapstructure:"default_plan_code"` } +type IPRegionConfig struct { + V4XDBPath string `mapstructure:"v4_xdb_path"` + V6XDBPath string `mapstructure:"v6_xdb_path"` +} + func Load(path string) (*Config, error) { v := viper.New() v.SetConfigFile(path) @@ -52,6 +58,8 @@ func Load(path string) (*Config, error) { v.SetDefault("default_admin.username", "admin") v.SetDefault("default_admin.display_name", "Administrator") v.SetDefault("admin_users.default_plan_code", "free") + v.SetDefault("ip_region.v4_xdb_path", "") + v.SetDefault("ip_region.v6_xdb_path", "") if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("read config: %w", err) @@ -92,5 +100,11 @@ func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" { cfg.AdminUsers.DefaultPlanCode = v } + if v := os.Getenv("OPS_IP_REGION_V4_XDB_PATH"); v != "" { + cfg.IPRegion.V4XDBPath = v + } + if v := os.Getenv("OPS_IP_REGION_V6_XDB_PATH"); v != "" { + cfg.IPRegion.V6XDBPath = v + } return nil } diff --git a/server/internal/ops/domain/types.go b/server/internal/ops/domain/types.go index 6123d98..46fd74d 100644 --- a/server/internal/ops/domain/types.go +++ b/server/internal/ops/domain/types.go @@ -35,6 +35,7 @@ type AuditEvent struct { TargetID string Metadata map[string]any IP string + IPRegion string UserAgent string RequestID string } @@ -48,6 +49,7 @@ type AuditLog struct { TargetID *string Metadata map[string]any IP *string + IPRegion *string UserAgent *string RequestID *string CreatedAt time.Time diff --git a/server/internal/ops/repository/audit.go b/server/internal/ops/repository/audit.go index c717e49..b0a2da6 100644 --- a/server/internal/ops/repository/audit.go +++ b/server/internal/ops/repository/audit.go @@ -33,15 +33,16 @@ func (r *AuditRepository) Append(ctx context.Context, e domain.AuditEvent) error targetType = nullableString(e.TargetType) targetID = nullableString(e.TargetID) ip = nullableString(e.IP) + ipRegion = nullableString(e.IPRegion) ua = nullableString(e.UserAgent) reqID = nullableString(e.RequestID) ) _, err := r.pool.Exec(ctx, ` INSERT INTO ops.audit_logs - (operator_id, operator_name, action, target_type, target_id, metadata, ip, user_agent, request_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - e.OperatorID, operatorName, e.Action, targetType, targetID, metaBytes, ip, ua, reqID) + (operator_id, operator_name, action, target_type, target_id, metadata, ip, ip_region, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + e.OperatorID, operatorName, e.Action, targetType, targetID, metaBytes, ip, ipRegion, ua, reqID) return err } @@ -86,7 +87,7 @@ func (r *AuditRepository) List(ctx context.Context, f domain.AuditLogFilter) ([] rows, err := r.pool.Query(ctx, fmt.Sprintf(` SELECT id, operator_id, operator_name, action, target_type, target_id, - metadata, ip, user_agent, request_id, created_at + metadata, ip, ip_region, user_agent, request_id, created_at FROM ops.audit_logs WHERE %s ORDER BY created_at DESC, id DESC @@ -104,8 +105,8 @@ func (r *AuditRepository) List(ctx context.Context, f domain.AuditLogFilter) ([] ) if err := rows.Scan( &log.ID, &log.OperatorID, &log.OperatorName, &log.Action, - &log.TargetType, &log.TargetID, &metaRaw, &log.IP, &log.UserAgent, - &log.RequestID, &log.CreatedAt, + &log.TargetType, &log.TargetID, &metaRaw, &log.IP, &log.IPRegion, + &log.UserAgent, &log.RequestID, &log.CreatedAt, ); err != nil { return nil, 0, err } diff --git a/server/internal/ops/transport/audit_handler.go b/server/internal/ops/transport/audit_handler.go index b12a9f1..97861b4 100644 --- a/server/internal/ops/transport/audit_handler.go +++ b/server/internal/ops/transport/audit_handler.go @@ -82,6 +82,7 @@ type auditLogDTO struct { TargetID *string `json:"target_id"` Metadata map[string]any `json:"metadata"` IP *string `json:"ip"` + IPRegion *string `json:"ip_region"` UserAgent *string `json:"user_agent"` RequestID *string `json:"request_id"` CreatedAt time.Time `json:"created_at"` @@ -97,6 +98,7 @@ func toAuditLogDTO(l domain.AuditLog) auditLogDTO { TargetID: l.TargetID, Metadata: l.Metadata, IP: l.IP, + IPRegion: l.IPRegion, UserAgent: l.UserAgent, RequestID: l.RequestID, CreatedAt: l.CreatedAt, diff --git a/server/migrations_ops/20260429100000_add_ops_audit_ip_region.down.sql b/server/migrations_ops/20260429100000_add_ops_audit_ip_region.down.sql new file mode 100644 index 0000000..0571b03 --- /dev/null +++ b/server/migrations_ops/20260429100000_add_ops_audit_ip_region.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ops.audit_logs + DROP COLUMN IF EXISTS ip_region; diff --git a/server/migrations_ops/20260429100000_add_ops_audit_ip_region.up.sql b/server/migrations_ops/20260429100000_add_ops_audit_ip_region.up.sql new file mode 100644 index 0000000..c1810f3 --- /dev/null +++ b/server/migrations_ops/20260429100000_add_ops_audit_ip_region.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE ops.audit_logs + ADD COLUMN ip_region VARCHAR(255);