File size: 3,387 Bytes
5fb8c4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
"""Admin 端点 IP 失败计数 + 临时封禁防护。

防止扫描器暴力探测 admin key:连续鉴权失败 N 次后临时封禁该 IP。
内存计数(进程级),重启清零,适合 HF Space 单实例场景。

用法:在鉴权失败时调用 record_failure(ip),在鉴权前调用 check_blocked(ip)。
"""
from __future__ import annotations

import logging
import time
from collections import defaultdict
from threading import Lock

logger = logging.getLogger(__name__)

# 配置:连续失败 5 次封禁 15 分钟
_FAIL_THRESHOLD = 5
_BAN_SECONDS = 900  # 15 分钟
# 超过此时间无新失败则重置计数(避免长期挂着的计数器)
_RESET_AFTER = 3600

_lock = Lock()
# ip -> {"fails": int, "last_fail_ts": float, "banned_until": float}
_records: dict[str, dict] = defaultdict(dict)


def check_blocked(ip: str) -> bool:
    """检查 IP 是否被临时封禁。返回 True 表示已封禁(应拒绝请求)。"""
    if not ip:
        return False
    with _lock:
        rec = _records.get(ip)
        if not rec:
            return False
        banned_until = rec.get("banned_until", 0)
        if banned_until > time.time():
            return True
        # 封禁已过期,重置
        if banned_until > 0 and banned_until <= time.time():
            rec.clear()
            logger.info("[admin_guard] IP %s ban expired, cleared", ip)
        return False


def record_failure(ip: str) -> None:
    """记录一次鉴权失败。达到阈值后封禁 IP。"""
    if not ip:
        return
    with _lock:
        rec = _records[ip]
        now = time.time()
        # 长时间无失败则重置计数
        last_ts = rec.get("last_fail_ts", 0)
        if last_ts and (now - last_ts > _RESET_AFTER):
            rec.clear()
        rec["fails"] = rec.get("fails", 0) + 1
        rec["last_fail_ts"] = now
        fails = rec["fails"]
        if fails >= _FAIL_THRESHOLD:
            rec["banned_until"] = now + _BAN_SECONDS
            logger.warning(
                "[admin_guard] IP %s banned for %ds after %d failures",
                ip, _BAN_SECONDS, fails,
            )


def record_success(ip: str) -> None:
    """鉴权成功时重置该 IP 的失败计数。"""
    if not ip:
        return
    with _lock:
        rec = _records.get(ip)
        if rec and rec.get("fails", 0) > 0:
            rec.clear()


def get_status() -> dict:
    """返回当前封禁状态快照(用于 admin 面板查看)。"""
    now = time.time()
    with _lock:
        banned = []
        for ip, rec in _records.items():
            bu = rec.get("banned_until", 0)
            if bu > now:
                banned.append({
                    "ip": ip,
                    "fails": rec.get("fails", 0),
                    "banned_until": int(bu),
                    "banned_remaining_sec": int(bu - now),
                })
        return {
            "banned_count": len(banned),
            "banned_ips": banned,
            "fail_threshold": _FAIL_THRESHOLD,
            "ban_seconds": _BAN_SECONDS,
        }


def clear_ban(ip: str) -> bool:
    """手动解除某 IP 的封禁。"""
    with _lock:
        rec = _records.get(ip)
        if rec and rec.get("banned_until", 0) > 0:
            rec.clear()
            logger.info("[admin_guard] IP %s ban manually cleared", ip)
            return True
        return False