Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
219 lines
6.1 KiB
Go
219 lines
6.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
const classifierVersion = "ingress-reject-v1"
|
|
|
|
type candidate struct {
|
|
id int64
|
|
statusCode int
|
|
message string
|
|
body string
|
|
}
|
|
|
|
func main() {
|
|
beforeRaw := flag.String("before", "", "required RFC3339 cutoff; only older rows are considered")
|
|
execute := flag.Bool("execute", false, "delete matched rows (default is dry-run)")
|
|
batchSize := flag.Int("batch-size", 5000, "scan/delete batch size (1-5000)")
|
|
flag.Parse()
|
|
|
|
if *beforeRaw == "" {
|
|
log.Fatal("--before is required")
|
|
}
|
|
before, err := time.Parse(time.RFC3339, *beforeRaw)
|
|
if err != nil {
|
|
log.Fatalf("invalid --before: %v", err)
|
|
}
|
|
if *batchSize < 1 || *batchSize > 5000 {
|
|
log.Fatal("--batch-size must be between 1 and 5000")
|
|
}
|
|
|
|
cfg, err := config.LoadForBootstrap()
|
|
if err != nil {
|
|
log.Fatalf("load config: %v", err)
|
|
}
|
|
client, db, err := repository.InitEnt(cfg)
|
|
if err != nil {
|
|
log.Fatalf("initialize database: %v", err)
|
|
}
|
|
defer func() { _ = client.Close() }()
|
|
|
|
ctx := context.Background()
|
|
counts, scanned, matched, deleted, err := cleanup(ctx, db, before, *batchSize, *execute)
|
|
if err != nil {
|
|
log.Fatalf("cleanup failed: %v", err)
|
|
}
|
|
|
|
digest := sha256.Sum256([]byte(classifierVersion))
|
|
mode := "dry-run"
|
|
if *execute {
|
|
mode = "execute"
|
|
}
|
|
fmt.Printf("mode=%s before=%s classifier=%s scanned=%d matched=%d deleted=%d\n",
|
|
mode, before.UTC().Format(time.RFC3339), hex.EncodeToString(digest[:]), scanned, matched, deleted)
|
|
reasons := make([]string, 0, len(counts))
|
|
for reason := range counts {
|
|
reasons = append(reasons, reason)
|
|
}
|
|
sort.Strings(reasons)
|
|
for _, reason := range reasons {
|
|
fmt.Printf("reason=%s count=%d\n", reason, counts[reason])
|
|
}
|
|
if *execute && deleted > 0 {
|
|
fmt.Println("cleanup complete; schedule VACUUM (ANALYZE) ops_error_logs during normal maintenance")
|
|
}
|
|
}
|
|
|
|
func cleanup(ctx context.Context, db *sql.DB, before time.Time, batchSize int, execute bool) (map[string]int64, int64, int64, int64, error) {
|
|
counts := make(map[string]int64)
|
|
var cursor, scanned, matched, deleted int64
|
|
for {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT id, COALESCE(status_code, 0), COALESCE(error_message, ''), COALESCE(error_body, '')
|
|
FROM ops_error_logs
|
|
WHERE id > $1
|
|
AND created_at < $2
|
|
AND error_phase = 'auth'
|
|
AND account_id IS NULL
|
|
AND upstream_status_code IS NULL
|
|
AND COALESCE(upstream_error_message, '') = ''
|
|
AND COALESCE(upstream_error_detail, '') = ''
|
|
ORDER BY id ASC
|
|
LIMIT $3`, cursor, before, batchSize)
|
|
if err != nil {
|
|
return nil, scanned, matched, deleted, err
|
|
}
|
|
|
|
batch := make([]candidate, 0, batchSize)
|
|
for rows.Next() {
|
|
var item candidate
|
|
if err := rows.Scan(&item.id, &item.statusCode, &item.message, &item.body); err != nil {
|
|
_ = rows.Close()
|
|
return nil, scanned, matched, deleted, err
|
|
}
|
|
batch = append(batch, item)
|
|
cursor = item.id
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return nil, scanned, matched, deleted, err
|
|
}
|
|
_ = rows.Close()
|
|
if len(batch) == 0 {
|
|
break
|
|
}
|
|
|
|
ids := make([]int64, 0, len(batch))
|
|
for _, item := range batch {
|
|
scanned++
|
|
if reason, ok := historicalIngressRejectReason(item); ok {
|
|
matched++
|
|
counts[reason]++
|
|
ids = append(ids, item.id)
|
|
}
|
|
}
|
|
if execute && len(ids) > 0 {
|
|
result, err := db.ExecContext(ctx,
|
|
`DELETE FROM ops_error_logs WHERE id = ANY($1) AND created_at < $2`, pq.Array(ids), before)
|
|
if err != nil {
|
|
return nil, scanned, matched, deleted, err
|
|
}
|
|
n, err := result.RowsAffected()
|
|
if err != nil {
|
|
return nil, scanned, matched, deleted, err
|
|
}
|
|
deleted += n
|
|
}
|
|
}
|
|
return counts, scanned, matched, deleted, nil
|
|
}
|
|
|
|
func historicalIngressRejectReason(item candidate) (string, bool) {
|
|
code, message := parseErrorIdentity(item.body, item.message)
|
|
switch code {
|
|
case "API_KEY_REQUIRED":
|
|
return "missing_key", true
|
|
case "INVALID_API_KEY":
|
|
return "invalid_key", true
|
|
case "API_KEY_DISABLED":
|
|
return "key_disabled", true
|
|
case "USER_INACTIVE":
|
|
return "user_inactive", true
|
|
case "GROUP_DELETED":
|
|
return "group_deleted", true
|
|
case "GROUP_DISABLED":
|
|
return "group_disabled", true
|
|
case "GROUP_NOT_ALLOWED":
|
|
return "group_forbidden", true
|
|
case "ACCESS_DENIED":
|
|
return "ip_acl_denied", true
|
|
case "api_key_in_query_deprecated":
|
|
return "query_key_deprecated", true
|
|
}
|
|
|
|
normalized := strings.TrimSpace(message)
|
|
switch {
|
|
case normalized == "API key is required":
|
|
return "missing_key", true
|
|
case normalized == "Invalid API key":
|
|
return "invalid_key", true
|
|
case normalized == "API key is disabled":
|
|
return "key_disabled", true
|
|
case normalized == "User account is not active":
|
|
return "user_inactive", true
|
|
case normalized == "API Key 所属分组已删除":
|
|
return "group_deleted", true
|
|
case normalized == "API Key 所属分组已停用":
|
|
return "group_disabled", true
|
|
case normalized == "API Key 所属专属分组不再允许当前用户使用":
|
|
return "group_forbidden", true
|
|
case normalized == "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.":
|
|
return "group_unassigned", true
|
|
case strings.HasPrefix(normalized, "Access denied. Your IP is "):
|
|
return "ip_acl_denied", true
|
|
case normalized == "Query parameter api_key is deprecated. Use Authorization header or key instead.":
|
|
return "query_key_deprecated", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func parseErrorIdentity(body, fallbackMessage string) (string, string) {
|
|
var payload struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Error struct {
|
|
Code json.RawMessage `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &payload); err != nil {
|
|
return "", fallbackMessage
|
|
}
|
|
message := payload.Message
|
|
if message == "" {
|
|
message = payload.Error.Message
|
|
}
|
|
if message == "" {
|
|
message = fallbackMessage
|
|
}
|
|
return strings.TrimSpace(payload.Code), message
|
|
}
|