feat(ops-api/audit): annotate audit log IP with region lookup

Add an IPRegionResolver wrapping the ip2region xdb library and attach it
to AuditService so each appended event records both the raw IP and a
resolved region in a new ops.audit_logs.ip_region column. Loopback and
private addresses short-circuit to local labels; missing xdb data or
lookup errors degrade silently so auditing keeps working without it.
The ops console audit view shows the region beneath the IP. Bundle the
v4/v6 xdb data under internal/ops/app/ipregiondata so the resolver works
out of the box, with config paths/env overrides for swapping in updated
data sets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:46:37 +08:00
parent 5a9d80c518
commit 3480d04ec3
17 changed files with 344 additions and 11 deletions
+11 -2
View File
@@ -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),
+207
View File
@@ -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()
}
}
+52
View File
@@ -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")
}
}
+14
View File
@@ -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
}
+2
View File
@@ -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
+7 -6
View File
@@ -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
}
@@ -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,