Files

238 lines
12 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.
import asyncio
import os
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__, database, integrations
from app.asn import asndb
from app.collector import start_listeners
from app.config import get_settings
from app.engine import engine
from app.models import Setting
from app.ros_poller import poller
from app import audit # noqa: F401 挂上审计(engine.audit)
from app.routers import admin, auth, setup, stats
from app.routers import share as share_router
from app.routers import audit as audit_router
from app.routers import compliance as compliance_router
from app.routers import people as people_router
from app import devtype
from app.security import ensure_admin
FRONTEND_DIR = os.environ.get("FRONTEND_DIR", os.path.join(os.path.dirname(__file__), "..", "frontend_dist"))
def _safe(fn, name):
def run():
try:
fn()
except Exception as e: # noqa: BLE001
print(f"[job] {name} 失败:{type(e).__name__}: {e}")
return run
def _apply_saved():
with database.session() as db:
wan = db.get(Setting, "wan_interface")
poller.wan_override = wan.value if wan and wan.value else ""
saved = {k: (db.get(Setting, k).value if db.get(Setting, k) else None) for k in integrations.KEYS}
integrations.apply(saved)
def _on_db_ready():
"""数据库连接成功后(启动时或安装向导保存后)执行。各步互不依赖:一步出错只记日志,不挡住后面的
(0.6.0 / 0.6.1 的主域名回填出错时,后面的外部数据源配置、自定义规则都没加载,外部数据源显示“未启用”)"""
ensure_admin() # 建管理员失败要报出来(安装向导靠这个提示)
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved),
("local_db", _refresh_local), ("share_lib", _share_startup)):
try:
fn()
except Exception as e: # noqa: BLE001
print(f"[startup] {name} 失败:{type(e).__name__}: {e}", flush=True)
print("[startup] 数据库已连接", flush=True)
def _refresh_local():
"""本地自学习库(debug15):从 host_domain_day 重建 IP→应用 长期记忆(启动 + 每小时),热加载"""
if not database.is_configured():
return
from app.classify.local import local
with database.session() as db:
local.load(db, engine.classifier)
db.commit()
def _auto_update_on() -> bool:
"""识别规则页的「自动更新」开关(默认开启):管 v2fly、ASN 库和补充库的定期更新"""
if not database.is_configured():
return False
with database.session() as db:
row = db.get(Setting, "rules_auto_update")
return row is None or row.value != "0"
def _share_cycle():
"""社区共享库:普通实例向社区主站心跳、上报、拉库;主站在进程内聚合(见 app/share.py)"""
if not database.is_configured():
return
from app import share
with database.session() as db:
res = share.cycle(engine, db)
db.commit()
if res:
print(f"[share] 社区共享已同步{'(本实例是社区主站)' if share.is_hub() else ''}:已部署 {res['total']} 台"
f"(在线 {res['online']}),社区库 {res['community_ips'] + res['community_domains']} 条", flush=True)
def _share_startup():
"""启动时先用上次缓存的社区库,再在后台跑第一轮同步(不挡住启动)"""
from app import share
if not share.is_hub():
share.load_cached_library()
share.kick(_safe(_share_cycle, "share"))
def _auto_update_rules():
"""v2fly 规则自动更新;更新器内部保证一天最多检查一次"""
if _auto_update_on():
from app.classify.updater import updater
updater.check(reload=engine.reload_classifier)
def _auto_reclassify():
"""识别库更新或过了零点后自动重算历史分类(debug14):指纹没变就不动,变了才重算"""
if not _auto_update_on():
return
from app.reclass import auto_reclassify
with database.session() as db:
res = auto_reclassify(engine, db)
db.commit()
if res:
print(f"[reclass] 识别规则有更新,已自动重算历史分类:搬移 {res['bytes_moved'] / 1e6:.1f} MB"
f"({res['seconds']}s)", flush=True)
def _auto_update_db(fn):
"""ASN / 补充库的定期下载跟随同一个开关;关掉后只用已下载的数据,手动「立即检查更新」仍然可用"""
def run():
if _auto_update_on():
fn()
return run
def _reconnect():
# 有配置但连接断开 / 启动时没连上:后台定期重试,不需要用户重走安装向导
if not database.is_configured() and database.configured_url():
database.connect_from_saved(force=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
s = get_settings()
database.add_ready_hook(_on_db_ready)
if database.configured_url():
# 放到后台连接,网页可以立刻打开(MySQL 未就绪时会重试约 1 分钟,之后由 _reconnect 接手)
asyncio.get_running_loop().run_in_executor(None, lambda: database.connect_from_saved(force=True, retries=20))
else:
print("[startup] 未配置数据库,请在网页安装向导中填写 MySQL 信息", flush=True)
transports = await start_listeners(engine, s.ipfix_port, s.tzsp_port)
sched = BackgroundScheduler(timezone=s.tz)
sched.add_job(_safe(engine.flush, "flush"), "cron", second=0, max_instances=1, coalesce=True)
sched.add_job(_safe(audit.auditor.flush_deferred, "notify-deferred"), "cron", second=30, max_instances=1, coalesce=True)
sched.add_job(_safe(engine.cleanup, "cleanup"), "cron", minute=17, max_instances=1)
sched.add_job(_safe(_reconnect, "db-reconnect"), "interval", seconds=30, max_instances=1, coalesce=True)
# 外部数据源:没配置时 poll() 直接返回
sched.add_job(_safe(integrations.singbox.poll, "singbox"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(integrations.mosdns.poll, "mosdns"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(integrations.adguard.poll, "adguard"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(_refresh_local, "local-db"), "interval", hours=1, max_instances=1, coalesce=True)
sched.add_job(_safe(_share_cycle, "share"), "interval", minutes=30, max_instances=1, coalesce=True)
# 识别库更新 / 过了零点后的自动重算:指纹没变时开销只有一次设置查询。启动后等 15 分钟再跑第一次,
# 第三方库、ASN、国内列表是在后台线程加载的,不能用没加载完的识别器去算历史
sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=1, max_instances=1, coalesce=True,
next_run_time=datetime.now(sched.timezone) + timedelta(minutes=15))
sched.add_job(_safe(_auto_update_rules, "rules-update"), "interval", hours=6, max_instances=1, coalesce=True)
# 审计报表:日报每天 08:00 推送昨天的,周报每周一 08:30 推送近 7 天的(在审计 → 报表里开启)
sched.add_job(_safe(lambda: audit.push_report("daily"), "report-daily"), "cron", hour=8, minute=0)
# 终端类型:每小时按主机名、MAC 厂商、最近用过的应用重新识别;MAC 厂商库每 30 天更新
sched.add_job(_safe(devtype.refresh_all, "devtype"), "cron", minute=7, max_instances=1, coalesce=True)
sched.add_job(_safe(devtype.oui.update if s.oui_db else devtype.oui.load, "oui-db"), "interval", hours=24,
max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, _safe(devtype.startup, "devtype-startup"))
sched.add_job(_safe(lambda: audit.push_report("weekly"), "report-weekly"), "cron", day_of_week="mon", hour=8, minute=30)
asyncio.get_running_loop().call_later(120, lambda: asyncio.get_running_loop().run_in_executor(
None, _safe(_auto_update_rules, "rules-update")))
if s.asn_db:
sched.add_job(_safe(_auto_update_db(asndb.update), "asn-db"), "interval", hours=6, max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, _safe(asndb.update, "asn-db"))
# 补充识别库:国内域名列表 + 官方 IP 段(sources.py,只做兜底;没配置的来源直接跳过)
from app.classify.sources import sources as extra_sources
if s.china_db or s.iplist_db:
# 每 6 小时看一次,文件满一天才重新下载(间隔和过期时间都是 24 小时会因几秒误差拖成两天)
sched.add_job(_safe(_auto_update_db(extra_sources.update), "extra-db"), "interval", hours=6,
max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, _safe(extra_sources.update, "extra-db"))
# 第三方规则库:blackmatrix7(应用级)、Hagezi / UT1(分类级),见 classify/lists.py
from app.classify.lists import lists as rule_lists
if s.bm7_db or s.hagezi_db or s.ut1_db:
sched.add_job(_safe(_auto_update_db(rule_lists.update), "rule-lists"), "interval", hours=6,
max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, _safe(rule_lists.update, "rule-lists"))
if poller.enabled:
sched.add_job(_safe(poller.sample, "ros-rate"), "interval", seconds=max(s.ros_rate_seconds, 2),
max_instances=1, coalesce=True)
sched.add_job(_safe(poller.poll, "ros-poll"), "interval", seconds=s.ros_poll_seconds,
max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, poller.poll)
if not os.environ.get("ROSDPI_NO_JOBS"): # 测试里不跑后台任务,避免整点落库打乱测试数据
sched.start()
print(f"[startup] ROS-DPI {__version__} 已启动,Web 端口 {s.http_port}", flush=True)
from app.security import setup_code
print(f"[startup] 首次设置的安装码:{setup_code()}(只有从公网地址打开安装向导时才需要;内网访问不用)", flush=True)
yield
if sched.running:
sched.shutdown(wait=False) # 不等长任务(历史重算可能跑几分钟);落库之间由 engine 的落库锁串行
for t in transports:
t.close()
_safe(lambda: engine.flush(final=True), "flush")() # 关闭前把还没结束的访问也写进访问日志
app = FastAPI(title="ROS-DPI", version=__version__, lifespan=lifespan)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception):
# 详情只进日志;返回给客户端的只有异常类型,避免带出内部路径 / SQL 等信息
print(f"[error] {request.method} {request.url.path}: {exc!r}")
return JSONResponse(status_code=500, content={"detail": f"服务器内部错误:{type(exc).__name__}"})
for r in (setup.router, auth.router, stats.router, admin.router, audit_router.router, people_router.router,
compliance_router.router, share_router.router):
app.include_router(r)
@app.get("/api/health")
def health():
return {"ok": True, "version": __version__}
if os.path.isdir(FRONTEND_DIR):
app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets")
@app.get("/{path:path}", include_in_schema=False)
def spa(path: str):
root = os.path.realpath(FRONTEND_DIR)
f = os.path.realpath(os.path.join(root, path))
if path and f.startswith(root + os.sep) and os.path.isfile(f):
return FileResponse(f)
if path.startswith("api/"):
return JSONResponse(status_code=404, content={"detail": "Not Found"})
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))