Files

145 lines
7.0 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.
"""管控命令生成(参照深信服 AC 的应用控制 / 时间控制 / 流量控制,但 ROS-DPI 不写路由器)。
根据人员 / 设备、应用、时段生成 RouterOS 7 命令,用户复制到终端执行;每次生成一个标签,附带撤销命令。
- block_app:按 TLS 域名(tls-host)拒绝 HTTPS;QUIC(udp/443)看不到域名,可选整体禁止该设备的 QUIC 让应用回落到 TCP。
- block_all:断网(定时断网就是加上时段)。
- limit:简单队列限速(需要设备的 IPv4 地址;开了 FastTrack 的连接不经过简单队列)。
"""
import re
import time
from collections import defaultdict
from sqlalchemy import func, select
DAYS = {"1": "mon", "2": "tue", "3": "wed", "4": "thu", "5": "fri", "6": "sat", "7": "sun"}
MAX_DOMAINS = 40
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
_DOMAIN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$")
_MAC = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
def _q(s: str) -> str:
"""RouterOS 双引号字符串:设备名(DHCP 主机名,任何设备都能随便设)会进到这里,管理员粘贴执行时
"$[...]" 会被 RouterOS 当命令执行、换行会另起一条命令——所以去掉控制字符,并转义反斜杠、双引号和 $"""
s = _CTRL.sub("", str(s))
return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"'
def _time_parts(days: str, t_from: str, t_to: str) -> list[str]:
"""RouterOS 防火墙 time= 参数;跨午夜拆成两段"""
if (t_from, t_to) in (("00:00", "24:00"), ("00:00", "00:00")) and len(days) == 7:
return [""]
d = ",".join(DAYS[x] for x in sorted(set(days)) if x in DAYS)
def hm(x):
return "1d" if x == "24:00" else f"{int(x[:2])}h{int(x[3:]):02d}m"
if t_to == "00:00":
t_to = "24:00"
if t_from < t_to:
return [f"{hm(t_from)}-{hm(t_to)},{d}"]
return [f"{hm(t_from)}-1d,{d}", f"0s-{hm(t_to)},{d}"]
def app_domains(db, apps: list[str], groups: list[str]) -> list[str]:
"""要封禁的域名:识别库里这些应用的域名,加上你的网络里最近 30 天实际出现过的(按流量排前面)"""
from app import stats, timeutil
from app.engine import engine
from app.models import HostDomainDay
cl = engine.classifier
want = set(apps)
for a in cl.apps.values():
c = cl.category_info(a.category)
if a.category in groups or c["group"] in groups:
want.add(a.id)
seen: dict[str, int] = defaultdict(int)
start = timeutil.range_bounds("last30")[0]
t = HostDomainDay
for dom, b in db.execute(select(t.domain, func.sum(t.bytes)).where(t.ts >= start, t.app.in_(list(want) or ["-"]))
.group_by(t.domain)):
if not stats.is_ip(dom):
seen[stats.base_domain(dom)] += int(b)
lib = [d for d, a in list(cl.domains.items()) + list(cl.full.items()) if a in want]
# 域名来自 DNS 嗅探(内网设备可以伪造应答),不加引号拼进 tls-host=,只收规范的主机名
seen = {d: b for d, b in seen.items() if len(d) <= 253 and _DOMAIN.match(d)}
out = sorted(seen, key=lambda d: -seen[d])
for d in sorted(lib, key=len):
if len(out) >= MAX_DOMAINS:
break
if d not in out and not any(d.endswith("." + x) for x in out):
out.append(d)
return out[:MAX_DOMAINS]
def build(db, targets: list[dict], action: str, apps: list[str], groups: list[str], days: str, t_from: str, t_to: str,
block_quic: bool = True, up_mbps: float = 0, down_mbps: float = 0, label: str = "") -> dict:
tag = "ROSDPI-CTRL " + time.strftime("%m%d%H%M%S")
note = f"{tag} {label}".strip()
lines, undo, notes = [], [], []
match = []
for h in targets:
mac, ip = (h.get("mac") or "").upper(), h.get("ip") or ""
if mac and _MAC.match(mac):
match.append((f"src-mac-address={mac}", h))
elif ip and _is_ip(ip):
match.append((f"src-address={ip}", h))
if not match:
raise ValueError("所选设备都没有 MAC 或 IP,无法生成规则")
times = _time_parts(days, t_from, t_to)
tparam = lambda tp: f" time={tp}" if tp else "" # noqa: E731
if action in ("block_app", "block_all"):
lines.append("/ip firewall filter")
if action == "block_app":
doms = app_domains(db, apps, groups)
if not doms:
raise ValueError("没有找到这些应用的域名")
for m, h in match:
for tp in times:
for d in doms:
lines.append(f"add chain=forward {m} protocol=tcp dst-port=443 tls-host=*{d}{tparam(tp)} "
f"action=reject reject-with=tcp-reset place-before=0 comment={_q(note)}")
if block_quic:
lines.append(f"add chain=forward {m} protocol=udp dst-port=443{tparam(tp)} action=drop "
f"place-before=0 comment={_q(note)}")
notes.append(f"按 TLS 域名封禁 {len(doms)} 个域名:{', '.join(doms[:12])}{' …' if len(doms) > 12 else ''}")
if block_quic:
notes.append("同时在该时段禁止这些设备的 QUIC(udp/443),应用会改用 TCP,从而能按域名拦截。")
notes.append("tls-host 只认 HTTPS 握手里的域名;应用改用 IP 直连或私有协议时拦不住。")
else:
for m, h in match:
for tp in times:
lines.append(f"add chain=forward {m}{tparam(tp)} action=drop place-before=0 comment={_q(note)}")
undo.append(f"/ip firewall filter remove [find comment={_q(note)}]")
notes.append("规则放在最前面(place-before=0),在 FastTrack 之前生效;已经建立的连接不受影响,可在 IP → Firewall → Connections 里清掉。")
elif action == "limit":
if not (up_mbps or down_mbps):
raise ValueError("请填写限速值")
lines.append("/queue simple")
for _m, h in match:
ip = h.get("ip") or ""
if not re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
notes.append(f"{h.get('name')} 没有 IPv4 地址,跳过")
continue
lines.append(f"add name={_q(note + ' ' + (h.get('name') or ip))} target={ip}/32 "
f"max-limit={_rate(up_mbps)}/{_rate(down_mbps)} comment={_q(note)}")
undo.append(f"/queue simple remove [find comment={_q(note)}]")
notes.append("简单队列按 IP 限速:设备换了 IP 要重新生成;开了 FastTrack 时被加速的连接不受队列限制。")
else:
raise ValueError("未知的动作")
return {"tag": tag, "script": "\n".join(lines), "undo": "\n".join(undo), "notes": notes,
"targets": [h.get("name") for _m, h in match]}
def _is_ip(s: str) -> bool:
import ipaddress
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
def _rate(mbps: float) -> str:
return "0" if not mbps else f"{int(mbps * 1000)}k"