Files

261 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""debug12:落库失败不丢数据、分批清理、登录限速、token 版本吊销、访问目标 SQL 聚合。"""
import time
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, insert, select
from app import database, security, stats, timeutil
from app.database import upsert_add
from app.engine import Engine
from app.main import app
from app.models import AccessLog, HostDomainDay, TrafficMinute
from packets import ipfix, tzsp_dns
@pytest.fixture
def login_fails_cleared():
"""限速状态是进程级内存,进出测试都要清干净,避免影响其它测试的登录。"""
security._login_fails.clear()
security._user_fails.clear()
yield
security._login_fails.clear()
security._user_fails.clear()
def _engine():
e = Engine()
e.set_lan("192.168.88.0/24,2408:8000:1:2::/64")
return e
def test_flush_failure_keeps_data_for_retry(monkeypatch):
"""数据库抖动时这一分钟的数据不能丢:失败留在内存,下次 flush 一起写入。"""
e = _engine()
e.on_tzsp(tzsp_dns("192.168.88.10", "www.bilibili.com", ["5.5.5.5"]))
e.on_ipfix(ipfix([dict(src="192.168.88.10", dst="5.5.5.5", sport=1, dport=443, proto=6, nbytes=1000,
mac="AA:00:00:00:00:10")]), "10.0.0.1")
now = time.time()
def boom():
raise RuntimeError("db down")
monkeypatch.setattr(database, "session", boom)
e.flush(now)
assert e._pending and ("AA:00:00:00:00:10", "bilibili") in e._pending[0]["buf"]
monkeypatch.undo()
e.flush(now + 60)
assert e._pending is None
with database.session() as db:
t = stats.total(db, *timeutil.range_bounds("today"), host="AA:00:00:00:00:10")
assert t["total"] == 1000
n = db.query(AccessLog).filter(AccessLog.host == "AA:00:00:00:00:10").count()
assert n >= 1 # 访问日志也要补写上
def test_cleanup_batched():
"""按保留期清理:旧的分批删掉,新的留下(TrafficMinute 走复合主键的批量删除路径)。"""
now = int(time.time())
old_min, new_min = now - 3 * 86400, now - 60
with database.session() as db:
db.execute(insert(TrafficMinute.__table__), [
{"ts": old_min + i, "host": f"OLD:{i}", "app": "web", "up": 1, "down": 1, "flows": 1} for i in range(8)
] + [{"ts": new_min, "host": "NEW", "app": "web", "up": 1, "down": 1, "flows": 1}])
db.execute(insert(AccessLog.__table__), [
{"id": 9_000_000_000 + i, "ts_start": now - 40 * 86400, "ts_end": now - 40 * 86400 + 10,
"host": f"OLD:{i}", "host_ip": "192.168.88.1", "dest": "x.com", "app": "web", "conns": 1, "up": 1, "down": 1}
for i in range(6)
] + [{"id": 9_000_000_100, "ts_start": now - 10, "ts_end": now, "host": "NEW", "host_ip": "192.168.88.2",
"dest": "y.com", "app": "web", "conns": 1, "up": 1, "down": 1}])
db.commit()
_engine().cleanup()
with database.session() as db:
assert db.query(TrafficMinute).filter(TrafficMinute.host.like("OLD:%")).count() == 0
assert db.query(TrafficMinute).filter(TrafficMinute.host == "NEW").count() == 1
assert db.query(AccessLog).filter(AccessLog.host.like("OLD:%")).count() == 0
assert db.query(AccessLog).filter(AccessLog.host == "NEW").count() == 1
def test_destinations_sql_aggregation():
"""访问目标排行:聚合在 SQL 侧完成,合并 / 域名 IP 筛选 / 搜索行为与原实现一致。
用例里用独立的域名,避免其它测试往同一张表写的数据影响断言。"""
day = timeutil.day_bucket(time.time())
rows = [
{"ts": day, "host": "H1", "domain": "a.d12test.example", "app": "bilibili", "bytes": 100,
"base": "d12test.example"},
{"ts": day, "host": "H1", "domain": "www.d12test.example", "app": "bilibili", "bytes": 300,
"base": "d12test.example"},
{"ts": day, "host": "H2", "domain": "www.d12test.example", "app": "bilibili", "bytes": 50,
"base": "d12test.example"},
{"ts": day, "host": "H1", "domain": "203.0.113.9", "app": "https", "bytes": 700, "base": "203.0.113.9"},
]
with database.session() as db:
upsert_add(db, HostDomainDay.__table__, rows, ["ts", "host", "domain"], ["bytes"], ["app", "base"])
db.commit()
s, t = timeutil.range_bounds("today")
merged = {r["dest"]: r for r in stats.destinations(db, s, t, merge=True)}
assert merged["d12test.example"]["bytes"] == 450
assert merged["d12test.example"]["hosts"] == 2
assert merged["d12test.example"]["names"] == 2 # 两个子域名
assert merged["d12test.example"]["app"]["id"] == "bilibili"
assert merged["203.0.113.9"]["ip"] is True
# 不合并:每个子域名 / IP 各自一行
plain = {r["dest"]: r for r in stats.destinations(db, s, t)}
assert plain["a.d12test.example"]["bytes"] == 100 and plain["203.0.113.9"]["bytes"] == 700
assert [r["dest"] for r in stats.destinations(db, s, t, merge=True, kind="ip")
if r["dest"] == "203.0.113.9"] == ["203.0.113.9"]
doms = stats.destinations(db, s, t, kind="domain")
assert doms and all(not r["ip"] for r in doms)
assert "203.0.113.9" not in {r["dest"] for r in doms}
assert [r["dest"] for r in stats.destinations(db, s, t, q="d12test", merge=True)] == ["d12test.example"]
assert stats.destinations(db, s, t, q="no-such-thing-d12") == []
def test_backfill_base_domain():
"""升级回填:旧版本写入的 host_domain_day 行(base 为空)补上主域名。"""
day = timeutil.day_bucket(time.time())
with database.session() as db:
db.execute(insert(HostDomainDay.__table__),
[{"ts": day, "host": "H9", "domain": "cdn.example.com.cn", "app": "web", "bytes": 5}])
db.commit()
Engine._backfill_base(db)
row = db.get(HostDomainDay, (day, "H9", "cdn.example.com.cn"))
assert row.base == "example.com.cn"
merged = stats.destinations(db, *timeutil.range_bounds("today"), merge=True)
assert any(r["dest"] == "example.com.cn" for r in merged)
def test_login_rate_limit(login_fails_cleared):
"""同一 IP + 用户名连续失败 5 次后锁定 15 分钟,密码正确也进不来;其它账号不受影响。"""
with TestClient(app) as c:
if not c.get("/api/auth/status").json()["initialized"]:
c.post("/api/auth/setup", json={"username": "admin", "password": "secret1"})
ha = {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "admin", "password": "secret1"}).json()["token"]}
assert c.post("/api/auth/users", json={"username": "lockme", "password": "lockpass1", "role": "user"},
headers=ha).status_code == 200
for _ in range(5):
assert c.post("/api/auth/login", json={"username": "lockme", "password": "wrong"}).status_code == 401
# 第 6 次即使密码正确也被锁定
assert c.post("/api/auth/login", json={"username": "lockme", "password": "lockpass1"}).status_code == 429
# 其它用户名不受影响
assert c.post("/api/auth/login", json={"username": "admin", "password": "secret1"}).status_code == 200
security._login_fails.clear()
security._user_fails.clear()
assert c.post("/api/auth/login", json={"username": "lockme", "password": "lockpass1"}).status_code == 200
def test_token_invalidated_on_password_or_role_change(login_fails_cleared):
"""改密码 / 管理员改权限后,已签发的旧 token 立即失效。"""
with TestClient(app) as c:
if not c.get("/api/auth/status").json()["initialized"]:
c.post("/api/auth/setup", json={"username": "admin", "password": "secret1"})
ha = {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "admin", "password": "secret1"}).json()["token"]}
r = c.post("/api/auth/users", json={"username": "bob", "password": "bobpass1", "role": "user"}, headers=ha)
assert r.status_code == 200, r.text
uid = r.json()["id"]
hb = {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "bob", "password": "bobpass1"}).json()["token"]}
assert c.get("/api/auth/me", headers=hb).status_code == 200
# bob 自己改密码 → 旧 token 失效(角色缓存清掉,立即生效)
assert c.post("/api/auth/password", json={"old_password": "bobpass1", "new_password": "bobpass2"},
headers=hb).status_code == 200
assert c.get("/api/auth/me", headers=hb).status_code == 401
hb = {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "bob", "password": "bobpass2"}).json()["token"]}
# 管理员提权 → bob 的旧 token 失效
assert c.put(f"/api/auth/users/{uid}", json={"role": "admin"}, headers=ha).status_code == 200
assert c.get("/api/auth/me", headers=hb).status_code == 401
assert c.delete(f"/api/auth/users/{uid}", headers=ha).status_code == 200
security.forget_roles()
def test_backfill_base_and_startup_settings():
"""0.6.0/0.6.1 的主域名回填会抛错,导致启动时外部数据源、自定义规则都没加载(debug12 第 7 条)"""
from app import integrations
from app.main import _on_db_ready
from app.models import Setting
with database.session() as db:
db.execute(insert(HostDomainDay), [
{"ts": 1790000000, "host": "AA:00:00:00:12:01", "domain": "a.b.example.com", "app": "https", "bytes": 1, "base": None},
{"ts": 1790000000, "host": "AA:00:00:00:12:01", "domain": "x.y.example.org", "app": "https", "bytes": 1, "base": None},
])
for k, v in (("singbox_url", "http://192.0.2.10:9090"), ("mosdns_url", "http://192.0.2.11:9099")):
row = db.get(Setting, k) or Setting(key=k)
row.value = v
db.merge(row)
db.commit()
try:
_on_db_ready()
with database.session() as db:
got = dict(db.execute(select(HostDomainDay.domain, HostDomainDay.base)
.where(HostDomainDay.host == "AA:00:00:00:12:01")).all())
assert got == {"a.b.example.com": "example.com", "x.y.example.org": "example.org"}
assert integrations.singbox.enabled and integrations.mosdns.enabled
finally:
with database.session() as db:
db.execute(delete(HostDomainDay).where(HostDomainDay.host == "AA:00:00:00:12:01"))
for k in ("singbox_url", "mosdns_url"):
row = db.get(Setting, k)
if row:
db.delete(row)
db.commit()
integrations.apply({})
def test_flush_long_hostname_and_bad_rows(monkeypatch):
"""全站审计 P0:DHCP 主机名超过 128 字符(MySQL 严格模式会拒绝整批)要截断;
数据本身有问题时逐段写入,其它段照常入库,不再整批重试 2 小时后丢掉"""
from sqlalchemy.exc import DataError
from app.models import Host, TrafficDay
e = _engine()
e.mac2name["AA:00:00:00:00:77"] = "x" * 300
e.on_ipfix(ipfix([dict(src="192.168.88.77", dst="5.5.5.6", sport=1, dport=443, proto=6, nbytes=500,
mac="AA:00:00:00:00:77")]), "10.0.0.1")
orig = e._w_geo
def bad_geo(db, p):
raise DataError("INSERT", {}, Exception("1406 Data too long"))
monkeypatch.setattr(e, "_w_geo", bad_geo)
e.flush(time.time())
assert not e._pending # 坏的那段被丢弃,不再留着重试
with database.session() as db:
h = db.get(Host, "AA:00:00:00:00:77")
assert h is not None and len(h.hostname) == 128
assert db.query(TrafficDay).filter(TrafficDay.host == "AA:00:00:00:00:77").count() == 1
monkeypatch.setattr(e, "_w_geo", orig)
def test_fit_truncates_by_column_length():
from app.models import AccessLog
rows = database.fit(AccessLog.__table__, [{"dest": "d" * 400, "host": "h"}])
assert len(rows[0]["dest"]) == 255 and rows[0]["host"] == "h"
def test_login_lock_not_bypassed_by_forwarded_header():
"""全站审计:换 X-Forwarded-For 不能绕过登录锁定;按用户名的总失败数也会锁"""
from fastapi.testclient import TestClient
from app.main import app
security._login_fails.clear()
security._user_fails.clear()
with TestClient(app) as c:
codes = [c.post("/api/auth/login", json={"username": "nobody-x", "password": "bad"},
headers={"X-Forwarded-For": f"1.2.3.{i}"}).status_code for i in range(8)]
assert codes[:5] == [401] * 5 and 429 in codes[5:]
security._login_fails.clear()
security._user_fails.clear()
def test_control_script_escapes_hostname():
"""全站审计:设备名进 RouterOS 脚本时去掉换行、转义 $,防止管理员粘贴执行时被注入命令"""
from app import control
q = control._q("pc$[/user add name=x group=full]\n/system reset")
assert "\n" not in q and "\$[" in q
assert not control._DOMAIN.match("c;/user add name=x")