feat: Prism HTTP/SOCKS5 代理网关及管理台
CI / docker (push) Successful in 2m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 15:58:28 +08:00
commit fe8ea784d0
170 changed files with 20056 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
const APIKeyPrefix = "prism_"
func GenerateAPIKey() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return APIKeyPrefix + hex.EncodeToString(b[:]), nil
}
func HashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func APIKeyDisplayPrefix(key string) string {
key = strings.TrimSpace(key)
if len(key) <= 12 {
return key
}
return key[:12] + "…"
}
func IsAPIKeyFormat(key string) bool {
return strings.HasPrefix(strings.TrimSpace(key), APIKeyPrefix)
}
func ValidateAPIKeyFormat(key string) error {
key = strings.TrimSpace(key)
if !strings.HasPrefix(key, APIKeyPrefix) {
return fmt.Errorf("invalid api key format")
}
raw := strings.TrimPrefix(key, APIKeyPrefix)
if len(raw) != 32 {
return fmt.Errorf("invalid api key length")
}
if _, err := hex.DecodeString(raw); err != nil {
return fmt.Errorf("invalid api key encoding")
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import "testing"
func TestGenerateAndHashAPIKey(t *testing.T) {
key, err := GenerateAPIKey()
if err != nil {
t.Fatal(err)
}
if err := ValidateAPIKeyFormat(key); err != nil {
t.Fatal(err)
}
if HashAPIKey(key) == "" {
t.Fatal("empty hash")
}
if HashAPIKey(key) != HashAPIKey(key) {
t.Fatal("hash not stable")
}
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"context"
"fmt"
"strconv"
"time"
"github.com/prism/proxy/internal/store"
)
const (
defaultMaxAttempts = 5
defaultLockMinutes = 15
)
type LoginConfig struct {
MaxAttempts int
Window time.Duration
LockDuration time.Duration
}
type LoginResult struct {
Allowed bool
Locked bool
Remaining int
RetryAfter time.Duration
}
type LoginLimiter struct {
store *store.Store
}
func NewLoginLimiter(st *store.Store) *LoginLimiter {
return &LoginLimiter{store: st}
}
func ParseLoginConfig(settings map[string]string) LoginConfig {
maxAttempts := parsePositiveInt(settings["login_max_attempts"], defaultMaxAttempts)
lockMin := parsePositiveInt(settings["login_lock_minutes"], defaultLockMinutes)
lock := time.Duration(lockMin) * time.Minute
return LoginConfig{
MaxAttempts: maxAttempts,
Window: lock,
LockDuration: lock,
}
}
func (l *LoginLimiter) Check(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
attempt, err := l.store.GetLoginAttempt(ctx, ip)
if err != nil {
return LoginResult{}, err
}
if attempt == nil {
return LoginResult{Allowed: true, Remaining: cfg.MaxAttempts}, nil
}
now := time.Now()
if attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
return LoginResult{
Allowed: false,
Locked: true,
RetryAfter: attempt.LockedUntil.Sub(now),
}, nil
}
remaining := cfg.MaxAttempts - attempt.FailCount
if remaining < 0 {
remaining = 0
}
if attempt.FailCount > 0 && now.Sub(attempt.WindowStart) > cfg.Window {
remaining = cfg.MaxAttempts
}
return LoginResult{Allowed: true, Remaining: remaining}, nil
}
func (l *LoginLimiter) RecordFailure(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
now := time.Now()
attempt, err := l.store.GetLoginAttempt(ctx, ip)
if err != nil {
return LoginResult{}, err
}
if attempt != nil && attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
return LoginResult{
Allowed: false,
Locked: true,
RetryAfter: attempt.LockedUntil.Sub(now),
}, nil
}
failCount := 1
windowStart := now
if attempt != nil {
if attempt.LockedUntil != nil || now.Sub(attempt.WindowStart) <= cfg.Window {
if now.Sub(attempt.WindowStart) <= cfg.Window {
failCount = attempt.FailCount + 1
windowStart = attempt.WindowStart
}
}
}
var lockedUntil *time.Time
locked := false
if failCount >= cfg.MaxAttempts {
t := now.Add(cfg.LockDuration)
lockedUntil = &t
locked = true
_ = l.store.AddAuditLog(ctx, "login_lock", "auth", fmt.Sprintf("ip=%s locked %v", ip, cfg.LockDuration))
}
if err := l.store.SetLoginAttempt(ctx, ip, store.LoginAttempt{
FailCount: failCount,
WindowStart: windowStart,
LockedUntil: lockedUntil,
}); err != nil {
return LoginResult{}, err
}
remaining := cfg.MaxAttempts - failCount
if remaining < 0 {
remaining = 0
}
res := LoginResult{Allowed: !locked, Remaining: remaining, Locked: locked}
if locked && lockedUntil != nil {
res.RetryAfter = lockedUntil.Sub(now)
}
return res, nil
}
func (l *LoginLimiter) RecordSuccess(ctx context.Context, ip string) error {
return l.store.ClearLoginAttempt(ctx, ip)
}
func parsePositiveInt(raw string, fallback int) int {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return fallback
}
return n
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/prism/proxy/internal/store"
)
func TestLoginLimiterLockAndClear(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "data"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
limiter := NewLoginLimiter(st)
cfg := LoginConfig{MaxAttempts: 3, Window: time.Minute, LockDuration: 2 * time.Minute}
ip := "203.0.113.1"
for i := 0; i < 2; i++ {
res, err := limiter.RecordFailure(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if res.Locked {
t.Fatalf("unexpected lock at attempt %d", i+1)
}
if res.Remaining != cfg.MaxAttempts-i-1 {
t.Fatalf("remaining=%d want %d", res.Remaining, cfg.MaxAttempts-i-1)
}
}
res, err := limiter.RecordFailure(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if !res.Locked || res.RetryAfter <= 0 {
t.Fatalf("expected lock, got %+v", res)
}
check, err := limiter.Check(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if check.Allowed {
t.Fatal("expected blocked while locked")
}
if err := limiter.RecordSuccess(ctx, ip); err != nil {
t.Fatal(err)
}
check, err = limiter.Check(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if !check.Allowed || check.Remaining != cfg.MaxAttempts {
t.Fatalf("expected reset, got %+v", check)
}
}