上一版把 arm64 适配的代码推了上去,但没有重打产物 —— Release 上挂的
三件套仍是旧的,不含那次修复。这一版把产物补齐,并首次发布 arm64 的两份。
改的就一件事:版本号 1.0.10 → 1.0.11(packaging/VERSION 是唯一真源,
同步 backend/drouter-web.py 的 VERSION_FALLBACK 与 drouter-update.py 的兜底值)。
⚠️ 全仓 grep '1.0.10' 会命中几十处,绝大多数是「1.0.10 修 / 1.0.10 起」这类
历史注释,指的是那个版本做过的事,**一个都没动** —— 改了等于篡改历史。
README 更新日志补 v1.0.11 段,写明这次适配的实测机型与分架构产物清单:
本体 drouter_1.0.11_all.deb Architecture: all,各 CPU 通用
离线包 drouter-1.0.11-offline-amd64.tar.gz / -arm64.tar.gz
镜像 drouter-1.0.11-docker.tar / drouter-1.0.11-arm64-docker.tar
关于「amd64 有没有实质更新」:代码逻辑上没有 ——
· _read_bios() 新增的设备树分支在 x86 上不触发(有 EFI/DMI 时走原分支,
结果与改动前逐字节一致,只是多返回 fw_type / is_dt / compat 三个字段);
· _deb_arch() 在 amd64 上返回 amd64,拼出的离线包名与原写死的完全相同。
但「只重打 arm」这条路走不通,两个硬约束:
1. deb 是 Architecture: all 的**单包**,不存在「arm 版 deb」;
2. 离线包里内含本体 deb,本体一变,amd64 那份离线包装出来的就是旧 deb。
所以这次是全量重打(deb 1 + 离线包 2 + 镜像 2)。
3031 lines
145 KiB
Python
3031 lines
145 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
drouter-web —— 轻量路由管理后端
|
||
|
||
设计目标(2 核 / 3.9G 内存环境):
|
||
* 仅使用 Python 3 标准库,零第三方依赖,零 pip
|
||
* 常驻内存目标 < 30 MB
|
||
* 单进程多线程(ThreadingHTTPServer),无框架
|
||
* 内置 HTTPS(自签证书自动生成)
|
||
* 所有 root 操作经由 sudo -n drouter-helper 白名单脚本完成
|
||
* 配置存 SQLite;每次应用渲染出原生配置文件
|
||
|
||
启动:python3 /opt/drouter/backend/drouter-web.py
|
||
"""
|
||
import os
|
||
import sys
|
||
import re
|
||
import ssl
|
||
import json
|
||
import time
|
||
import sqlite3
|
||
import hashlib
|
||
import hmac
|
||
import base64
|
||
import secrets
|
||
import threading
|
||
import subprocess
|
||
import ipaddress
|
||
import mimetypes
|
||
import socket
|
||
from datetime import datetime, timedelta
|
||
|
||
# ---------------------------------------------------------------- PATH 归一化
|
||
# Debian 上非 root / 非登录 shell 的 PATH 里没有 /usr/sbin 和 /sbin,而 nft、
|
||
# dnsmasq、radvd、chronyd 等路由器关键命令全都装在 /usr/sbin 下。
|
||
# systemd 服务的默认 PATH 是 /usr/local/bin:/usr/bin:/bin,于是所有 sh(['nft', ...])
|
||
# 都会以「未找到命令」静默失败 —— 表现是防火墙页面空白、日志为空。这里补上。
|
||
_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin')
|
||
|
||
|
||
def _ensure_sbin_path():
|
||
cur = os.environ.get('PATH') or ''
|
||
parts = [p for p in cur.split(os.pathsep) if p]
|
||
for d in _SBIN_DIRS:
|
||
if d not in parts and os.path.isdir(d):
|
||
parts.insert(0, d)
|
||
os.environ['PATH'] = os.pathsep.join(parts)
|
||
|
||
|
||
_ensure_sbin_path()
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import urlparse, parse_qs
|
||
|
||
# 让按路径加载的同目录模块能工作(见 _load_mod)。
|
||
# 服务由 systemd 以绝对路径启动,脚本所在目录**不在** sys.path 里
|
||
# (sys.path[0] 是启动时的 CWD,不是脚本目录)—— 早先没注入,
|
||
# 结果是版本检测一访问就 ImportError,前端只能显示「检测失败」。
|
||
# 显式把脚本自身所在目录插到最前面,不依赖 CWD。
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
if _HERE not in sys.path:
|
||
sys.path.insert(0, _HERE)
|
||
|
||
BASE = '/opt/drouter'
|
||
DATA_DIR = os.path.join(BASE, 'data')
|
||
WEB_DIR = os.path.join(BASE, 'web')
|
||
CERT_DIR = os.path.join(BASE, 'certs')
|
||
DB_PATH = os.path.join(DATA_DIR, 'drouter.db')
|
||
HELPER = '/opt/drouter/backend/drouter-helper.py'
|
||
LOG_DIR = '/var/log/drouter'
|
||
BUILD_FLAG = '/etc/drouter/BUILD_MODE'
|
||
|
||
# ---- 版本号(唯一真源 = packaging/VERSION,构建时安装到 /opt/drouter/VERSION)----
|
||
# 早先版本号在 openapi 里硬编码,于是「页面显示什么版本」和「包管理器认为
|
||
# 是哪个版本」是两条互不相干的线,只能靠人手记着同步 —— 漏一次就是一个
|
||
# 改不掉的 bug(apt 按 control 认版本,文件名是 1.0.9 它就当 1.0.8)。
|
||
VERSION_FALLBACK = '1.0.11'
|
||
_VERSION_READ = [False]
|
||
|
||
|
||
def app_version():
|
||
"""当前版本号。读一次就缓存 —— /opt/drouter/VERSION 不会在运行期变。"""
|
||
if not _VERSION_READ[0]:
|
||
_VERSION_READ[0] = True
|
||
try:
|
||
with open(os.path.join(BASE, 'VERSION')) as f:
|
||
v = f.read().strip()
|
||
# 只认形如 1.2.3 的,避免把构建事故(空文件/半截写入)显示到页面上
|
||
if re.match(r'^\d+\.\d+(\.\d+)?([-+~][\w.]+)?$', v):
|
||
return v
|
||
except Exception:
|
||
pass
|
||
return VERSION_FALLBACK
|
||
|
||
|
||
def in_build_mode():
|
||
"""构建保护模式:存在该标志文件时,禁止一切抢端口/改网络的动作。"""
|
||
return os.path.exists(BUILD_FLAG)
|
||
|
||
|
||
HOST = '0.0.0.0'
|
||
PORT_HTTP = 8080
|
||
PORT_HTTPS = 8443 # 默认值,允许被 /etc/drouter/web-port 覆盖
|
||
SESSION_MINUTES = 60
|
||
MAX_LOGIN_FAILS = 5
|
||
LOGIN_LOCK_MINUTES = 10
|
||
|
||
WEB_PORT_FILE = '/etc/drouter/web-port'
|
||
DEFAULT_WEB_PORT = 8443
|
||
|
||
|
||
def load_web_port():
|
||
"""读取用户自定义的 Web HTTPS 端口(默认 8443)。
|
||
支持 /etc/drouter/web-port 文件(单行数字)或环境变量 DROUTER_WEB_PORT。
|
||
非法值一律回退到默认端口,避免把 Web 服务改到打不开的端口上。
|
||
"""
|
||
cand = os.environ.get('DROUTER_WEB_PORT', '').strip()
|
||
if not cand and os.path.isfile(WEB_PORT_FILE):
|
||
try:
|
||
with open(WEB_PORT_FILE, encoding='utf-8', errors='replace') as f:
|
||
cand = f.read().strip().splitlines()[0].strip()
|
||
except Exception:
|
||
cand = ''
|
||
try:
|
||
n = int(cand)
|
||
if 1 <= n <= 65535:
|
||
return n
|
||
except Exception:
|
||
pass
|
||
return DEFAULT_WEB_PORT
|
||
|
||
|
||
def save_web_port(port):
|
||
"""把端口写入 /etc/drouter/web-port(需要 root,由 helper 执行)。"""
|
||
os.makedirs(os.path.dirname(WEB_PORT_FILE), exist_ok=True)
|
||
with open(WEB_PORT_FILE, 'w', encoding='utf-8') as f:
|
||
f.write('%d\n' % int(port))
|
||
return True
|
||
|
||
# ------------------------------------------------------------------ 数据库
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY, value TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS admins (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT UNIQUE NOT NULL,
|
||
pw_hash TEXT NOT NULL,
|
||
salt TEXT NOT NULL,
|
||
created_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS ifaces (
|
||
mac TEXT PRIMARY KEY,
|
||
name TEXT,
|
||
remark TEXT DEFAULT '',
|
||
role TEXT DEFAULT '',
|
||
updated_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS audits (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts TEXT, user TEXT, action TEXT, detail TEXT, ok INTEGER, ip TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS snapshots (
|
||
ts TEXT PRIMARY KEY, tag TEXT, note TEXT, created_at TEXT
|
||
);
|
||
"""
|
||
|
||
_lock = threading.RLock()
|
||
|
||
# ---- 当前界面语言(1.0.10)----
|
||
# 前端每个请求都带 X-Lang;helper() 据此把 lang 透传给 helper 进程。
|
||
# ⚠️ 存成**单元素列表**而不是裸字符串:Python 里模块级变量
|
||
# 在别的函数里赋值时不会被重新绑定(会变成局部变量),
|
||
# 列表原地改就没这个问题。同 helper 里的 _cur_lang 写法。
|
||
_cur_lang = ['zh-CN']
|
||
|
||
# NAT 检测最近一次结果的进程内缓存(重启即失效,前端用于展示"上次检测")
|
||
NAT_LAST = {'nat': '', 'title': '', 'desc': '', 'detail': '', 'checked_at': ''}
|
||
|
||
# Web 终端会话表:sid → {user, created}
|
||
SHELL_SESSIONS = {}
|
||
|
||
|
||
def db():
|
||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute('PRAGMA journal_mode=WAL')
|
||
return conn
|
||
|
||
|
||
def init_db():
|
||
os.makedirs(DATA_DIR, exist_ok=True)
|
||
os.makedirs(LOG_DIR, exist_ok=True)
|
||
with _lock, db() as c:
|
||
c.executescript(SCHEMA)
|
||
cur = c.execute('SELECT COUNT(*) n FROM admins').fetchone()
|
||
if cur['n'] == 0:
|
||
pw = 'admin123' # 首次登录后应立即修改
|
||
salt = secrets.token_hex(16)
|
||
c.execute('INSERT INTO admins(username,pw_hash,salt,created_at) VALUES(?,?,?,?)',
|
||
('admin', hash_pw(pw, salt), salt, now()))
|
||
cfg_defaults = {
|
||
'dnsmasq': {
|
||
'domain': 'lan', 'dhcp_enabled': False, 'pool_start': '192.168.7.100',
|
||
'pool_end': '192.168.7.200', 'pool_netmask': '255.255.255.0', 'lease_time': 7200,
|
||
'option_gateway': '192.168.7.3', 'option_dns': '192.168.7.3',
|
||
# code 3 / 6 由上面的 option_gateway / option_dns 专属字段负责。
|
||
# 这里**不再预置**同名 code —— 两处并存的写法会渲染出重复的
|
||
# dhcp-option 行(1.0.1 及更早的默认值就是这样,1.0.2 去掉)。
|
||
'options': [],
|
||
'static_leases': [],
|
||
'dns_mode': 'custom', 'dns_custom': '223.5.5.5,119.29.29.29',
|
||
'dns_lan_server': '', 'dns_cache_size': 1000, 'dns_negcache': True,
|
||
'dns_query_log': False, 'log_file': '/var/log/drouter/dnsmasq.log',
|
||
},
|
||
'nft_v4': {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], 'mss_clamp': True, 'mss': 1452,
|
||
'mss_mode': 'clamp', 'masquerade': True, 'raw': ''},
|
||
'nft_v6': {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], 'mss_clamp': False, 'mss': 1432,
|
||
'mss_mode': 'clamp', 'masquerade': False, 'raw': ''},
|
||
'radvd': {'iface': 'ens18', 'prefix': '', 'managed': False, 'other_config': False,
|
||
'max_interval': 600, 'min_interval': 200, 'lifetime': 1800, 'hop_limit': 64,
|
||
'reachable': 0, 'retrans': 0, 'valid_life': 86400, 'pref_life': 14400,
|
||
'rdnss': '', 'rdnss_life': 600, 'dnssl': ''},
|
||
'dhcpv6': {'wan_iface': 'ppp0', 'lan_iface': 'ens18', 'request_pd': True,
|
||
'request_address': False, 'iaid': 0, 'sla_id': '::1', 'prefix_len': 64, 'slaac': False},
|
||
'upnp': {'enable': False, 'ext_iface': 'ppp0', 'listen_ip': '192.168.7.3',
|
||
'port': 5000, 'natpmp': False, 'secure_mode': True, 'igd_v1': False},
|
||
'ntp': {'servers': 'ntp.aliyun.com,time.cloudflare.com', 'allow_lan': False,
|
||
'allow_net': '', 'use_timesyncd': False},
|
||
'system': {
|
||
'lan_iface': 'ens18', 'lan_address': '192.168.7.3/24', 'lan_mtu': 1500,
|
||
'gateway': '192.168.7.2', 'manage_scope': 'lan_only',
|
||
'wan_iface': '', 'wan_present': False,
|
||
},
|
||
'pppoe': {'iface': '', 'username': '', 'password': '', 'isp': 'auto',
|
||
'service_name': '', 'mtu': 1492, 'mru': 1492, 'persist': True,
|
||
'maxfail': 0, 'holdoff': 5, 'saved_only': True,
|
||
'access_type': 'pppoe',
|
||
'note': '仅保存凭据,未执行拨号'},
|
||
'ddns': {'enabled': False, 'provider': 'custom', 'domain': '', 'subdomain': '',
|
||
'ipv4': True, 'ipv6': False, 'ttl': 300, 'interval': 300,
|
||
'url4': '', 'url6': '', 'fields': {},
|
||
'last_update': '', 'last_ip4': '', 'last_ip6': '',
|
||
'last_result': '', 'last_msg_cn': ''},
|
||
'portfwd': {'enable_v4': False, 'enable_v6': False, 'rules': [],
|
||
'dmz_v4_enable': False, 'dmz_v4_host': '',
|
||
'dmz_v6_enable': False, 'dmz_v6_host': '',
|
||
'note': '端口转发与 DMZ,规则将渲染为 nftables DNAT'},
|
||
'acl': {'enable': False, 'time_groups': [], 'groups': [], 'rules': [],
|
||
'note': '访问控制与家长时间组,规则渲染为 nftables(shared/DPI 分类)'},
|
||
'share': {'enable_smb': False, 'enable_nfs': False,
|
||
'samba': {'workgroup': 'WORKGROUP', 'server_string': 'drouter 文件共享',
|
||
'disable_netbios': True, 'wins_enable': False,
|
||
'wins_support': False, 'interfaces': '', 'global_extra': '',
|
||
'shares': []},
|
||
'nfs': {'threads': 8, 'exports': []},
|
||
'note': '内网文件共享(SMB / NFS)'},
|
||
'docker': {'stacks': [],
|
||
'note': 'Docker 与 Docker Compose 管理面板'},
|
||
'ulog': {'enabled': True, 'keep_days': 7, 'keep_rows': 200000,
|
||
'archive': True, 'min_level': 'debug',
|
||
'note': '连接跟踪与统一日志系统(保留策略与源开关)'},
|
||
'theme': {'active': 'default',
|
||
'note': '主题之家:当前生效主题 id(实际样式由 helper 写入 theme.css)'},
|
||
}
|
||
with _lock, db() as c:
|
||
for k, v in cfg_defaults.items():
|
||
row = c.execute('SELECT value FROM settings WHERE key=?', (k,)).fetchone()
|
||
if not row:
|
||
c.execute('INSERT INTO settings(key,value) VALUES(?,?)',
|
||
(k, json.dumps(v, ensure_ascii=False)))
|
||
|
||
|
||
def now():
|
||
return datetime.now().isoformat(timespec='seconds')
|
||
|
||
|
||
def get_cfg(key, default=None):
|
||
with _lock, db() as c:
|
||
row = c.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
|
||
if not row:
|
||
return default
|
||
try:
|
||
return json.loads(row['value'])
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def get_cfg_many(keys, default=None):
|
||
"""一次连接取回多个配置键 —— /api/config 一次要拉十几个模块,
|
||
逐个 get_cfg 就是十几次「开库 + PRAGMA + 关库」,低功耗机上能省几十毫秒。"""
|
||
keys = list(keys or [])
|
||
out = {k: (default if default is not None else {}) for k in keys}
|
||
if not keys:
|
||
return out
|
||
q = 'SELECT key,value FROM settings WHERE key IN (%s)' % ','.join('?' * len(keys))
|
||
with _lock, db() as c:
|
||
rows = c.execute(q, keys).fetchall()
|
||
for r in rows:
|
||
try:
|
||
out[r['key']] = json.loads(r['value'])
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
|
||
def set_cfg(key, value):
|
||
with _lock, db() as c:
|
||
c.execute('INSERT INTO settings(key,value) VALUES(?,?) '
|
||
'ON CONFLICT(key) DO UPDATE SET value=excluded.value',
|
||
(key, json.dumps(value, ensure_ascii=False)))
|
||
|
||
|
||
def merge_cfg(key, patch):
|
||
cur = get_cfg(key, {}) or {}
|
||
if isinstance(cur, dict) and isinstance(patch, dict):
|
||
cur.update(patch)
|
||
else:
|
||
cur = patch
|
||
set_cfg(key, cur)
|
||
return cur
|
||
|
||
|
||
def hash_pw(pw, salt):
|
||
return hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt.encode('utf-8'),
|
||
180000).hex()
|
||
|
||
|
||
# 审计表保留上限。/api/login 是**不需要鉴权**的,公网暴露时随便刷就一直 INSERT,
|
||
# 而读取端只有 LIMIT 300 —— 数据库和 WAL 会安静地长到把磁盘占满。
|
||
AUDIT_MAX_ROWS = 20000
|
||
_audit_since_prune = [0]
|
||
|
||
|
||
def audit(user, action, detail, ok=True, ip=''):
|
||
with _lock, db() as c:
|
||
c.execute('INSERT INTO audits(ts,user,action,detail,ok,ip) VALUES(?,?,?,?,?,?)',
|
||
(now(), user, action, str(detail)[:2000], 1 if ok else 0, ip))
|
||
_audit_since_prune[0] += 1
|
||
# 每 500 条才整理一次:不要给每次登录都加一次 DELETE + 可能的 VACUUM
|
||
if _audit_since_prune[0] >= 500:
|
||
_audit_since_prune[0] = 0
|
||
try:
|
||
c.execute('DELETE FROM audits WHERE id NOT IN '
|
||
'(SELECT id FROM audits ORDER BY id DESC LIMIT ?)',
|
||
(AUDIT_MAX_ROWS,))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def read_audits(limit=300):
|
||
with _lock, db() as c:
|
||
rows = c.execute('SELECT * FROM audits ORDER BY id DESC LIMIT ?', (limit,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
# ------------------------------------------------------------------ helper 调用
|
||
|
||
# ---- 常驻执行守护(#7 性能优化)----
|
||
#
|
||
# 原来的每个请求都要 spawn 一次 `sudo python3 drouter-helper.py`,实测固定开销
|
||
# 约 236ms(Python 启动 + sudo + 重新 import 上万行代码),而动作本身往往只要
|
||
# 几十毫秒。drouter-helpd 常驻后,请求直接走 Unix socket,省掉这部分。
|
||
#
|
||
# 降级策略:socket 连不上就回退到原来的 subprocess 方式。守护挂掉只会变慢,
|
||
# 不会让面板不可用。为此「守护不可用」这个判断会缓存一小段时间,
|
||
# 否则守护没起时每个请求都要白等一次连接超时。
|
||
HELPD_SOCK = '/run/drouter/helper.sock'
|
||
# JSON 响应达到多大才值得压缩。太小的话压缩省下的字节还不如 CPU 开销。
|
||
GZIP_MIN = 4096
|
||
# 单次请求体上限。早年完全信任 Content-Length:`rfile.read(n)` 会按声明的
|
||
# 长度一次性分配(还可能永久等待),一个未认证的连接就能把 4GB 小机器拖死。
|
||
MAX_BODY = 16 * 1024 * 1024
|
||
# 同时处理请求的工作线程上限。ThreadingHTTPServer 是「来一个连接开一个线程」,
|
||
# 慢连接/慢客户端(slowloris)能把线程和 fd 一起吃完。
|
||
# 并发上限:ThreadingHTTPServer 是「来一个连接开一个线程」,
|
||
# 慢连接/慢客户端(slowloris)能把线程和 fd 一起吃完。
|
||
MAX_WORKERS = 48
|
||
_WORKER_SEM = threading.BoundedSemaphore(MAX_WORKERS)
|
||
# 会话表上限:GC 线程万一挂了,也不能让 token 无限堆积。
|
||
MAX_SESSIONS_WEB = 500
|
||
|
||
# ---- 按路径加载的模块缓存(见 Handler._load_mod 的注释)----
|
||
# ⚠️ 缓存不是性能优化,是**正确性要求**:不缓存会让每个请求得到一个
|
||
# 全新模块对象,其模块级 threading.Event / Lock 全部失效 ——
|
||
# 最直接的后果是「中断更新」永远不生效(详见 _load_mod 里的说明)。
|
||
# key 是模块名,value 是 (mtime, 模块对象);带 mtime 是为了部署新版本后
|
||
# 自动失效,不会拿着旧代码不放。
|
||
_MOD_CACHE = {}
|
||
_MOD_CACHE_LOCK = threading.Lock()
|
||
|
||
# ---- 「保存并应用」允许的模块名 ----
|
||
# 权威定义在 helper 侧,由两部分组成(act_apply 的实际判定逻辑):
|
||
# APPLY_SPEC(12 项:有独立配置文件,需渲染 + 语法预检 + 重载服务)
|
||
# ∪ CONFIG_ONLY_MODULES(2 项:纯配置,web.py 落库即可,无独立配置文件)
|
||
# ⚠️ 少一个 → 用户点「应用」被拒;多一个 → 任意 key 落库(1.0.10 修的正是后者)。
|
||
# ⚠️ 两份定义会漂移,t-107 断言两边一致,改一处必须改另一处。
|
||
APPLY_MODULES = {
|
||
# == helper.APPLY_SPEC
|
||
'dnsmasq', 'radvd', 'dhcpv6', 'miniupnpd', 'upnp', 'chrony', 'ntp',
|
||
'nft_v4', 'nft_v6', 'network', 'ppp', 'pppoe',
|
||
# == helper.CONFIG_ONLY_MODULES
|
||
'system', 'portfwd',
|
||
}
|
||
|
||
# ---- 静态资源压缩缓存(#7)----
|
||
# app.js 有 583KB,gzip 后 198KB。每次请求都重压一遍纯属浪费 CPU,
|
||
# 所以按 (文件路径, 修改时间, 大小, 编码) 做键把压缩结果缓在内存里;
|
||
# 文件一变(部署新版本)键就变了,旧条目自动失效。
|
||
#
|
||
# ⚠️ 编码必须进键:同一份数据 gzip 出来 198KB、brotli 出来 150KB,
|
||
# 共用一个键就会互相覆盖 —— 拿到 br 的客户端可能收到 gzip 版,
|
||
# 浏览器解不开就是满屏乱码。
|
||
_comp_lock = threading.Lock()
|
||
_comp_cache = {}
|
||
_COMP_MAX = 24 # 资源种类就那么几个,缓存上限给足余量即可
|
||
# 但**只限条目数是不够的**:24 个大响应就能占掉上百 MB 常驻内存。
|
||
# 4GB 机器上按字节再设一道闸(超过就整表清空,重建成本很低)。
|
||
_COMP_MAX_BYTES = 32 * 1024 * 1024
|
||
_comp_bytes = [0]
|
||
|
||
# brotli 质量档。实测 app.js(583KB):
|
||
# q5 → 约 160KB,压缩耗时 ~0.1s
|
||
# q11 → 150KB,压缩耗时 1.5-3s(2 核小机器上很明显)
|
||
# 省下的 10KB 不值 2 秒,**首屏最怕的是等**。q5 是这个权衡的平衡点。
|
||
BROTLI_QUALITY = 5
|
||
|
||
|
||
def _accepts(headers, token):
|
||
"""Accept-Encoding 里是否含某个编码(大小写不敏感、带 q=0 视为不支持)。
|
||
|
||
⚠️ 不能只做 `'br' in s`:`Accept-Encoding: br;q=0` 的意思是
|
||
「我认识 br 但明确不要」,按子串匹配会正好发一份它解不开的东西。
|
||
"""
|
||
try:
|
||
s = (headers.get('Accept-Encoding') or '').lower()
|
||
except Exception:
|
||
return False
|
||
for part in s.split(','):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
bits = part.split(';')
|
||
name = bits[0].strip()
|
||
if name != token:
|
||
continue
|
||
for q in bits[1:]:
|
||
q = q.strip()
|
||
if q.startswith('q='):
|
||
try:
|
||
return float(q[2:]) > 0
|
||
except ValueError:
|
||
return True
|
||
return True
|
||
return False
|
||
|
||
|
||
def _want_gzip(headers):
|
||
return _accepts(headers, 'gzip')
|
||
|
||
|
||
def _want_brotli(headers):
|
||
"""浏览器是否收 br。
|
||
|
||
实测 app.js(583KB 源码):gzip → 198KB,**brotli → 160KB**(q5),
|
||
再省 20%。走外网(cloudflared / 远程打开面板)这个差别是实打实的秒数。
|
||
|
||
⚠️ 只在客户端**明确**带 br 时才用。发一份客户端解不开的编码,
|
||
表现是页面全是乱码 —— 比慢严重得多。
|
||
"""
|
||
return _accepts(headers, 'br')
|
||
|
||
|
||
def _compress_cached(path, data, enc):
|
||
"""压一次并缓存。返回 (body, enc) 或 (data, None)(压不动就直出)。"""
|
||
try:
|
||
st = os.stat(path)
|
||
key = (path, int(st.st_mtime), st.st_size, enc)
|
||
except (OSError, TypeError):
|
||
# path=None(JSON 响应不在磁盘上)或文件刚好被删 → 按内容哈希缓存。
|
||
# 只接 OSError/TypeError:os.stat 失败就这两种,再宽就会把
|
||
# 拼错路径之类的编程错误一起吞掉。
|
||
key = (None, hash(data), len(data), enc)
|
||
with _comp_lock:
|
||
hit = _comp_cache.get(key)
|
||
if hit is not None:
|
||
return hit, enc
|
||
try:
|
||
if enc == 'br':
|
||
import brotli
|
||
z = brotli.compress(data, quality=BROTLI_QUALITY)
|
||
else:
|
||
import gzip
|
||
z = gzip.compress(data, 6)
|
||
except Exception:
|
||
return data, None
|
||
if len(z) >= len(data):
|
||
return data, None
|
||
with _comp_lock:
|
||
if (len(_comp_cache) >= _COMP_MAX
|
||
or _comp_bytes[0] + len(z) > _COMP_MAX_BYTES):
|
||
_comp_cache.clear()
|
||
_comp_bytes[0] = 0
|
||
_comp_cache[key] = z
|
||
_comp_bytes[0] += len(z)
|
||
return z, enc
|
||
|
||
|
||
def _brotli_cached(path, data):
|
||
"""brotli 版。**brotli 模块不可用时返回 (None, None)** —— 由调用方
|
||
决定回退到 gzip,这里不能自己吐原文(那等于 583KB 裸传)。
|
||
|
||
兜底只覆盖 ImportError:模块没装是预期情况(Debian 13 的包名是
|
||
python3-brotli,属于可选依赖)。其余异常照抛 —— 被宽 except 吞掉的
|
||
内部错误会伪装成「压缩没生效」,见 _gzip_cached 里的说明。
|
||
"""
|
||
try:
|
||
import brotli # noqa: F401
|
||
except ImportError:
|
||
return None, None
|
||
return _compress_cached(path, data, 'br')
|
||
|
||
|
||
def _gzip_cached(path, data):
|
||
"""gzip 版。返回 (body, encoding)。data 已是最终字节内容时传 path=None。
|
||
|
||
⚠️ 兜底**只接 ImportError**(gzip 是标准库,实际等于不接),
|
||
刻意不写 `except Exception`、也不接 TypeError/ValueError:
|
||
早先写的是宽 except,结果一次 NameError(漏注入 _comp_lock)被静默
|
||
吞成「返回原文」,表现是**压缩功能完好但从不生效**,
|
||
页面照常能开,只看「响应头有没有 Content-Encoding」的判据也发现不了,
|
||
583KB 一直裸传。TypeError/ValueError 同样不能接 ——
|
||
「把 str 传给要 bytes 的接口」也是 TypeError,接了等于没接。
|
||
真出这类错就该抛出来:请求 500 比静默劣化好排查得多。
|
||
"""
|
||
return _compress_cached(path, data, 'gzip')
|
||
|
||
|
||
def _encode_body(headers, path, data, min_size=GZIP_MIN):
|
||
"""按客户端能力挑编码:br > gzip > 原文。
|
||
|
||
**调用方负责已经判过 len(data) >= min_size** —— 这里不再重复判断。
|
||
返回 (body, encoding);encoding 为 None 表示原样输出。
|
||
|
||
⚠️ 降级必须是**逐级**的,不能「要 br 就只发 br」:
|
||
目标机没装 python3-brotli 时如果直接返回原文,583KB 就裸传了,
|
||
比优化前还慢。正确做法是 br 不可用 → 试 gzip → 都不行才原文。
|
||
|
||
这里也**不写宽 except**:压缩链出内部错误时宁可让请求 500,
|
||
也不要静默返回一个「没压缩但看起来正常」的响应 ——
|
||
那正是 583KB 裸传却没人发现的成因。
|
||
"""
|
||
# 顺序不能换:brotli 压得更小,但要先确认客户端真的收。
|
||
# 反过来(先 gzip 再问 br)会让不支持 br 的客户端拿到 br 版。
|
||
if _want_brotli(headers):
|
||
body, enc = _brotli_cached(path, data)
|
||
if enc:
|
||
return body, enc
|
||
# 落到这里 = brotli 模块没有 / 压不小 → 继续试 gzip
|
||
if _want_gzip(headers):
|
||
return _gzip_cached(path, data)
|
||
return data, None
|
||
_helpd_lock = threading.Lock()
|
||
_helpd_down_until = [0.0] # 在这个时间戳之前,不再尝试连 socket
|
||
_helpd_down_logged = [False]
|
||
|
||
|
||
def _helpd_call(action, payload, timeout):
|
||
"""通过 Unix socket 让常驻守护执行一个动作。返回 dict;不可用返回 None。"""
|
||
now_ts = time.time()
|
||
with _helpd_lock:
|
||
if now_ts < _helpd_down_until[0]:
|
||
return None
|
||
try:
|
||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
s.settimeout(min(timeout, 900))
|
||
except Exception:
|
||
return None
|
||
try:
|
||
try:
|
||
s.connect(HELPD_SOCK)
|
||
except Exception:
|
||
# 守护没在跑:标记一小段冷却期,避免后续请求逐个白等
|
||
with _helpd_lock:
|
||
_helpd_down_until[0] = time.time() + 30
|
||
if not _helpd_down_logged[0]:
|
||
_helpd_down_logged[0] = True
|
||
return None
|
||
req = (json.dumps({'action': action, 'payload': payload or {}},
|
||
ensure_ascii=False) + '\n').encode('utf-8')
|
||
s.sendall(req)
|
||
# 用 list 收集而不是 buf += chunk:后者每收一块都要整块复制一遍,
|
||
# 32MB 的响应会退化成 O(n^2) 并留下多份临时内存。
|
||
parts = []
|
||
total = 0
|
||
while True:
|
||
chunk = s.recv(65536)
|
||
if not chunk:
|
||
break
|
||
parts.append(chunk)
|
||
total += len(chunk)
|
||
if total > 32 * 1024 * 1024 or parts[-1].endswith(b'\n'):
|
||
break
|
||
line = b''.join(parts).decode('utf-8', 'replace').strip()
|
||
if not line:
|
||
return None
|
||
return json.loads(line)
|
||
except Exception:
|
||
return None
|
||
finally:
|
||
try:
|
||
s.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def helper(action, payload=None, timeout=120, lang=None):
|
||
"""调用 root 特权白名单脚本,返回 dict
|
||
|
||
`lang` 透传给 helper('en-US' / 'zh-CN'),由 helper 的 set_lang()
|
||
决定 msg_en 是真英文还是回落中文。None = 不传(helper 保持默认中文)。
|
||
"""
|
||
if lang is None:
|
||
lang = _cur_lang[0]
|
||
if lang != 'zh-CN':
|
||
payload = dict(payload or {})
|
||
payload['_lang'] = lang
|
||
# 1) 先试常驻守护(快路径)
|
||
r = _helpd_call(action, payload, timeout)
|
||
if r is not None:
|
||
return r
|
||
# 2) 回退:spawn 一次 helper 进程(慢但一定能用)
|
||
cmd = ['sudo', '-n', '/usr/bin/python3', HELPER, action,
|
||
json.dumps(payload or {}, ensure_ascii=False)]
|
||
try:
|
||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, errors='replace')
|
||
except subprocess.TimeoutExpired:
|
||
return {'ok': False, 'code': 'TIMEOUT', 'msg_cn': '操作超时(%ss)' % timeout,
|
||
'msg_en': 'Operation timed out after %ss' % timeout,
|
||
'msg': ('Operation timed out after %ss' % timeout) if lang == 'en-US'
|
||
else '操作超时(%ss)' % timeout}
|
||
except FileNotFoundError:
|
||
return {'ok': False, 'code': 'NO_HELPER', 'msg_cn': '找不到特权执行器',
|
||
'msg_en': 'Privileged executor not found',
|
||
'msg': 'Privileged executor not found' if lang == 'en-US'
|
||
else '找不到特权执行器'}
|
||
out = (p.stdout or '').strip()
|
||
line = out.splitlines()[-1] if out else ''
|
||
try:
|
||
return json.loads(line)
|
||
except Exception:
|
||
return {'ok': False, 'code': 'BAD_OUTPUT',
|
||
'msg_cn': '特权执行器返回异常', 'msg_en': 'Privileged executor returned malformed output',
|
||
'msg': ('Privileged executor returned malformed output' if lang == 'en-US'
|
||
else '特权执行器返回异常'),
|
||
'data': {'stdout': out[-800:], 'stderr': (p.stderr or '')[-800:]},
|
||
'rc': p.returncode}
|
||
|
||
|
||
# ------------------------------------------------------------------ 证书
|
||
|
||
def ensure_cert():
|
||
os.makedirs(CERT_DIR, exist_ok=True)
|
||
crt = os.path.join(CERT_DIR, 'server.crt')
|
||
key = os.path.join(CERT_DIR, 'server.key')
|
||
if os.path.isfile(crt) and os.path.isfile(key):
|
||
return crt, key
|
||
ips = set()
|
||
|
||
def _add(ip):
|
||
try:
|
||
ipaddress.ip_address(ip)
|
||
except Exception:
|
||
return
|
||
ips.add(ip)
|
||
|
||
try:
|
||
rc = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=5)
|
||
for x in (rc.stdout or '').split():
|
||
_add(x)
|
||
except Exception:
|
||
pass
|
||
if not ips or ips <= {'127.0.0.1', '::1'}:
|
||
# hostname -I 不可用或还没拿到地址时,退回「默认路由的源地址」——
|
||
# 也就是本机真正在局域网上用的那张脸。写得这么绕是为了不再把某个
|
||
# 具体的 192.168.x.x 钉死在代码里:那台是他自己家的机器,装到别人
|
||
# 家里证书里却带着一个不属于对方的 IP。
|
||
try:
|
||
rc = subprocess.run(['ip', '-o', 'route', 'get', '1.1.1.1'],
|
||
capture_output=True, text=True, timeout=5)
|
||
m = re.search(r'\bsrc\s+(\S+)', rc.stdout or '')
|
||
if m:
|
||
_add(m.group(1))
|
||
except Exception:
|
||
pass
|
||
_add('127.0.0.1')
|
||
san = ','.join('IP:%s' % i for i in sorted(ips)) + ',DNS:localhost,DNS:drouter.local'
|
||
config = os.path.join(CERT_DIR, 'openssl.cnf')
|
||
with open(config, 'w') as f:
|
||
f.write('[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n'
|
||
'[dn]\nCN=drouter.local\nO=drouter\n'
|
||
'[v3]\nsubjectAltName=%s\nbasicConstraints=CA:FALSE\n' % san)
|
||
rc = subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
||
'-keyout', key, '-out', crt, '-days', '3650',
|
||
'-config', config], capture_output=True, timeout=60)
|
||
# 原先完全不看返回值:openssl 没装 / 失败时只是静默留下两个不存在的文件,
|
||
# 报错要等到后面 SSLContext.load_cert_chain 才炸,而且完全看不出是签发失败。
|
||
if rc.returncode != 0 or not (os.path.isfile(crt) and os.path.isfile(key)):
|
||
err = ((rc.stderr or b'') if isinstance(rc.stderr, bytes) else (rc.stderr or ''))
|
||
raise RuntimeError(
|
||
'自签证书生成失败(openssl 未安装或被拒绝?)。'
|
||
'管理面板的 HTTPS 需要一张证书:请先 apt-get install -y openssl 后重启'
|
||
' drouter-web。openssl 输出:%s' % str(err)[-400:])
|
||
try:
|
||
os.chmod(key, 0o600)
|
||
except Exception:
|
||
pass
|
||
return crt, key
|
||
|
||
|
||
# ------------------------------------------------------------------ 会话
|
||
|
||
SESSIONS = {}
|
||
|
||
|
||
def _session_expiry_s():
|
||
return SESSION_MINUTES * 60
|
||
|
||
|
||
def new_session(user, ip):
|
||
tok = secrets.token_urlsafe(32)
|
||
_prune_sessions()
|
||
# exp 保留墙钟时间给界面展示,判过期一律用 exp_mono:
|
||
# NTP 校时/机器休眠会把 time.time() 来回拨,校时回拨时所有 token 会
|
||
# 「意外续命」,前跳时又会一次性全部失效。
|
||
SESSIONS[tok] = {'user': user, 'ip': ip,
|
||
'exp': time.time() + _session_expiry_s(),
|
||
'exp_mono': time.monotonic() + _session_expiry_s(),
|
||
'csrf': secrets.token_urlsafe(24)}
|
||
return SESSIONS[tok]
|
||
|
||
|
||
def _prune_sessions():
|
||
"""兜底:会话表不能无限长(GC 线程万一被异常打死也要有上限)。"""
|
||
if len(SESSIONS) <= MAX_SESSIONS_WEB:
|
||
return
|
||
try:
|
||
now = time.monotonic()
|
||
for k in sorted(SESSIONS, key=lambda x: SESSIONS[x].get('exp_mono', 0))[
|
||
:len(SESSIONS) - MAX_SESSIONS_WEB]:
|
||
SESSIONS.pop(k, None)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def get_session(tok):
|
||
s = SESSIONS.get(tok)
|
||
if not s:
|
||
return None
|
||
# 兼容老条目(没有 exp_mono 的),用墙钟时间兜底
|
||
exp_mono = s.get('exp_mono')
|
||
if exp_mono is None:
|
||
exp_mono = s.get('exp', 0)
|
||
s['exp_mono'] = exp_mono
|
||
if exp_mono < time.monotonic():
|
||
SESSIONS.pop(tok, None)
|
||
return None
|
||
return s
|
||
|
||
|
||
def gc_sessions():
|
||
while True:
|
||
time.sleep(300)
|
||
try:
|
||
# 用 list() 固化:CPython 3 里若在遍历中有人登录/注销会抛
|
||
# 「dictionary changed size during iteration」,GC 线程一旦被打死,
|
||
# 过期 token 就再也没人清理了。
|
||
now = time.monotonic()
|
||
dead = [k for k, v in list(SESSIONS.items())
|
||
if v.get('exp_mono', 0) < now]
|
||
for k in dead:
|
||
SESSIONS.pop(k, None)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
LOGIN_FAILS = {}
|
||
|
||
|
||
# ------------------------------------------------------------------ 路由处理
|
||
|
||
class Api:
|
||
def __init__(self, handler):
|
||
self.h = handler
|
||
self.user = None
|
||
self.method = 'GET'
|
||
|
||
# ---------- 工具 ----------
|
||
def json(self, obj, status=200):
|
||
body = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||
# 大响应 gzip 压缩(#7):统一日志单次能回 300KB,压缩后通常只剩
|
||
# 十分之一。局域网里无所谓,但通过 cloudflared / 外网访问时这是数量级
|
||
# 的差别。小响应不压——省下的字节还不如压缩本身的花销。
|
||
enc = None
|
||
if len(body) >= GZIP_MIN:
|
||
body, enc = _encode_body(self.h.headers, None, body)
|
||
self.h.send_response(status)
|
||
self.h.send_header('Content-Type', 'application/json; charset=utf-8')
|
||
if enc:
|
||
self.h.send_header('Content-Encoding', enc)
|
||
# 必须让中间代理按 Accept-Encoding 分别缓存,否则压缩版会被拿去
|
||
# 喂给不支持 gzip 的客户端
|
||
self.h.send_header('Vary', 'Accept-Encoding')
|
||
self.h.send_header('Content-Length', str(len(body)))
|
||
self.h.send_header('Cache-Control', 'no-store')
|
||
self.h.end_headers()
|
||
self.h.wfile.write(body)
|
||
|
||
|
||
def body(self):
|
||
try:
|
||
n = int(self.h.headers.get('Content-Length') or 0)
|
||
# 声明的长度必须可信:负数/超大一律拒绝(见 MAX_BODY 的注释)
|
||
if n < 0 or n > MAX_BODY:
|
||
return {}
|
||
raw = self.h.rfile.read(n) if n else b'{}'
|
||
return json.loads(raw.decode('utf-8') or '{}')
|
||
except Exception:
|
||
return {}
|
||
|
||
def auth(self, need_admin=False):
|
||
tok = self.h.headers.get('X-Token') or ''
|
||
s = get_session(tok)
|
||
if not s:
|
||
self.json({'ok': False, 'code': 'UNAUTH', 'msg_cn': '登录已过期,请重新登录'}, 401)
|
||
return None
|
||
self.user = s['user']
|
||
return s
|
||
|
||
def helper(self, action, payload=None, timeout=120):
|
||
return helper(action, payload, timeout)
|
||
|
||
# ---------- 分发 ----------
|
||
def dispatch(self, method, path, query):
|
||
p = path.rstrip('/') or '/'
|
||
self.method = method
|
||
# 1.0.10:先记下当前请求要用的语言,后面的 helper() 会读它。
|
||
# 放在 dispatch 最开头,任何分支(含免鉴权的)都能取到。
|
||
try:
|
||
_cur_lang[0] = ('en-US'
|
||
if (self.h.headers.get('X-Lang') or '').lower()
|
||
.startswith('en') else 'zh-CN')
|
||
except Exception:
|
||
_cur_lang[0] = 'zh-CN'
|
||
|
||
# ========== 无需登录 ==========
|
||
if p == '/api/login' and method == 'POST':
|
||
return self.login()
|
||
if p == '/api/health':
|
||
return self.json({'ok': True, 'msg_cn': '服务正常', 'ts': now()})
|
||
|
||
# ========== 需要登录 ==========
|
||
if p == '/api/logout' and method == 'POST':
|
||
if self.auth():
|
||
SESSIONS.pop(self.h.headers.get('X-Token'), None)
|
||
return self.json({'ok': True, 'msg_cn': '已退出登录'})
|
||
return
|
||
if p == '/api/me':
|
||
if self.auth():
|
||
return self.json({'ok': True, 'data': {'user': self.user}})
|
||
return
|
||
if p == '/api/password' and method == 'POST':
|
||
return self.change_password()
|
||
|
||
# 只读观测
|
||
readings = {
|
||
'/api/sysinfo': 'read:sysinfo', '/api/ifaces': 'read:ifaces',
|
||
'/api/routes': 'read:routes', '/api/services': 'read:services',
|
||
'/api/nft': 'read:nft', '/api/leases': 'read:leases',
|
||
'/api/upnpmap': 'read:upnpmap',
|
||
'/api/ipv6': 'read:ipv6', '/api/ntp': 'read:ntp',
|
||
# 上游链路详情(IPv4/IPv6 协议 + DHCPv6 统计)
|
||
'/api/upstream': 'read:upstream',
|
||
# 邻居表 + 路由表 + IPv6 规则表
|
||
'/api/netdetail': 'read:netdetail',
|
||
'/api/users': 'read:users', '/api/logs': 'read:logs',
|
||
'/api/journal': 'read:journal',
|
||
'/api/iface_method': 'read:iface_method',
|
||
'/api/ppp/log': 'read:ppp_log',
|
||
'/api/ppp_log': 'read:ppp_log',
|
||
'/api/metrics': 'read:metrics',
|
||
# 健康总览(#5):一次拿回所有模块的状态灯
|
||
'/api/status': 'read:status',
|
||
'/api/ddns': 'read:ddns',
|
||
'/api/acl': 'read:acl',
|
||
'/api/share': 'read:share',
|
||
'/api/docker': 'read:docker',
|
||
'/api/ulog': 'read:ulog',
|
||
'/api/ulog/flow': 'read:ulog_flow',
|
||
'/api/ulog/conf': 'read:ulog_conf',
|
||
'/api/ulog/daemon': 'read:logd',
|
||
'/api/theme': 'read:theme',
|
||
}
|
||
# 这几个路径「既读又写」:下面各自有专属的 POST 写分支。
|
||
# 绝不能让上面的 readings 把 POST 也吃掉 —— 那样写操作永远走不到,
|
||
# 而且返回的是读结果 + ok:true,前端照样提示「保存完成」,
|
||
# 用户以为存上了,实际什么都没发生(访问控制/文件共享/Docker/
|
||
# 动态域名/主题/流日志 六个模块全废,1.0.5 之前一直存在)。
|
||
# 与 1.0.5 那个 depcheck 的 keys/only 属于同一类:静默失效还报成功。
|
||
_has_post_branch = {
|
||
'/api/ulog', '/api/ulog/flow', '/api/ulog/conf', '/api/theme',
|
||
'/api/share', '/api/acl', '/api/docker', '/api/ddns',
|
||
}
|
||
if p in readings and (method == 'GET'
|
||
or (method == 'POST' and p not in _has_post_branch)):
|
||
if not self.auth():
|
||
return
|
||
payload = {}
|
||
if p == '/api/logs':
|
||
payload = {'module': (query.get('module') or ['all'])[0],
|
||
'limit': (query.get('limit') or ['300'])[0]}
|
||
if p == '/api/journal':
|
||
payload = {'unit': (query.get('unit') or [''])[0],
|
||
'limit': (query.get('limit') or ['200'])[0]}
|
||
if p == '/api/metrics':
|
||
payload = {'quick': (query.get('quick') or ['0'])[0] in ('1', 'true', 'yes')}
|
||
if p in ('/api/ulog', '/api/ulog/flow', '/api/ulog/conf'):
|
||
for k in ('since', 'limit', 'level', 'src', 'action', 'proto',
|
||
'addr', 'port', 'q', 'sources'):
|
||
if query.get(k):
|
||
payload[k] = query[k][0]
|
||
res = self.helper(readings[p], payload)
|
||
return self.json(res)
|
||
|
||
# ========== 连接跟踪与统一日志(#11) ==========
|
||
if p in ('/api/ulog', '/api/ulog/op', '/api/ulog/flow',
|
||
'/api/ulog/conf', '/api/flow') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
for k in ('since', 'limit', 'level', 'src', 'action', 'proto',
|
||
'addr', 'port', 'q', 'sources'):
|
||
if k in query and k not in b:
|
||
b[k] = query[k][0]
|
||
op = (b.get('op') or '').strip()
|
||
if p in ('/api/ulog/flow', '/api/flow'):
|
||
return self.json(self.helper('read:ulog_flow', b, timeout=40))
|
||
if p == '/api/ulog/conf':
|
||
return self.json(self.helper('read:ulog_conf', b, timeout=25))
|
||
if not op:
|
||
op = 'flow' if b.get('view') == 'flow' else 'query'
|
||
to = 60 if op in ('export', 'prune', 'save_conf') else 45
|
||
res = self.helper('ulog', dict(b, op=op), timeout=to)
|
||
# 保存日志设置后,同步重建采集定时器(按 enabled/archive 启停)
|
||
if op == 'save_conf' and res.get('ok'):
|
||
sync = self.helper('logd_sync', {
|
||
'interval_sec': b.get('interval_sec') or 60}, timeout=45)
|
||
if sync.get('ok'):
|
||
res['msg_cn'] = '%s;%s' % (res.get('msg_cn', ''),
|
||
sync.get('msg_cn', ''))
|
||
return self.json(res)
|
||
|
||
# ========== 主题之家(#12) ==========
|
||
# 只读:列表 / 单个主题 / 变量清单
|
||
if p in ('/api/theme', '/api/themes') and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
op = (query.get('op') or ['list'])[0]
|
||
payload = {'op': op, 'id': (query.get('id') or [''])[0]}
|
||
return self.json(self.helper('read:theme', payload, timeout=30))
|
||
# 写操作:save / delete / apply / reset / export / import / preview
|
||
if p in ('/api/theme', '/api/theme/op') and method == 'POST':
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
op = str(b.get('op') or '').strip()
|
||
readonly = {'list', 'get', 'vars'}
|
||
if not op:
|
||
return self.json({'ok': False, 'code': 'NOOP', 'msg_cn': '缺少 op 参数'})
|
||
if op in readonly:
|
||
return self.json(self.helper('read:theme', b, timeout=30))
|
||
# 导入可能带较大 base64,给足超时
|
||
to = 180 if op in ('import', 'import_apply') else 60
|
||
res = self.helper('theme', b, timeout=to)
|
||
audit(self.user, '主题之家', '%s -> %s' % (op, res.get('msg_cn')),
|
||
res.get('ok', False), self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
# ========== WAN 接入方式统一日志(#4) ==========
|
||
if p in ('/api/wan/log', '/api/wanlog', '/api/wan/access') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
payload = {
|
||
'access': b.get('access') or (query.get('access') or ['pppoe'])[0],
|
||
'iface': b.get('iface') or (query.get('iface') or [''])[0],
|
||
'limit': b.get('limit') or (query.get('limit') or ['120'])[0],
|
||
'since': b.get('since') or (query.get('since') or ['2 hours ago'])[0],
|
||
}
|
||
return self.json(self.helper('read:wan_log', payload, timeout=45))
|
||
|
||
# ========== 配置渲染预览(只读,不写盘) ==========
|
||
if p in ('/api/render', '/api/preview') and method == 'POST':
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
module = (b.get('module') or '').strip()
|
||
if module not in ('dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6',
|
||
'upnp', 'ntp', 'system', 'ppp', 'portfwd'):
|
||
return self.json({'ok': False, 'code': 'BAD_MODULE',
|
||
'msg_cn': '不支持预览该模块:%s' % module})
|
||
# portfwd 不是独立的渲染模块,它的规则并入 nft_v4/nft_v6
|
||
if module == 'portfwd':
|
||
module = 'nft_v4'
|
||
cfg = get_cfg(module, {}) or {}
|
||
if isinstance(b.get('data'), dict):
|
||
cfg = dict(cfg, **b['data'])
|
||
syscfg = get_cfg('system', {}) or {}
|
||
if module == 'upnp':
|
||
# 与 apply 路径保持一致,否则预览出来的 allow 网段
|
||
# 和真正生效的不一样(预览永远显示 192.168.0.0/16)
|
||
cfg['lan_address'] = (cfg.get('lan_address')
|
||
or syscfg.get('lan_address') or '')
|
||
if module in ('nft_v4', 'nft_v6'):
|
||
cfg['wan_iface'] = cfg.get('wan_iface') or syscfg.get('wan_iface') or 'ppp0'
|
||
cfg['lan_ifaces'] = cfg.get('lan_ifaces') or [syscfg.get('lan_iface') or 'ens18']
|
||
pf = get_cfg('portfwd', {}) or {}
|
||
if module == 'nft_v4':
|
||
cfg['portfwd'] = {'enable': pf.get('enable_v4', False),
|
||
'rules': [r for r in (pf.get('rules') or [])
|
||
if (r.get('family') or 'v4') != 'v6'],
|
||
'dmz': {'enable': pf.get('dmz_v4_enable', False),
|
||
'host': pf.get('dmz_v4_host') or ''}}
|
||
else:
|
||
cfg['portfwd'] = {'enable': pf.get('enable_v6', False),
|
||
'rules': [r for r in (pf.get('rules') or [])
|
||
if (r.get('family') or 'v4') == 'v6'],
|
||
'dmz': {'enable': pf.get('dmz_v6_enable', False),
|
||
'host': pf.get('dmz_v6_host') or ''}}
|
||
try:
|
||
res = self.helper('render_text', {'module': module, 'cfg': cfg}, timeout=20)
|
||
return self.json(res)
|
||
except Exception as e:
|
||
return self.json({'ok': False, 'code': 'RENDER_FAIL',
|
||
'msg_cn': '渲染失败:%s' % e})
|
||
|
||
# ========== 内网文件共享 SMB / NFS(#9) ==========
|
||
if p in ('/api/share', '/api/share/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'get').strip()
|
||
to = 60 if op in ('save', 'on', 'off') else 25
|
||
return self.json(self.helper('share', dict(b, op=op), timeout=to))
|
||
|
||
# ========== 外置存储设备(USB / Type-C / 雷电) ==========
|
||
# 挂载用 40s、格式化给 900s:大容量硬盘格式化可能要好几分钟,
|
||
# 超时设短了会出现「后台其实还在跑,前端却报失败」的错位。
|
||
if p in ('/api/storage', '/api/storage/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'get').strip()
|
||
to = 900 if op == 'format' else (40 if op in ('mount', 'umount') else 25)
|
||
return self.json(self.helper('storage', dict(b, op=op), timeout=to))
|
||
|
||
# ========== 访问控制 / 家长时间组(#8) ==========
|
||
if p in ('/api/acl', '/api/acl/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or ('get' if method == 'GET' else 'get')).strip()
|
||
if op == 'save':
|
||
return self.json(self.helper('acl', b, timeout=30))
|
||
if op == 'plan':
|
||
return self.json(self.helper('acl', dict(b, op='plan'), timeout=30))
|
||
to = 20
|
||
return self.json(self.helper('acl', dict(b, op=op), timeout=to))
|
||
|
||
# ========== Docker / Docker Compose 管理(#10) ==========
|
||
if p in ('/api/docker', '/api/docker/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'get').strip()
|
||
# 这些操作耗时较长,放长超时
|
||
slow = ('stack_up', 'stack_down', 'stack_restart', 'stack_pull',
|
||
'prune', 'logs', 'inspect')
|
||
to = 620 if op in slow else (70 if op != 'get' else 60)
|
||
return self.json(self.helper('docker', dict(b, op=op), timeout=to))
|
||
|
||
# ========== 动态域名 DDNS(#6) ==========
|
||
if p in ('/api/ddns', '/api/ddns/op') and method == 'POST':
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
op = (b.get('op') or 'get').strip()
|
||
if op == 'save':
|
||
# 校验服务商与必填字段
|
||
prov = b.get('provider') or 'custom'
|
||
cfg = {'enabled': bool(b.get('enabled')), 'provider': prov,
|
||
'domain': (b.get('domain') or '').strip(),
|
||
'subdomain': (b.get('subdomain') or '').strip(),
|
||
'ipv4': b.get('ipv4', True), 'ipv6': b.get('ipv6', False),
|
||
'ttl': b.get('ttl') or 300, 'interval': b.get('interval') or 300,
|
||
'url4': b.get('url4') or '', 'url6': b.get('url6') or '',
|
||
'fields': b.get('fields') or {}}
|
||
return self.json(self.helper('ddns', dict(cfg, op='save'), timeout=30))
|
||
to = 60 if op in ('test', 'update') else 20
|
||
return self.json(self.helper('ddns', b, timeout=to))
|
||
|
||
# ========== 真·公网 IP 判定(#2) ==========
|
||
# check 要并发打 5 个外部回显服务 + 一次 traceroute,给足超时;
|
||
# probe_* 只是启动 / 轮询状态,必须秒回,否则前端倒计时会卡。
|
||
if p in ('/api/pubip', '/api/pubip/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'check').strip()
|
||
to = 90 if op == 'check' else 15
|
||
return self.json(self.helper('pubip', b, timeout=to))
|
||
|
||
# ========== 磁盘与日志清理(#3) ==========
|
||
# run 要遍历 /var/log、/tmp 并执行 apt-get clean,扫盘慢,给足超时;
|
||
# status 只读 + 扫描,也要 30s(/var/log 递归)。
|
||
if p in ('/api/cleanup', '/api/cleanup/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'status').strip()
|
||
to = 180 if op == 'run' else 60
|
||
return self.json(self.helper('cleanup', b, timeout=to))
|
||
|
||
# ========== 内核转发与加速(#4) ==========
|
||
# save 会 modprobe + sysctl -w + systemctl 启停 snmpd,给足超时;
|
||
# status 只是读 /proc/sys 和几个 systemctl is-active。
|
||
if p in ('/api/kern', '/api/kern/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'status').strip()
|
||
to = 90 if op == 'save' else 30
|
||
return self.json(self.helper('kern', b, timeout=to))
|
||
|
||
# ========== Docker 引擎配置 daemon.json(#5) ==========
|
||
# mirror_test 要逐个 curl 七个源(每个最多 8 秒),restart 要等 docker 起来;
|
||
# 其余都是读文件或写文件,很快。
|
||
if p in ('/api/dcfg', '/api/dcfg/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'status').strip()
|
||
to = 200 if op == 'mirror_test' else (190 if op == 'restart' else 40)
|
||
return self.json(self.helper('dcfg', b, timeout=to))
|
||
|
||
# ========== 打印服务 CUPS / USB RAW 直通(#7) ==========
|
||
# save 会 cupsctl + 校验 + 重启 cups / cups-browsed / RAW 服务,给足超时;
|
||
# testpage 要 lp 排队;其余都是读文件与 lpstat。
|
||
if p in ('/api/print', '/api/print/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'status').strip()
|
||
to = 150 if op == 'save' else (100 if op in ('testpage', 'queue_add') else 40)
|
||
return self.json(self.helper('print', b, timeout=to))
|
||
|
||
# ========== AC / AP 管理中心 OpenSOHO(#10) ==========
|
||
# install 要下载并解压 13MB 压缩包、建用户、写单元、起服务,给足超时;
|
||
# probe 要挨个查 8 个集合,svc/save/secret 会 systemctl;status 只是读状态。
|
||
if p in ('/api/opensoho', '/api/opensoho/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'status').strip()
|
||
to = 280 if op == 'install' else (90 if op in ('svc', 'save', 'secret') else 45)
|
||
return self.json(self.helper('opensoho', b, timeout=to))
|
||
|
||
# ========== CA 证书管理 + SSL 测试(#11) ==========
|
||
# deploy 要备份 + 落盘 + 重启 drouter-web + 握手验证(最多再等 20 秒),
|
||
# 失败还要回滚再启一次 —— 给它 180 秒;ssltest 是外网握手,给 45 秒。
|
||
if p in ('/api/ca', '/api/ca/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'status').strip()
|
||
to = 180 if op in ('deploy', 'restore') else (60 if op == 'ssltest' else 45)
|
||
return self.json(self.helper('ca', b, timeout=to))
|
||
|
||
# ========== 新手向导 ==========
|
||
# probe 要 ping 外网 + dig 一个域名(各几秒);apply_step 最多一次提交
|
||
# 四个模块的重启;dial 是真正的 pppd 拨号,要等它协商完。
|
||
if p in ('/api/wizard', '/api/wizard/op') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or 'probe').strip()
|
||
to = 120 if op == 'dial' else (110 if op == 'apply_step' else 60)
|
||
return self.json(self.helper('wizard', b, timeout=to))
|
||
|
||
# ========== 防火墙滚动日志(#1) ==========
|
||
if p in ('/api/fw/log', '/api/fwlog') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
payload = {
|
||
'family': b.get('family') or (query.get('family') or ['all'])[0],
|
||
'limit': b.get('limit') or (query.get('limit') or ['200'])[0],
|
||
'action': b.get('action') or (query.get('action') or [''])[0],
|
||
'q': b.get('q') or (query.get('q') or [''])[0],
|
||
'since': b.get('since') or (query.get('since') or ['60 min ago'])[0],
|
||
}
|
||
return self.json(self.helper('read:fw_log', payload))
|
||
|
||
# 配置读写
|
||
if p == '/api/config' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
# 必须与 save_config 的 allowed 集合保持一致,否则会出现
|
||
# 「能保存但读不回来」的配置模块(theme 曾漏在这里)。
|
||
keys = ['dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6', 'upnp',
|
||
'ntp', 'system', 'pppoe', 'ddns', 'portfwd', 'acl', 'share',
|
||
'docker', 'ulog', 'theme']
|
||
out = get_cfg_many(keys, {})
|
||
return self.json({'ok': True, 'data': out})
|
||
if p == '/api/config' and method == 'POST':
|
||
return self.save_config()
|
||
|
||
if p == '/api/iface/save' and method == 'POST':
|
||
return self.iface_save()
|
||
if p == '/api/iface/meta' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
with _lock, db() as c:
|
||
rows = c.execute('SELECT * FROM ifaces').fetchall()
|
||
return self.json({'ok': True, 'data': [dict(r) for r in rows]})
|
||
|
||
if p == '/api/buildmode' and method == 'POST':
|
||
return self.build_mode()
|
||
# 前端也会用 GET 读取构建保护模式状态
|
||
if p == '/api/buildmode' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
bm = in_build_mode()
|
||
return self.json({'ok': True, 'data': {'enabled': bm, 'build_mode': bm},
|
||
'msg_cn': '构建保护模式已开启' if bm else '构建保护模式已关闭'})
|
||
if p == '/api/apply' and method == 'POST':
|
||
return self.apply_module()
|
||
if p == '/api/apply/raw' and method == 'POST':
|
||
return self.apply_raw()
|
||
|
||
if p == '/api/service' and method == 'POST':
|
||
return self.service_op()
|
||
if p == '/api/power' and method == 'POST':
|
||
return self.power_op()
|
||
if p == '/api/user' and method == 'POST':
|
||
return self.user_op()
|
||
if p == '/api/diag' and method == 'POST':
|
||
return self.diag()
|
||
if p == '/api/pkg' and method == 'POST':
|
||
return self.pkg()
|
||
if p == '/api/snapshot' and method == 'POST':
|
||
return self.snapshot()
|
||
if p in ('/api/snapshot/list', '/api/snapshots') and method == 'GET':
|
||
return self.snapshot_list()
|
||
if p in ('/api/snapshot/delete',) and method == 'POST':
|
||
return self.snapshot_delete()
|
||
if p in ('/api/snapshot/prune',) and method == 'POST':
|
||
return self.snapshot_prune()
|
||
# 快照备注编辑 / 上锁解锁(两者都只改 _meta.json,不碰快照内容)
|
||
if p in ('/api/snapshot/note',) and method == 'POST':
|
||
return self.snapshot_note()
|
||
if p in ('/api/snapshot/protect',) and method == 'POST':
|
||
return self.snapshot_protect()
|
||
# 自动快照策略(查询 / 保存 / 立即执行)
|
||
if p in ('/api/snapshot/auto',) and method in ('GET', 'POST'):
|
||
return self.auto_snapshot()
|
||
# 快照下载到本地电脑
|
||
if p.startswith('/api/snapshot/download'):
|
||
return self.snapshot_download(query)
|
||
# 配置备份与还原(查询 / 导出 / 下载 / 预检 / 还原 / 清理 / 设置)
|
||
if p in ('/api/backup', '/api/backup/op') and method in ('GET', 'POST'):
|
||
return self.backup(method, p, query)
|
||
if p.startswith('/api/backup/download'):
|
||
return self.backup_download(query)
|
||
# 自动备份定时器状态
|
||
if p in ('/api/backupd',) and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
return self.json(self.helper('read:backupd', {}, timeout=30))
|
||
# 告警与通知中心
|
||
if p in ('/api/alert', '/api/alert/op') and method in ('GET', 'POST'):
|
||
return self.alert(method)
|
||
# WireGuard VPN
|
||
if p in ('/api/vpn', '/api/vpn/op') and method in ('GET', 'POST'):
|
||
return self.vpn(method)
|
||
# 用量统计(配额与账单)
|
||
if p in ('/api/quota', '/api/quota/op') and method in ('GET', 'POST'):
|
||
return self.quota(method, query)
|
||
# 紧急救援通道(查询 / 配置)
|
||
if p in ('/api/rescue',) and method in ('GET', 'POST'):
|
||
return self.rescue()
|
||
if p == '/api/rollback' and method == 'POST':
|
||
return self.rollback()
|
||
if p in ('/api/audit', '/api/audits') and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
return self.json({'ok': True, 'data': read_audits()})
|
||
if p == '/api/migrate/preview' and method == 'POST':
|
||
return self.migrate_preview()
|
||
# Web 管理端口:读取 / 修改
|
||
if p in ('/api/webport',) and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
return self.json({'ok': True, 'data': {'port': load_web_port(),
|
||
'default': DEFAULT_WEB_PORT}})
|
||
if p in ('/api/webport',) and method == 'POST':
|
||
return self.set_web_port()
|
||
if p in ('/api/lease/static', '/api/lease/make_static') and method == 'POST':
|
||
return self.lease_make_static()
|
||
if p in ('/api/lease/release',) and method == 'POST':
|
||
return self.lease_release()
|
||
if p in ('/api/ppp', '/api/ppp/control') and method == 'POST':
|
||
return self.ppp_control()
|
||
if p == '/api/upgrade/info' and method == 'GET':
|
||
return self.upgrade_info()
|
||
# 只读:查询当前已保存的 PPPoE 配置(不返回明文密码)
|
||
if p == '/api/pppoe/saved' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
c_ = get_cfg('pppoe', {}) or {}
|
||
safe = {k: v for k, v in c_.items() if k != 'password'}
|
||
safe['has_password'] = bool(c_.get('password'))
|
||
return self.json({'ok': True, 'data': safe})
|
||
|
||
# ========== NAT 类型自动检测(#20) ==========
|
||
if p in ('/api/nat/check', '/api/natcheck') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
res = self.helper('nat_check', {}, timeout=40)
|
||
if res.get('ok') and res.get('data'):
|
||
try:
|
||
NAT_LAST.update({k: res['data'].get(k, '') for k in
|
||
('nat', 'title', 'desc', 'detail', 'checked_at')})
|
||
except Exception:
|
||
pass
|
||
return self.json(res)
|
||
# 历史结果缓存(前端画"上次检测"用)
|
||
if p == '/api/nat/last' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
# 必须读落盘的那份:NAT_LAST 只是本进程的内存变量,Web 服务一重启
|
||
# (每次部署都会)就清空,页面上的「上次检测」会凭空消失,看起来像
|
||
# 从来没检测过。helper 在每次检测后已经写进 settings 表了。
|
||
saved = {}
|
||
try:
|
||
r = self.helper('read:nat_last', {}, timeout=20)
|
||
if r.get('ok') and isinstance(r.get('data'), dict):
|
||
saved = r['data']
|
||
except Exception:
|
||
saved = {}
|
||
if saved.get('nat') and saved.get('checked_at'):
|
||
return self.json({'ok': True, 'data': saved})
|
||
# 落盘为空时退回本进程内存(本次启动后刚检测过、还没走完落盘的情况)
|
||
return self.json({'ok': True, 'data': NAT_LAST})
|
||
|
||
# ========== nftables flowtable 软加速(#22,与 NAT 检测同页) ==========
|
||
if p in ('/api/accel',) and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
op = (b.get('op') or (query.get('op') or ['get'])[0]).strip()
|
||
payload = {'op': op}
|
||
if b.get('devices'):
|
||
payload['devices'] = b.get('devices')
|
||
if 'v4' in b:
|
||
payload['v4'] = bool(b.get('v4'))
|
||
if 'v6' in b:
|
||
payload['v6'] = bool(b.get('v6'))
|
||
return self.json(self.helper('accel', payload, timeout=40))
|
||
|
||
# ========== VLAN(#21) ==========
|
||
if p in ('/api/vlan',) and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('vlan', {'op': 'get'}))
|
||
b = self.body()
|
||
return self.json(self.helper('vlan', b, timeout=60))
|
||
|
||
# ========== WOL 唤醒(#21) ==========
|
||
if p in ('/api/wol',) and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('wol', {'op': 'get'}))
|
||
b = self.body()
|
||
return self.json(self.helper('wol', b, timeout=40))
|
||
|
||
# ========== PPPoE 多拨(#23) ==========
|
||
if p in ('/api/pppoe-multi', '/api/pppomulti') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('pppoe_multi', {'op': 'get'}))
|
||
b = self.body()
|
||
# apply 会真正拨号并改路由,给足超时
|
||
to = 180 if (b.get('op') == 'apply') else 60
|
||
return self.json(self.helper('pppoe_multi', b, timeout=to))
|
||
if p == '/api/pppoe-multi/log' and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if method == 'POST' else {}
|
||
idx = b.get('idx') or (query.get('idx') or ['1'])[0]
|
||
return self.json(self.helper('pppoe_multi', {'op': 'log', 'idx': idx}, timeout=40))
|
||
|
||
# ========== QoS 智能限速(#24) ==========
|
||
if p in ('/api/qos',) and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('qos', {'op': 'get'}))
|
||
b = self.body()
|
||
to = 120 if b.get('op') in ('on', 'off') else 60
|
||
return self.json(self.helper('qos', b, timeout=to))
|
||
|
||
# ========== DPI 识别库(#25) ==========
|
||
if p in ('/api/dpi',) and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('dpi', {'op': 'get'}))
|
||
b = self.body()
|
||
# update 可能拉几百 MB,给足超时
|
||
to = 900 if b.get('op') == 'update' else 120
|
||
return self.json(self.helper('dpi', b, timeout=to))
|
||
if p == '/api/dpi/log' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
return self.json(self.helper('dpi', {'op': 'log'}, timeout=30))
|
||
|
||
# ========== 运行依赖自检 + 一键安装(#19) ==========
|
||
if p in ('/api/deps/check', '/api/depcheck') and method in ('GET', 'POST'):
|
||
if not self.auth():
|
||
return
|
||
return self.json(self.helper('depcheck', {'op': 'check'}, timeout=90))
|
||
if p == '/api/deps/install' and method == 'POST':
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
# 前端逐项「安装」发的是 keys;只认 only 会把 keys 丢掉,
|
||
# helper 收到空列表就走「只装必需项」分支——必需项全齐时
|
||
# 直接返回「无需操作」,用户点了安装却什么都没装(1.0.5 实测 bug)。
|
||
keys = b.get('keys') or b.get('only') or []
|
||
return self.json(self.helper('depcheck', {'op': 'install',
|
||
'only': keys},
|
||
timeout=900))
|
||
if p == '/api/deps/log' and method == 'GET':
|
||
if not self.auth():
|
||
return
|
||
return self.json(self.helper('depcheck', {'op': 'log'}))
|
||
|
||
# ========== 版本检测 / 自更新(概述页)============
|
||
if p.startswith('/api/update'):
|
||
if not self.auth():
|
||
return
|
||
return self.update_api(p, query, method)
|
||
|
||
# ========== 对外通用 API 说明 + 文本范例(#17) ==========
|
||
if p in ('/api/openapi', '/api/openapi.json') and method == 'GET':
|
||
return self.openapi_descriptor()
|
||
if p in ('/api/examples',) and method == 'GET':
|
||
return self.api_examples()
|
||
|
||
# ========== Web 终端(#18) ==========
|
||
if p.startswith('/api/webshell'):
|
||
if not self.auth():
|
||
return
|
||
return self.webshell_api(p)
|
||
|
||
# ========== Web 远程文件管理(#18) ==========
|
||
if p.startswith('/api/files'):
|
||
if not self.auth():
|
||
return
|
||
return self.files_api(p, query, method)
|
||
|
||
return self.json({'ok': False, 'code': 'NOTFOUND',
|
||
'msg_cn': '接口不存在:%s' % p}, 404)
|
||
|
||
# ---------- 版本检测 / 自更新 ----------
|
||
def _load_mod(self, name):
|
||
"""按文件路径加载 backend/ 下的模块,**结果缓存**。
|
||
|
||
⚠️ 三个坑叠在一起,改之前先读完:
|
||
|
||
1) **不能写 `import drouter_update`** —— 文件名带连字符
|
||
(drouter-update.py),不是合法 Python 标识符,import 必然
|
||
ModuleNotFoundError。项目里 drouter-ddnsd.py / drouter-helpd.py
|
||
都是用 importlib 按路径加载的,这里跟着同一套做法。
|
||
|
||
2) ⛔ **必须缓存,否则模块级状态全部失效**(1.0.10 实测踩到):
|
||
每次请求都 `exec_module` 会得到**全新的模块对象**,于是
|
||
`drouter-update.py` 里的 `_stop_flag = threading.Event()`、
|
||
`_lock`、`_worker` 每个请求都是新的。后果:
|
||
· `apply_update` 在实例 A 上 clear(),
|
||
· 用户点「中断」→ 新请求 → 新实例 B → `cancel()` 在 B 上 set(),
|
||
· **对 A 毫无影响** → 169MB 继续下完,但界面显示「正在取消」。
|
||
这是本项目最典型的「报成功但没做」。同理 `_lock` 失效会让并发
|
||
点「开始更新」的请求各自起一个 worker,同时写同一个 `.part`
|
||
文件 → 产出损坏的安装包。
|
||
|
||
3) **缓存 key 要带 mtime** —— 部署新版本后模块内容变了,
|
||
但文件名没变,不看 mtime 就永远用旧代码。
|
||
"""
|
||
path = os.path.join(_HERE, name + '.py')
|
||
if not os.path.isfile(path):
|
||
raise IOError('模块不存在:%s' % path)
|
||
try:
|
||
mt = os.stat(path).st_mtime
|
||
except OSError:
|
||
mt = 0
|
||
with _MOD_CACHE_LOCK:
|
||
hit = _MOD_CACHE.get(name)
|
||
if hit and hit[0] == mt:
|
||
return hit[1]
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location(
|
||
name.replace('-', '_'), path)
|
||
m = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(m)
|
||
_MOD_CACHE[name] = (mt, m)
|
||
return m
|
||
|
||
def update_api(self, p, query, method):
|
||
"""/api/update/* —— 概述页的「检测新版本 / 一键更新」。
|
||
|
||
刻意**不自动安装**:下载+校验后停住,安装动作交给用户或 apt。
|
||
一次点击就默默改系统,不是这个面板该有的行为。
|
||
"""
|
||
U = self._load_mod('drouter-update')
|
||
if p == '/api/update/check':
|
||
force = (query.get('force') or ['0'])[0] in ('1', 'true', 'yes')
|
||
r = U.check(force=force)
|
||
# ⚠️ assets 现在**无论有没有更新都要给**(1.0.10 改)。
|
||
# 原先只在 has_update 时给,于是「已是最新」的用户打开
|
||
# 「查看版本发布说明」窗口时拿不到附件清单,
|
||
# 里面就没有「下载地址 / 安装命令」——
|
||
# 而那些恰恰是「已是最新」的用户最想看的东西
|
||
# (别人问「这版能不能装」时直接复制命令用)。
|
||
if r.get('ok') and r.get('latest'):
|
||
r['assets'] = U.asset_list(r['latest'])
|
||
else:
|
||
r['assets'] = []
|
||
return self.json({'ok': True, 'data': r})
|
||
if p == '/api/update/state':
|
||
return self.json({'ok': True, 'data': U.read_state()})
|
||
if p == '/api/update/apply' and method == 'POST':
|
||
b = self.body()
|
||
r = U.apply_update(b.get('version') or '', b.get('asset') or 'deb',
|
||
backup=b.get('backup', True))
|
||
return self.json(r)
|
||
if p == '/api/update/cancel' and method == 'POST':
|
||
return self.json(U.cancel())
|
||
return self.json({'ok': False, 'code': 'NOTFOUND',
|
||
'msg_cn': '接口不存在:%s' % p}, 404)
|
||
|
||
# ---------- 具体实现 ----------
|
||
def login(self):
|
||
ip = self.h.client_address[0]
|
||
f = LOGIN_FAILS.get(ip)
|
||
if f and f['n'] >= MAX_LOGIN_FAILS and f['until'] > time.time():
|
||
left = int((f['until'] - time.time()) / 60) + 1
|
||
return self.json({'ok': False, 'code': 'LOCKED',
|
||
'msg_cn': '登录失败次数过多,请 %d 分钟后再试' % left}, 429)
|
||
b = self.body()
|
||
u = (b.get('username') or '').strip()
|
||
pw = b.get('password') or ''
|
||
with _lock, db() as c:
|
||
row = c.execute('SELECT * FROM admins WHERE username=?', (u,)).fetchone()
|
||
if not row or hash_pw(pw, row['salt']) != row['pw_hash']:
|
||
rec = LOGIN_FAILS.setdefault(ip, {'n': 0, 'until': 0})
|
||
rec['n'] += 1
|
||
rec['until'] = time.time() + LOGIN_LOCK_MINUTES * 60 if rec['n'] >= MAX_LOGIN_FAILS else 0
|
||
audit(u or '(空)', '登录失败', ip, False, ip)
|
||
left = MAX_LOGIN_FAILS - rec['n']
|
||
return self.json({'ok': False, 'code': 'BADCRED',
|
||
'msg_cn': '用户名或密码错误' + ('(还可尝试 %d 次)' % left if left > 0 else '')}, 401)
|
||
LOGIN_FAILS.pop(ip, None)
|
||
s = new_session(u, ip)
|
||
tok = [k for k, v in SESSIONS.items() if v is s][0]
|
||
audit(u, '登录成功', ip, True, ip)
|
||
return self.json({'ok': True, 'msg_cn': '登录成功',
|
||
'data': {'token': tok,
|
||
'csrf': s['csrf'], 'user': u,
|
||
'expires_in': SESSION_MINUTES * 60}})
|
||
|
||
def change_password(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
old = b.get('old_password') or ''
|
||
new = b.get('new_password') or ''
|
||
if len(new) < 8:
|
||
return self.json({'ok': False, 'code': 'WEAK', 'msg_cn': '新密码至少 8 位'})
|
||
with _lock, db() as c:
|
||
row = c.execute('SELECT * FROM admins WHERE username=?', (self.user,)).fetchone()
|
||
if not row or hash_pw(old, row['salt']) != row['pw_hash']:
|
||
return self.json({'ok': False, 'code': 'BADOLD', 'msg_cn': '原密码不正确'})
|
||
salt = secrets.token_hex(16)
|
||
c.execute('UPDATE admins SET pw_hash=?,salt=? WHERE username=?',
|
||
(hash_pw(new, salt), salt, self.user))
|
||
audit(self.user, '修改Web密码', '成功')
|
||
return self.json({'ok': True, 'msg_cn': 'Web 管理密码已修改'})
|
||
|
||
def save_config(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
module = b.get('module')
|
||
data = b.get('data')
|
||
allowed = {'dnsmasq', 'nft_v4', 'nft_v6', 'radvd', 'dhcpv6', 'upnp', 'ntp', 'system', 'pppoe', 'ddns', 'portfwd', 'acl', 'share', 'docker', 'ulog', 'theme'}
|
||
if module not in allowed:
|
||
return self.json({'ok': False, 'code': 'BADMODULE', 'msg_cn': '未知配置模块'})
|
||
if not isinstance(data, dict):
|
||
return self.json({'ok': False, 'code': 'BADDATA', 'msg_cn': '配置格式不正确'})
|
||
merge_cfg(module, data)
|
||
audit(self.user, '保存配置', module)
|
||
return self.json({'ok': True, 'msg_cn': '已保存(尚未应用)', 'data': get_cfg(module)})
|
||
|
||
def iface_save(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
mac = (b.get('mac') or '').strip().lower()
|
||
if not re.match(r'^([0-9a-f]{2}:){5}[0-9a-f]{2}$', mac):
|
||
return self.json({'ok': False, 'code': 'BADMAC', 'msg_cn': 'MAC 地址格式不正确'})
|
||
name = (b.get('name') or '').strip()
|
||
remark = (b.get('remark') or '').strip()[:128]
|
||
role = (b.get('role') or '').strip()
|
||
ALLOWED_ROLES = ('', 'wan', 'lan', 'bridge', 'guest', 'iot', 'dmz', 'mgmt', 'unused')
|
||
if role not in ALLOWED_ROLES:
|
||
return self.json({'ok': False, 'code': 'BADROLE',
|
||
'msg_cn': '角色取值不合法,请从下拉列表中选择'})
|
||
with _lock, db() as c:
|
||
c.execute('INSERT INTO ifaces(mac,name,remark,role,updated_at) VALUES(?,?,?,?,?) '
|
||
'ON CONFLICT(mac) DO UPDATE SET name=excluded.name, remark=excluded.remark, '
|
||
'role=excluded.role, updated_at=excluded.updated_at',
|
||
(mac, name, remark, role, now()))
|
||
# 角色联动到 system 配置
|
||
syscfg = get_cfg('system', {}) or {}
|
||
if role == 'wan':
|
||
syscfg['wan_iface'] = name
|
||
syscfg['wan_present'] = True
|
||
elif role == 'lan':
|
||
syscfg['lan_iface'] = name
|
||
set_cfg('system', syscfg)
|
||
audit(self.user, '编辑网卡', '%s %s 备注=%s 角色=%s' % (mac, name, remark, role))
|
||
return self.json({'ok': True, 'msg_cn': '网卡信息已保存'})
|
||
|
||
def apply_module(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
module = b.get('module')
|
||
data = b.get('data')
|
||
live = bool(b.get('live')) # 默认 False:只写盘不生效
|
||
check_only = bool(b.get('check_only'))
|
||
# ⚠️ 白名单必须在写库**之前**判。
|
||
# 早先这里没有校验,于是 `POST /api/apply {"module":"任意字符串"}`
|
||
# 会把任意 module 写进 settings 表(merge_cfg → set_cfg 直落库),
|
||
# 然后才在 helper 的 APPLY_SPEC 里被拒 —— 接口返回
|
||
# 「未知的模块」,但**配置已经写进去了**。用户完全不知道。
|
||
# 这就是本项目最典型的「报失败但副作用已发生」。
|
||
# 取值与 helper 的 APPLY_SPEC 保持一致(APPLY_SPEC 是权威定义)。
|
||
if module not in APPLY_MODULES:
|
||
return self.json({'ok': False, 'code': 'BADMODULE',
|
||
'msg_cn': '未知配置模块'})
|
||
# 预检绝不写配置库:只该校验「传进来的这份数据能不能渲染成合法配置」,
|
||
# 不该把这份数据变成「当前配置」。否则点一次「仅语法检查」
|
||
# 就等于悄悄保存了一次,用户完全没察觉。
|
||
if isinstance(data, dict) and not check_only:
|
||
merge_cfg(module, data)
|
||
if check_only and isinstance(data, dict):
|
||
# 预检只看「这次提交的这份」,以它为准,不受库里旧值影响
|
||
cfg = dict(get_cfg(module, {}) or {}, **data)
|
||
else:
|
||
cfg = get_cfg(module, {})
|
||
# 注入必要的上下文(LAN/WAN 接口名)
|
||
syscfg = get_cfg('system', {}) or {}
|
||
if module == 'dnsmasq':
|
||
cfg['lan_iface'] = cfg.get('lan_iface') or syscfg.get('lan_iface') or 'ens18'
|
||
elif module in ('nft_v4', 'nft_v6'):
|
||
cfg['wan_iface'] = cfg.get('wan_iface') or syscfg.get('wan_iface') or 'ppp0'
|
||
cfg['lan_ifaces'] = cfg.get('lan_ifaces') or [syscfg.get('lan_iface') or 'ens18']
|
||
# 注入端口转发 / DMZ 配置(#7),由 render.py 生成 DNAT 与放通规则
|
||
pf = get_cfg('portfwd', {}) or {}
|
||
if module == 'nft_v4':
|
||
cfg['portfwd'] = {'enable': pf.get('enable_v4', pf.get('enable', False)),
|
||
'rules': [r for r in (pf.get('rules') or [])
|
||
if (r.get('family') or 'v4') != 'v6'],
|
||
'dmz': {'enable': pf.get('dmz_v4_enable', False),
|
||
'host': pf.get('dmz_v4_host') or ''}}
|
||
else:
|
||
cfg['portfwd'] = {'enable': pf.get('enable_v6', False),
|
||
'rules': [r for r in (pf.get('rules') or [])
|
||
if (r.get('family') or 'v4') == 'v6'],
|
||
'dmz': {'enable': pf.get('dmz_v6_enable', False),
|
||
'host': pf.get('dmz_v6_host') or ''}}
|
||
elif module == 'radvd':
|
||
cfg['iface'] = cfg.get('iface') or syscfg.get('lan_iface') or 'ens18'
|
||
elif module == 'dhcpv6':
|
||
cfg['wan_iface'] = cfg.get('wan_iface') or syscfg.get('wan_iface') or 'ppp0'
|
||
cfg['lan_iface'] = cfg.get('lan_iface') or syscfg.get('lan_iface') or 'ens18'
|
||
elif module == 'upnp':
|
||
cfg['ext_iface'] = cfg.get('ext_iface') or syscfg.get('wan_iface') or 'ppp0'
|
||
# lan_address 也要:miniupnpd 的 allow 规则网段取自它,
|
||
# 不注入的话自定义网段(10.x/172.x)的用户 UPnP 永远被 deny,
|
||
# 而界面上开关是开的、没有任何提示。
|
||
cfg['lan_address'] = (cfg.get('lan_address')
|
||
or syscfg.get('lan_address') or '')
|
||
elif module in ('ppp', 'pppoe'):
|
||
# ⚠️ 这里必须同时认 'pppoe':前端 PAGE_MODULES 里声明的模块名就是
|
||
# pppoe(`web/app.js` 的 `wan: { save:['pppoe'], apply:['pppoe'] }`),
|
||
# 而渲染器里的别名才叫 ppp。早先只写 'ppp',这条回落永远不生效 ——
|
||
# 界面上「拨号网卡」看着是选中的,点保存却报「PPPoE 网卡不能为空」。
|
||
# PPPoE 拨号网卡:优先用用户填写值,其次回落到已分配的 WAN 口;
|
||
# 若都没有,且当前只有一个物理网卡,则直接用它(单口调试场景)
|
||
cfg['iface'] = (cfg.get('iface') or '').strip() or syscfg.get('wan_iface') or ''
|
||
if not cfg['iface']:
|
||
# 从已保存的网卡备注中找角色为 wan 的
|
||
with _lock, db() as c:
|
||
row = c.execute("SELECT name FROM ifaces WHERE role='wan' LIMIT 1").fetchone()
|
||
if row:
|
||
cfg['iface'] = row['name']
|
||
if not cfg['iface']:
|
||
return self.json({
|
||
'ok': False, 'code': 'NO_WAN_IFACE',
|
||
'msg_cn': '尚未指定 PPPoE 拨号网卡。请先在「网卡与桥接」中把某张网卡角色设为 WAN,'
|
||
'或在本页的「拨号网卡」下拉框中手动选择。'})
|
||
res = self.helper('apply', {'module': module, 'cfg': cfg,
|
||
'live': live, 'check_only': check_only})
|
||
audit(self.user, '应用配置', '%s live=%s -> %s' % (module, live, res.get('msg_cn')),
|
||
res.get('ok', False), self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
def apply_raw(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
module = b.get('module')
|
||
if module not in ('nft_v4', 'nft_v6'):
|
||
return self.json({'ok': False, 'code': 'BADMODULE', 'msg_cn': '仅支持防火墙规则原文应用'})
|
||
raw = b.get('raw') or ''
|
||
if len(raw) > 200000:
|
||
return self.json({'ok': False, 'code': 'TOOBIG', 'msg_cn': '规则内容过大'})
|
||
cfg = get_cfg(module, {}) or {}
|
||
cfg['raw'] = raw
|
||
set_cfg(module, cfg)
|
||
res = self.helper('apply', {'module': module, 'cfg': cfg,
|
||
'live': bool(b.get('live')),
|
||
'check_only': bool(b.get('check_only'))})
|
||
audit(self.user, '应用防火墙原文', module, res.get('ok', False), self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
def service_op(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('service', {'name': b.get('name'), 'op': b.get('op')})
|
||
audit(self.user, '服务操作', '%s %s' % (b.get('name'), b.get('op')), res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def power_op(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
op = b.get('op')
|
||
if op == 'force_reboot' and not b.get('confirm_text') == '确认强制重启':
|
||
return self.json({'ok': False, 'code': 'NEEDCONFIRM',
|
||
'msg_cn': '强制重启风险极高,请输入「确认强制重启」以继续'})
|
||
audit(self.user, '电源操作', op, True, self.h.client_address[0])
|
||
return self.json(self.helper('power', {'op': op}))
|
||
|
||
def user_op(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('user', b)
|
||
audit(self.user, '用户管理', '%s %s' % (b.get('op'), b.get('name')), res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def diag(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('diag', b, timeout=90)
|
||
audit(self.user, '诊断工具', '%s %s' % (b.get('tool'), b.get('target') or b.get('iface') or ''),
|
||
res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def pkg(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('pkg', b, timeout=960)
|
||
audit(self.user, '软件包管理', '%s %s' % (b.get('op'), ','.join(b.get('packages') or [])[:200]),
|
||
res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def snapshot(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('snapshot', {'tag': b.get('tag') or 'manual',
|
||
'protected': b.get('protected')})
|
||
audit(self.user, '创建快照', res.get('msg_cn'), res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def snapshot_list(self):
|
||
if not self.auth():
|
||
return
|
||
return self.json(self.helper('snapshot_list'))
|
||
|
||
def snapshot_delete(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
# force 只在用户对「已上锁」那份点了二次确认之后才为真。
|
||
# 审计要记下这是强制删除,否则日志上只看得到「删了快照」,
|
||
# 看不出删的是用户特意保下来的那一份。
|
||
res = self.helper('snapshot_delete', {'ts': b.get('ts'),
|
||
'force': b.get('force')})
|
||
if res.get('ok'):
|
||
audit(self.user, '删除快照',
|
||
'%s%s' % (b.get('ts'), '(受保护,已强制删除)' if b.get('force') else ''),
|
||
True)
|
||
return self.json(res)
|
||
|
||
def snapshot_note(self):
|
||
"""改快照备注。"""
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
payload = {'ts': b.get('ts')}
|
||
# 允许清空:前端传 tag:''(空串)而不是不传,所以要按 key 在不在
|
||
# 来区分「清空」和「没改这个字段」。
|
||
if 'tag' in b:
|
||
payload['tag'] = b.get('tag')
|
||
else:
|
||
payload['tag'] = b.get('note')
|
||
res = self.helper('snapshot_note', payload)
|
||
if res.get('ok'):
|
||
audit(self.user, '修改快照备注', '%s → %s' % (b.get('ts'), payload['tag']), True)
|
||
return self.json(res)
|
||
|
||
def snapshot_protect(self):
|
||
"""给/取消快照上锁。"""
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
# ⚠️ 只有用户**真的传了** protected 才透传。
|
||
# 无条件写进 payload(哪怕值是 None)会让 helper 的
|
||
# `if 'protected' in p` 永远成立,于是「不传就取反」这条语义
|
||
# 从 HTTP 这条路上彻底失效 —— 而前端的上锁开关正是靠取反工作的,
|
||
# 表现是「点一下开关没反应」。判「传没传」要按 key 在不在,
|
||
# 不能按值是否为真。
|
||
payload = {'ts': b.get('ts')}
|
||
if 'protected' in b:
|
||
payload['protected'] = b.get('protected')
|
||
res = self.helper('snapshot_protect', payload)
|
||
if res.get('ok'):
|
||
audit(self.user, '快照保护',
|
||
'%s %s' % (b.get('ts'), res.get('msg_cn')), True)
|
||
return self.json(res)
|
||
|
||
def snapshot_prune(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('snapshot_prune', {
|
||
'keep_days': b.get('keep_days'), 'keep_count': b.get('keep_count'),
|
||
'keep_manual': b.get('keep_manual', True)})
|
||
if res.get('ok'):
|
||
audit(self.user, '清理快照', res.get('msg_cn'), True)
|
||
return self.json(res)
|
||
|
||
def auto_snapshot(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if self._is_post() else {}
|
||
op = b.get('op') or 'get'
|
||
payload = dict(b)
|
||
payload['op'] = op
|
||
res = self.helper('auto_snapshot', payload)
|
||
if res.get('ok') and op == 'set':
|
||
audit(self.user, '自动快照策略', res.get('msg_cn'), True)
|
||
return self.json(res)
|
||
|
||
def snapshot_download(self, query):
|
||
"""把一份快照打包成 tar.gz 供用户下载到本地电脑。"""
|
||
if not self.auth():
|
||
return
|
||
ts = (query.get('ts') or [''])[0].strip()
|
||
if not re.match(r'^\d{8}-\d{6}$', ts):
|
||
return self.json({'ok': False, 'msg_cn': '快照编号格式不正确'})
|
||
res = self.helper('snapshot_pack', {'ts': ts})
|
||
if not res.get('ok'):
|
||
return self.json(res)
|
||
path = (res.get('data') or {}).get('path') or ''
|
||
if not path or not os.path.isfile(path):
|
||
return self.json({'ok': False, 'msg_cn': '打包文件生成失败'})
|
||
try:
|
||
with open(path, 'rb') as f:
|
||
data = f.read()
|
||
except Exception as e:
|
||
return self.json({'ok': False, 'msg_cn': '读取打包文件失败:%s' % e})
|
||
fn = 'drouter-snapshot-%s.tar.gz' % ts
|
||
self.h.send_response(200)
|
||
self.h.send_header('Content-Type', 'application/gzip')
|
||
self.h.send_header('Content-Disposition', 'attachment; filename="%s"' % fn)
|
||
self.h.send_header('Content-Length', str(len(data)))
|
||
self.h.send_header('Cache-Control', 'no-store')
|
||
self.h.end_headers()
|
||
try:
|
||
self.h.wfile.write(data)
|
||
except Exception:
|
||
pass
|
||
audit(self.user, '下载快照', ts, True)
|
||
|
||
def backup(self, method, p, query):
|
||
"""配置备份与还原。
|
||
|
||
GET -> status(顺带返回备份包列表)
|
||
POST -> {op: create|list|delete|pack|inspect|restore|prune|conf}
|
||
|
||
还原是唯一会「大面积改写磁盘」的动作,所以强制要求显式确认:
|
||
没有 confirm=true 就只跑 dry_run 预检并把计划返回给界面。
|
||
"""
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('backup', {'op': 'status'}, timeout=120))
|
||
b = self.body()
|
||
op = str(b.get('op') or 'status')
|
||
payload = dict(b)
|
||
payload['op'] = op
|
||
if op == 'restore':
|
||
if b.get('confirm') is not True:
|
||
# 未确认:强制预检,不写任何文件
|
||
payload['dry_run'] = True
|
||
res = self.helper('backup', payload, timeout=600)
|
||
if res.get('ok') and b.get('confirm') is True:
|
||
audit(self.user, '还原配置',
|
||
'从备份包 %s 还原了 %s 个文件'
|
||
% (b.get('name'), (res.get('data') or {}).get('applied', 0)),
|
||
True, self.h.client_address[0])
|
||
return self.json(res)
|
||
if op == 'create':
|
||
res = self.helper('backup', payload, timeout=600)
|
||
if res.get('ok'):
|
||
audit(self.user, '导出配置备份',
|
||
(res.get('msg_cn') or ''), True, self.h.client_address[0])
|
||
return self.json(res)
|
||
if op in ('delete', 'prune'):
|
||
res = self.helper('backup', payload, timeout=120)
|
||
if res.get('ok'):
|
||
audit(self.user, '清理配置备份', (res.get('msg_cn') or ''), True)
|
||
return self.json(res)
|
||
return self.json(self.helper('backup', payload, timeout=300))
|
||
|
||
def backup_download(self, query):
|
||
"""把一份备份包送到浏览器。"""
|
||
if not self.auth():
|
||
return
|
||
name = (query.get('name') or [''])[0]
|
||
res = self.helper('backup', {'op': 'pack', 'name': name}, timeout=300)
|
||
if not res.get('ok'):
|
||
return self.json(res)
|
||
path = (res.get('data') or {}).get('path') or ''
|
||
if not path or not os.path.isfile(path):
|
||
return self.json({'ok': False, 'msg_cn': '准备下载文件失败'})
|
||
try:
|
||
with open(path, 'rb') as f:
|
||
data = f.read()
|
||
except Exception as e:
|
||
return self.json({'ok': False, 'msg_cn': '读取备份文件失败:%s' % e})
|
||
fn = os.path.basename(path)
|
||
quoted = fn.encode('utf-8').decode('latin-1', 'ignore')
|
||
self.h.send_response(200)
|
||
self.h.send_header('Content-Type', 'application/gzip')
|
||
self.h.send_header('Content-Disposition',
|
||
"attachment; filename=\"%s\"; filename*=UTF-8''%s"
|
||
% (quoted, _url_quote(fn)))
|
||
self.h.send_header('Content-Length', str(len(data)))
|
||
self.h.send_header('Cache-Control', 'no-store')
|
||
self.h.end_headers()
|
||
try:
|
||
self.h.wfile.write(data)
|
||
except Exception:
|
||
pass
|
||
audit(self.user, '下载配置备份', fn, True)
|
||
|
||
def alert(self, method):
|
||
"""告警与通知中心。
|
||
|
||
GET -> status(含规则、通道、探测值、历史)
|
||
POST -> {op: run|test|conf|history|clear|services}
|
||
|
||
推送通道里存着 SMTP 密码,所以读取时一律把 pass 抹掉。
|
||
抹掉之后前端表单里那个字段必然是空的 —— 如果原样回传,
|
||
一次「只改了阈值」的点保存就会把密码清成空串,用户下次再也收不到邮件,
|
||
而且界面上完全看不出发生过。补密码的动作必须放在 helper 侧:
|
||
只有它读得到旧值,Web 层拿不到明文。
|
||
"""
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
res = self.helper('alert', {'op': 'status'}, timeout=120)
|
||
else:
|
||
b = self.body()
|
||
op = str(b.get('op') or 'status')
|
||
res = self.helper('alert', dict(b, op=op), timeout=300)
|
||
if res.get('ok') and isinstance(res.get('data'), dict):
|
||
d = res['data']
|
||
if isinstance(d.get('conf'), dict) and isinstance(
|
||
d['conf'].get('channels'), list):
|
||
d['conf']['channels'] = [
|
||
{k: v for k, v in c.items() if k != 'pass'}
|
||
for c in d['conf']['channels']]
|
||
return self.json(res)
|
||
|
||
def vpn(self, method):
|
||
"""WireGuard VPN。
|
||
|
||
这个接口有两处特别要求:
|
||
1) **服务端与客户端私钥都不下发**。客户端私钥只在
|
||
peer_add / peer_conf 这一次返回里给(用户要导入手机),
|
||
status 接口一律走 helper 的 _vpn_mask 抹掉。
|
||
2) apply(启停服务)和 delete_all 会改网络状态,要写审计。
|
||
"""
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
return self.json(self.helper('vpn', {'op': 'status'}, timeout=120))
|
||
b = self.body()
|
||
op = str(b.get('op') or 'status')
|
||
# 双保险:即使 helper 侧的确认校验被绕过,Web 层也不再放行
|
||
if op == 'apply' and (b.get('conf') or {}).get('exit_node') is True \
|
||
and b.get('confirm_exit') is not True:
|
||
return self.json({'ok': False, 'code': 'NEEDCONFIRM',
|
||
'msg_cn': '启用「全部流量经过本机」需要勾选确认后重试'})
|
||
res = self.helper('vpn', dict(b, op=op), timeout=180)
|
||
if res.get('ok') and op in ('apply', 'stop', 'delete_all', 'peer_del'):
|
||
audit(self.user, {'apply': '应用 VPN 配置', 'stop': '停止 VPN',
|
||
'delete_all': '删除全部 VPN 配置',
|
||
'peer_del': '删除 VPN 客户端'}[op],
|
||
res.get('msg_cn') or '', True, self.h.client_address[0])
|
||
elif res.get('ok') and op == 'peer_add':
|
||
audit(self.user, '添加 VPN 客户端',
|
||
(res.get('data') or {}).get('peer', {}).get('name', ''),
|
||
True, self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
def quota(self, method, query):
|
||
"""用量统计。
|
||
|
||
GET -> status / report(?month=YYYY-MM)
|
||
POST -> {op: conf|reset|aggregate|device}
|
||
|
||
report 走的是**已聚合**的小文件,不扫原始归档 —— 这是这套东西
|
||
能在 4GB 机器上跑起来的关键,调用频率高也不能改。
|
||
"""
|
||
if not self.auth():
|
||
return
|
||
if method == 'GET':
|
||
month = (query.get('month') or [''])[0]
|
||
op = 'report' if month else 'status'
|
||
payload = {'op': op}
|
||
if month:
|
||
payload['month'] = month
|
||
return self.json(self.helper('quota', payload, timeout=180))
|
||
b = self.body()
|
||
op = str(b.get('op') or 'status')
|
||
res = self.helper('quota', dict(b, op=op), timeout=300)
|
||
if res.get('ok') and op == 'reset':
|
||
audit(self.user, '清空用量统计', '已清空全部聚合数据', True)
|
||
return self.json(res)
|
||
|
||
def rescue(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body() if self._is_post() else {}
|
||
op = b.get('op') or 'get'
|
||
payload = dict(b)
|
||
payload['op'] = op
|
||
# 开关救援通道需要显式确认
|
||
if op == 'set' and b.get('enabled') is True and b.get('confirm') is not True:
|
||
return self.json({'ok': False, 'code': 'NEEDCONFIRM',
|
||
'msg_cn': '开启紧急救援通道会在网卡上新增虚拟地址,请确认后重试'})
|
||
res = self.helper('rescue', payload)
|
||
if res.get('ok') and op == 'set':
|
||
audit(self.user, '紧急救援通道', res.get('msg_cn'), True,
|
||
self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
def _is_post(self):
|
||
return getattr(self, 'method', 'GET') == 'POST'
|
||
|
||
def rollback(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('rollback', {'ts': b.get('ts')})
|
||
audit(self.user, '回滚配置', b.get('ts'), res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def migrate_preview(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('migrate_networkd', {'dry_run': True, 'cfg': b.get('cfg') or {}})
|
||
return self.json(res)
|
||
|
||
def build_mode(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
op = b.get('op') or 'status'
|
||
if op == 'off' and b.get('confirm') is not True:
|
||
return self.json({'ok': False, 'code': 'NEEDCONFIRM',
|
||
'msg_cn': '关闭构建保护模式需二次确认'})
|
||
res = self.helper('build_mode', {'op': op, 'confirm': b.get('confirm')})
|
||
audit(self.user, '构建保护模式', op, res.get('ok', False), self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
def ppp_control(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
op = b.get('op')
|
||
if op in ('connect', 'disconnect') and b.get('confirm') is not True:
|
||
return self.json({'ok': False, 'code': 'NEEDCONFIRM',
|
||
'msg_cn': '拨号/断开会影响全网,请确认后重试'})
|
||
res = self.helper('ppp_control', {'op': op})
|
||
audit(self.user, 'PPPoE 控制', op, res.get('ok', False), self.h.client_address[0])
|
||
return self.json(res)
|
||
|
||
def set_web_port(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
raw = b.get('port')
|
||
try:
|
||
port = int(raw)
|
||
except Exception:
|
||
return self.json({'ok': False, 'code': 'BADPORT', 'msg_cn': '端口必须是 1-65535 的数字'})
|
||
if not (1 <= port <= 65535):
|
||
return self.json({'ok': False, 'code': 'BADPORT', 'msg_cn': '端口必须是 1-65535 的数字'})
|
||
if port in (8080, 80, 53, 67, 68, 5900):
|
||
return self.json({'ok': False, 'code': 'RESERVED',
|
||
'msg_cn': '该端口已被系统其它服务占用(如 8080/5900/53/67),请换一个'})
|
||
res = self.helper('web_port_set', {'port': port})
|
||
if res.get('ok'):
|
||
audit(self.user, '修改 Web 端口', str(port), True)
|
||
return self.json(res)
|
||
|
||
def lease_make_static(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
res = self.helper('lease_make_static',
|
||
{'mac': b.get('mac'), 'ip': b.get('ip'), 'host': b.get('host')})
|
||
audit(self.user, '租约转静态', '%s → %s' % (b.get('mac'), b.get('ip')), res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def lease_release(self):
|
||
if not self.auth():
|
||
return
|
||
b = self.body()
|
||
if b.get('confirm') is not True:
|
||
return self.json({'ok': False, 'code': 'NEEDCONFIRM',
|
||
'msg_cn': '回收地址会让该客户端暂时断网,请确认后重试'})
|
||
res = self.helper('lease_release', {'ip': b.get('ip'), 'mac': b.get('mac')})
|
||
audit(self.user, '回收 DHCP 地址', b.get('ip'), res.get('ok', False))
|
||
return self.json(res)
|
||
|
||
def upgrade_info(self):
|
||
if not self.auth():
|
||
return
|
||
# 只读:检查是否有 apt 更新 + RealVNC 状态
|
||
out = {}
|
||
try:
|
||
p = subprocess.run(['apt', 'list', '--upgradable'], capture_output=True,
|
||
text=True, timeout=60, errors='replace')
|
||
rows = [l for l in (p.stdout or '').splitlines()[1:] if '/' in l]
|
||
risky = []
|
||
for l in rows:
|
||
nm = l.split('/')[0]
|
||
if re.match(r'^(xserver-xorg|xorg|linux-image|linux-headers|xfce4|realvnc|vnc)', nm):
|
||
risky.append(l)
|
||
out['upgradable'] = rows
|
||
out['risky'] = risky
|
||
# 「发现 127 个可升级包…」这行在概览/更新页直接上屏,必须按界面语言给
|
||
if _cur_lang[0] == 'en-US':
|
||
out['notice'] = ('Found %d upgradable package(s), %d of which may '
|
||
'affect the desktop / RealVNC - create a snapshot '
|
||
'before upgrading.' % (len(rows), len(risky)))
|
||
else:
|
||
out['notice'] = ('发现 %d 个可升级包,其中 %d 个可能影响桌面/RealVNC,'
|
||
'升级前请先创建快照。' % (len(rows), len(risky)))
|
||
except Exception as e:
|
||
out['notice'] = ('Check failed: %s' % e) if _cur_lang[0] == 'en-US' \
|
||
else ('检查失败:%s' % e)
|
||
try:
|
||
p2 = subprocess.run(['systemctl', 'is-active', 'vncserver-x11-serviced'],
|
||
capture_output=True, text=True, timeout=5)
|
||
out['vnc_active'] = (p2.stdout or '').strip() == 'active'
|
||
except Exception:
|
||
out['vnc_active'] = None
|
||
# ⚠️ `helper().get('data', {})` 在 helper 走 fail() 分支时**不生效**:
|
||
# fail() 返回的 dict 里 'data' 键是存在的(值是 None),
|
||
# dict.get 只在键**缺失**时才用默认值 —— 于是拿到 None,
|
||
# 紧接着 .get('holds') 抛 AttributeError,整个接口 500。
|
||
# 概述页是登录后必调接口,这个 500 很显眼。
|
||
# 正确写法:`(x.get('data') or {})`,None 与缺失都能兜住。
|
||
out['holds'] = (helper('pkg', {'op': 'holds'}).get('data')
|
||
or {}).get('holds', [])
|
||
return self.json({'ok': True, 'data': out})
|
||
|
||
# -------------------------------------------------- 对外通用 API(#17)
|
||
|
||
def _api_base(self):
|
||
host = self.h.headers.get('Host') or ('%s:%d' % (HOST, PORT_HTTPS))
|
||
sch = 'https' if isinstance(self.h.connection, ssl.SSLSocket) else 'http'
|
||
return '%s://%s' % (sch, host)
|
||
|
||
def openapi_descriptor(self):
|
||
"""返回一份 OpenAPI 3.0 风格的接口描述(无需登录,便于第三方自发现)。"""
|
||
base = self._api_base()
|
||
desc = build_openapi(base)
|
||
return self.json({'ok': True, 'data': desc})
|
||
|
||
def api_examples(self):
|
||
base = self._api_base()
|
||
return self.json({'ok': True, 'data': build_examples(base)})
|
||
|
||
# -------------------------------------------------- Web 终端(#18)
|
||
|
||
def webshell_api(self, p):
|
||
"""Web 终端(真 PTY)。
|
||
|
||
协议变成交互式的了:
|
||
connect → 拿到 sid;之后前端每敲一个键就 write,再高频 read 取回显。
|
||
所以这里不再做「每请求一次慢超时」,read/write 必须快,否则打字会卡顿。
|
||
"""
|
||
sub = p[len('/api/webshell'):].strip('/')
|
||
b = self.body() if self.method == 'POST' else {}
|
||
if sub == 'connect':
|
||
res = self.helper('webshell', {'op': 'connect', **b}, timeout=40)
|
||
if res.get('ok'):
|
||
sid = (res.get('data') or {}).get('sid')
|
||
if sid:
|
||
with _lock:
|
||
SHELL_SESSIONS[sid] = {'user': self.user,
|
||
'created': time.time()}
|
||
return self.json(res)
|
||
if sub in ('read', 'write', 'resize'):
|
||
sid = b.get('sid')
|
||
with _lock:
|
||
ok_sess = sid in SHELL_SESSIONS
|
||
if not ok_sess:
|
||
return self.json({'ok': False, 'code': 'NOSESS',
|
||
'msg_cn': '会话不存在或已过期,请重新连接'}, 400)
|
||
# read 支持长轮询(前端带 wait,最多 5 秒),超时要留出余量;
|
||
# write / resize 则是打字路径上的,必须立刻返回
|
||
tmo = 20 if sub == 'read' else 15
|
||
return self.json(self.helper('webshell', {'op': sub, **b}, timeout=tmo))
|
||
if sub == 'disconnect':
|
||
sid = b.get('sid')
|
||
with _lock:
|
||
SHELL_SESSIONS.pop(sid, None)
|
||
return self.json(self.helper('webshell', {'op': 'disconnect', **b}, timeout=20))
|
||
if sub == 'sessions':
|
||
# 关键:先在锁内把快照复制出来再返回。早先这里是
|
||
# `with _lock: return self.json(...)` —— 而 self.json 会阻塞在
|
||
# wfile.write 上,慢客户端(或半开连接)就能把全局 _lock 按住,
|
||
# 连带把数据库、配置、审计、终端全堵死,表现为「整个面板卡住」。
|
||
with _lock:
|
||
rows = [{'sid': k, 'age': int(time.time() - v['created'])}
|
||
for k, v in SHELL_SESSIONS.items()]
|
||
return self.json({'ok': True, 'data': rows})
|
||
return self.json({'ok': False, 'code': 'NOTFOUND',
|
||
'msg_cn': '终端接口不存在:%s' % sub}, 404)
|
||
|
||
# -------------------------------------------------- 文件管理(#18)
|
||
|
||
def files_api(self, p, query, method):
|
||
"""Web 文件管理器后端:全部委托给特权执行器(目录白名单在 helper 内校验)。"""
|
||
sub = p[len('/api/files'):].strip('/')
|
||
g = lambda k, d='': (query.get(k) or [d])[0]
|
||
if sub in ('list', '') and method == 'GET':
|
||
return self.json(self.helper('fs', {'op': 'list', 'path': g('path', '/')}))
|
||
if sub == 'download' and method == 'GET':
|
||
return self.fs_download(g('path'))
|
||
if sub == 'read' and method == 'GET':
|
||
return self.json(self.helper('fs', {'op': 'read', 'path': g('path'),
|
||
'max': g('max', '262144')}))
|
||
if sub == 'mkdir' and method == 'POST':
|
||
b = self.body()
|
||
return self.json(self.helper('fs', {'op': 'mkdir',
|
||
'parent': b.get('parent') or b.get('path'),
|
||
'name': b.get('name')}))
|
||
if sub == 'rename' and method == 'POST':
|
||
b = self.body()
|
||
return self.json(self.helper('fs', {'op': 'rename',
|
||
'path': b.get('path'),
|
||
'name': b.get('name'),
|
||
'to': b.get('to')}))
|
||
if sub == 'delete' and method == 'POST':
|
||
b = self.body()
|
||
return self.json(self.helper('fs', {'op': 'delete',
|
||
'paths': b.get('paths') or []}))
|
||
if sub == 'zip' and method == 'POST':
|
||
b = self.body()
|
||
return self.json(self.helper('fs', {'op': 'zip',
|
||
'paths': b.get('paths') or [],
|
||
'name': b.get('name') or ''}, timeout=300))
|
||
if sub == 'write' and method == 'POST':
|
||
b = self.body()
|
||
return self.json(self.helper('fs', {'op': 'write',
|
||
'path': b.get('path'),
|
||
'name': b.get('name'),
|
||
'b64': b.get('b64'),
|
||
'size': b.get('size')}, timeout=600))
|
||
if sub == 'hash' and method == 'GET':
|
||
return self.json(self.helper('fs', {'op': 'hash', 'path': g('path')}))
|
||
if sub == 'search' and method == 'GET':
|
||
return self.json(self.helper('fs', {'op': 'search', 'path': g('path', '/'),
|
||
'kw': g('kw'), 'limit': g('limit', '200')}))
|
||
return self.json({'ok': False, 'code': 'NOTFOUND',
|
||
'msg_cn': '文件接口不存在:%s' % sub}, 404)
|
||
|
||
def fs_download(self, path):
|
||
"""下载文件:走特权执行器把内容 base64 取回再以流式落盘。"""
|
||
res = self.helper('fs', {'op': 'get', 'path': path, 'b64': True}, timeout=300)
|
||
if not res.get('ok'):
|
||
return self.json(res, 404)
|
||
d = res.get('data') or {}
|
||
raw = base64.b64decode(d.get('b64') or '')
|
||
name = d.get('name') or 'download.bin'
|
||
quoted = name.encode('utf-8').decode('latin-1', 'ignore')
|
||
self.h.send_response(200)
|
||
self.h.send_header('Content-Type', 'application/octet-stream')
|
||
self.h.send_header('Content-Length', str(len(raw)))
|
||
self.h.send_header('Content-Disposition',
|
||
"attachment; filename=\"%s\"; filename*=UTF-8''%s"
|
||
% (quoted, _url_quote(name)))
|
||
self.h.send_header('Cache-Control', 'no-store')
|
||
self.h.end_headers()
|
||
self.h.wfile.write(raw)
|
||
|
||
|
||
def _url_quote(s):
|
||
from urllib.parse import quote
|
||
return quote(s or '')
|
||
|
||
|
||
def build_openapi(base):
|
||
"""构造对外通用 API 的机器可读描述(OpenAPI 3.0.3 子集)。"""
|
||
sec = [{'X-Token': []}]
|
||
def ep(path, method, summary, params=None, body=None, auth=True, tag='通用'):
|
||
node = {'tags': [tag], 'summary': summary,
|
||
'responses': {'200': {'description': '统一 JSON 响应',
|
||
'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Resp'}}}}}}
|
||
if auth:
|
||
node['security'] = sec
|
||
if params:
|
||
node['parameters'] = [{'name': k, 'in': 'query', 'required': False,
|
||
'schema': {'type': 'string'}, 'description': v}
|
||
for k, v in params.items()]
|
||
if body is not None:
|
||
node['requestBody'] = {'content': {'application/json': {
|
||
'schema': {'type': 'object', 'properties': {
|
||
k: {'type': v} for k, v in (body or {}).items()}}}}}
|
||
return {path: {method: node}}
|
||
|
||
paths = {}
|
||
paths['/api/health'] = {'get': {'tags': ['通用'], 'summary': '健康检查(无需登录)',
|
||
'responses': {'200': {'description': '服务状态'}}}}
|
||
paths['/api/login'] = {'post': {'tags': ['鉴权'], 'summary': '登入获取 Token',
|
||
'requestBody': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Login'}}}},
|
||
'responses': {'200': {'description': '返回 token'}}}}
|
||
|
||
reads = {
|
||
'/api/sysinfo': '系统概览(CPU / 内存 / 磁盘 / 进程 / 温度 / 虚拟化)',
|
||
'/api/ifaces': '网络接口列表',
|
||
'/api/routes': '路由表',
|
||
'/api/services': '受管服务状态',
|
||
'/api/nft': '防火墙规则快照',
|
||
'/api/leases': 'DHCP 租约',
|
||
'/api/ipv6': 'IPv6 地址与 RA 状态',
|
||
'/api/ntp': '时间同步状态',
|
||
'/api/logs': '日志',
|
||
'/api/journal': '系统日志',
|
||
# ↓ 1.0.10 新增。⚠️ build_openapi 是**逐个显式登记**的,
|
||
# 不是从 dispatch 的 readings 自动生成 —— 所以新端点光在 readings
|
||
# 里加一行不够,这里也必须加,否则 API 文档里看不到。
|
||
'/api/upstream': '上游链路详情(IPv4/IPv6 协议、地址、网关、DNS、DHCPv6 统计)',
|
||
'/api/netdetail': '邻居表 / 路由表 / IPv6 规则表',
|
||
}
|
||
for path, summary in reads.items():
|
||
paths.update(ep(path, 'get', summary, tag='只读观测'))
|
||
|
||
acts = {
|
||
'/api/nat/check': ('get', 'NAT 类型自动检测(STUN)', None, '网络诊断'),
|
||
'/api/accel': ('post', 'nftables flowtable 软加速 开关/查询', {'op': 'string: get|on|off'}, '网络加速'),
|
||
'/api/vlan': ('post', 'VLAN 接口 查询/保存/应用/删除', {'op': 'string: get|save|apply|delete', 'id': 'string', 'parent': 'string', 'vid': 'integer', 'name': 'string'}, '二层网络'),
|
||
'/api/wol': ('post', 'WOL 网络唤醒 / 网卡能力查询', {'op': 'string: get|iface|save|wake', 'mac': 'string', 'iface': 'string'}, '二层网络'),
|
||
# ↓ 1.0.10 新增:版本检测 / 下载更新。
|
||
# check 与 state 是 GET,apply/cancel 是 POST —— openapi 里只能记一种
|
||
# 方法,这里记 POST(真正会改状态的那个),GET 的两个在 API 文档里
|
||
# 按摘要说明。apply 只下载+校验,**不自动安装**。
|
||
'/api/update/apply': ('post', '下载指定版本的安装包(先备份→官方源下载→SHA256 校验)',
|
||
{'version': 'string: 形如 1.0.9', 'asset': 'string: deb|offline|docker',
|
||
'backup': 'boolean'}, '版本更新'),
|
||
'/api/update/cancel': ('post', '中断正在进行的更新下载', None, '版本更新'),
|
||
'/api/pppoe-multi': ('post', 'PPPoE 多拨:查询/保存/应用/启停/单会话操作',
|
||
{'op': 'string: get|save|apply|stop|session_start|session_stop|session_restart',
|
||
'strategy': 'string: balance|primary_backup|weighted',
|
||
'iface': 'string', 'sessions': 'array', 'confirm': 'boolean',
|
||
'duplicate_account': 'boolean', 'idx': 'integer'}, 'WAN 接入'),
|
||
'/api/pppoe-multi/log': ('post', 'PPPoE 单会话日志与排错建议', {'idx': 'integer'}, 'WAN 接入'),
|
||
'/api/qos': ('post', 'QoS 智能限速:查询/保存/启用/停用/重置',
|
||
{'op': 'string: get|save|on|off|reset',
|
||
'mode': 'string: diffserv3|diffserv4|besteffort',
|
||
'wan_iface': 'string', 'up_mbit': 'integer', 'down_mbit': 'integer',
|
||
'ips': 'array', 'dscp': 'array', 'connlimit': 'boolean',
|
||
'connlimit_max': 'integer', 'confirm': 'boolean'}, '服务'),
|
||
'/api/dpi': ('post', 'DPI 识别库:状态/代理测速/更新',
|
||
{'op': 'string: get|test|update|plan|log',
|
||
'mirror': 'string: direct|ghproxy|gh-proxy|moeyy|ghfast|ghproxy-net|gitclone|custom',
|
||
'custom_prefix': 'string', 'only': 'array', 'which': 'string'}, '服务'),
|
||
'/api/dpi/log': ('get', 'DPI 更新日志', None, '服务'),
|
||
'/api/wan/log': ('get', 'WAN 接入方式统一实时日志与状态(PPPoE/DHCP/静态/双栈/桥接)',
|
||
{'access': 'string: pppoe|dhcp|static|pppoe_dhcp6|ipoe_dhcp6|bridge',
|
||
'iface': 'string', 'limit': 'integer', 'since': 'string'}, 'WAN 接入'),
|
||
'/api/ddns': ('post', '动态域名 DDNS:查询/保存/启停/公网检测/立即更新',
|
||
{'op': 'string: get|save|on|off|test|update',
|
||
'provider': 'string', 'domain': 'string', 'subdomain': 'string',
|
||
'ipv4': 'boolean', 'ipv6': 'boolean', 'ttl': 'integer',
|
||
'fields': 'object'}, '寻址与路由'),
|
||
'/api/portfwd': ('post', '端口转发与 DMZ:查询/保存(渲染为 nftables DNAT)',
|
||
{'enable_v4': 'boolean', 'enable_v6': 'boolean',
|
||
'rules': 'array', 'dmz_v4_enable': 'boolean',
|
||
'dmz_v4_host': 'string', 'dmz_v6_enable': 'boolean',
|
||
'dmz_v6_host': 'string'}, '安全'),
|
||
'/api/acl': ('post', '访问控制与家长时间组:查询/保存/启停/渲染预览(nftables)',
|
||
{'op': 'string', 'enable': 'boolean', 'time_groups': 'array',
|
||
'groups': 'array', 'rules': 'array'}, '安全'),
|
||
'/api/share': ('post', '内网文件共享 SMB / NFS:查询/保存/启停/建目录/渲染预览',
|
||
{'op': 'string', 'enable_smb': 'boolean', 'enable_nfs': 'boolean',
|
||
'samba': 'object', 'nfs': 'object'}, '服务'),
|
||
'/api/storage': ('post', '外置存储设备(USB / Type-C / 雷电):识别设备、挂载、卸载、'
|
||
'格式化(ext4/xfs/btrfs/f2fs/exfat/ntfs/vfat)、开机自动挂载',
|
||
{'op': 'string: get|mount|umount|format|fstab_add|fstab_del',
|
||
'name': 'string', 'fs': 'string', 'label': 'string',
|
||
'confirm': 'string', 'target': 'string', 'uuid': 'string'}, '服务'),
|
||
'/api/docker': ('post', 'Docker / Compose 面板:状态查询、容器运维、镜像/网络/卷、'
|
||
'Compose 项目管理、docker run → compose 转换',
|
||
{'op': 'string: get|start|stop|restart|pause|unpause|rm|kill|logs|'
|
||
'inspect|prune|convert|stack_list|stack_save|stack_get|'
|
||
'stack_up|stack_down|stack_restart|stack_pull|stack_ps|stack_delete',
|
||
'id': 'string', 'name': 'string', 'cmd': 'string',
|
||
'content': 'string', 'what': 'string'}, '工具'),
|
||
'/api/ulog': ('post', '连接跟踪与统一日志:查询/当前连接表/保存保留策略/清理/导出',
|
||
{'op': 'string: query|flow|conf|save_conf|prune|export|clear_archive',
|
||
'view': 'string: log|flow', 'src': 'string', 'level': 'string',
|
||
'action': 'string', 'proto': 'string', 'addr': 'string',
|
||
'port': 'string', 'q': 'string', 'since': 'string',
|
||
'limit': 'integer', 'sources': 'array', 'format': 'string: json|csv',
|
||
'keep_days': 'integer', 'keep_rows': 'integer'}, '日志与审计'),
|
||
'/api/ulog/flow': ('get', '当前连接跟踪表快照(conntrack -L,含传输字节)',
|
||
{'proto': 'string', 'q': 'string', 'limit': 'integer'},
|
||
'日志与审计'),
|
||
'/api/ulog/conf': ('get', '统一日志配置与采集能力自检', None, '日志与审计'),
|
||
'/api/theme': ('get', '主题列表 / 主题详情 / 可定制变量清单',
|
||
{'op': 'list|get|vars', 'id': 'string'}, '主题之家'),
|
||
'/api/theme/op': ('post', '主题保存 / 删除 / 应用 / 复位 / 导出 / 导入 / 预览 / 配色',
|
||
{'op': 'save|delete|apply|reset|export|import|import_apply|preview|validate|palette',
|
||
'id': 'string', 'theme': 'object', 'b64': 'string'}, '主题之家'),
|
||
'/api/render': ('post', '配置渲染预览(只读,不写盘)',
|
||
{'module': 'string', 'data': 'object'}, '配置管理'),
|
||
'/api/deps/check': ('get', '运行依赖自检', None, '系统维护'),
|
||
'/api/deps/install': ('post', '一键安装缺失依赖', {'only': 'array'}, '系统维护'),
|
||
'/api/config': ('get', '读取全部结构化配置', None, '配置管理'),
|
||
'/api/apply': ('post', '应用配置(渲染 + 校验 + 回滚保护)', {'module': 'string'}, '配置管理'),
|
||
'/api/snapshot': ('post', '创建快照', {'tag': 'string', 'note': 'string'}, '系统维护'),
|
||
'/api/snapshot/list': ('get', '快照列表', None, '系统维护'),
|
||
'/api/rollback': ('post', '回滚到指定快照', {'ts': 'string'}, '系统维护'),
|
||
'/api/audit': ('get', '审计日志', None, '审计'),
|
||
}
|
||
for path, (m, summary, body, tag) in acts.items():
|
||
paths.update(ep(path, m, summary, body=body, tag=tag))
|
||
|
||
fs_eps = {
|
||
'/api/files/list': '列目录',
|
||
'/api/files/download': '下载文件',
|
||
'/api/files/read': '读取文本(预览)',
|
||
'/api/files/hash': '文件校验值',
|
||
'/api/files/search': '关键词搜索',
|
||
'/api/files/mkdir': '新建目录',
|
||
'/api/files/rename': '重命名',
|
||
'/api/files/delete': '删除(走回收站)',
|
||
'/api/files/zip': '打包 zip',
|
||
}
|
||
for path, summary in fs_eps.items():
|
||
paths.update(ep(path, 'get' if path.endswith(('list', 'read', 'hash', 'search', 'download')) else 'post',
|
||
summary, params={'path': '目标路径'} if not path.endswith(('list',)) else None,
|
||
tag='文件管理'))
|
||
|
||
return {
|
||
'openapi': '3.0.3',
|
||
'info': {
|
||
'title': 'Drouter 通用管理 API',
|
||
'version': app_version(),
|
||
'description': ('Drouter(Debian 13 拼装主路由)的统一 REST 接口。'
|
||
'任意语言 / 框架(curl、Python、Node、Go、PHP、Java、.NET、'
|
||
'Shell、Postman、工单系统、IoT 网关)均可直接调用。'
|
||
'响应固定为 JSON:{"ok": bool, "msg_cn": str, "data": any, "code": str}。'
|
||
'鉴权:POST /api/login 取 token,之后每个请求带 X-Token 头。'),
|
||
'license': {'name': 'MIT'},
|
||
},
|
||
'servers': [{'url': base}],
|
||
'components': {
|
||
'securitySchemes': {
|
||
'X-Token': {'type': 'apiKey', 'in': 'header', 'name': 'X-Token',
|
||
'description': '登入后返回的会话令牌'}},
|
||
'schemas': {
|
||
'Resp': {'type': 'object', 'properties': {
|
||
'ok': {'type': 'boolean'}, 'msg_cn': {'type': 'string'},
|
||
'code': {'type': 'string'}, 'data': {}}},
|
||
'Login': {'type': 'object', 'required': ['username', 'password'],
|
||
'properties': {'username': {'type': 'string'},
|
||
'password': {'type': 'string'}}},
|
||
},
|
||
},
|
||
'paths': paths,
|
||
}
|
||
|
||
|
||
def build_examples(base):
|
||
"""Multilingual API call examples (plain-text blocks shown / copied on both
|
||
the frontend and backend). Language follows the request's X-Lang header."""
|
||
EN = _cur_lang[0] == 'en-US'
|
||
def pick(zh, en):
|
||
return en if EN else zh
|
||
c1 = pick('# 1) 登入拿 token\n', '# 1) Log in to obtain the token\n')
|
||
c2 = pick('# 2) 读取系统概览\n', '# 2) Read the system overview\n')
|
||
c3 = pick('# 3) 一键开启软加速\n', '# 3) Enable acceleration with one call\n')
|
||
py_cert = pick(' # 自签证书\n\n', ' # self-signed certificate\n\n')
|
||
http_sep = pick('--- 拿到 token 后 ---\n\n', '--- after obtaining the token ---\n\n')
|
||
http_tok = pick('<上一步返回的 token>', '<token from the previous step>')
|
||
return {
|
||
'base': base,
|
||
'note': pick(
|
||
'所有接口统一返回 {"ok":..,"msg_cn":..,"data":..};失败时 ok=false。'
|
||
'除 /api/health、/api/login 外均需 X-Token 头。',
|
||
'All endpoints return {"ok":..,"msg_cn":..,"data":..}; on failure ok=false. '
|
||
'Every endpoint except /api/health and /api/login requires the X-Token header.'),
|
||
'examples': [
|
||
{'lang': 'curl', 'title': pick('cURL(最通用,Linux/macOS/Windows 均可)',
|
||
'cURL (most universal; Linux/macOS/Windows)'), 'code':
|
||
("BASE=\"%s\"\n"
|
||
+ c1 +
|
||
"TOKEN=$(curl -sk -X POST \"$BASE/api/login\" \\\n"
|
||
" -H 'Content-Type: application/json' \\\n"
|
||
" -d '{\"username\":\"admin\",\"password\":\"admin123\"}' \\\n"
|
||
" | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"data\"][\"token\"])')\n\n"
|
||
+ c2 +
|
||
"curl -sk \"$BASE/api/sysinfo\" -H \"X-Token: $TOKEN\"\n\n"
|
||
+ c3 +
|
||
"curl -sk -X POST \"$BASE/api/accel\" -H \"X-Token: $TOKEN\" \\\n"
|
||
" -H 'Content-Type: application/json' -d '{\"op\":\"on\"}'\n"
|
||
) % base},
|
||
{'lang': 'python', 'title': pick('Python(仅用标准库 requests 可省)',
|
||
'Python (standard library only; no requests needed)'), 'code':
|
||
("import json, urllib.request, ssl\n\n"
|
||
"BASE = \"%s\"\n"
|
||
+ py_cert +
|
||
"def call(path, data=None, token=None, method=None):\n"
|
||
" body = json.dumps(data).encode() if data is not None else None\n"
|
||
" req = urllib.request.Request(BASE + path, data=body,\n"
|
||
" method=method or ('POST' if data else 'GET'))\n"
|
||
" req.add_header('Content-Type', 'application/json')\n"
|
||
" if token:\n"
|
||
" req.add_header('X-Token', token)\n"
|
||
" with urllib.request.urlopen(req, context=CTX, timeout=30) as r:\n"
|
||
" return json.load(r)\n\n"
|
||
"tok = call('/api/login', {'username': 'admin', 'password': 'admin123'})['data']['token']\n"
|
||
"print(call('/api/sysinfo', token=tok)['data']['hostname'])\n"
|
||
"print(call('/api/nat/check', token=tok)['data']['title'])\n"
|
||
) % base},
|
||
{'lang': 'javascript', 'title': 'JavaScript / Node.js / browser fetch', 'code':
|
||
"const BASE = '%s';\n\n"
|
||
"async function call(path, data, token) {\n"
|
||
" const r = await fetch(BASE + path, {\n"
|
||
" method: data ? 'POST' : 'GET',\n"
|
||
" headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Token': token } : {}) },\n"
|
||
" body: data ? JSON.stringify(data) : undefined,\n"
|
||
" });\n"
|
||
" return r.json();\n"
|
||
"}\n\n"
|
||
"(async () => {\n"
|
||
" const { data } = await call('/api/login', { username: 'admin', password: 'admin123' });\n"
|
||
" const info = await call('/api/sysinfo', null, data.token);\n"
|
||
" console.log(info.data.hostname, info.data.virt.name);\n"
|
||
"})();\n" % base},
|
||
{'lang': 'go', 'title': pick('Go(net/http 标准库)',
|
||
'Go (net/http standard library)'), 'code':
|
||
"package main\n\n"
|
||
"import (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n"
|
||
"const base = \"%s\"\n\n"
|
||
"func call(path string, payload any, token string) map[string]any {\n"
|
||
"\tvar buf io.Reader\n"
|
||
"\tif payload != nil {\n\t\tb, _ := json.Marshal(payload)\n\t\tbuf = bytes.NewReader(b)\n\t}\n"
|
||
"\tmethod := \"GET\"\n\tif payload != nil {\n\t\tmethod = \"POST\"\n\t}\n"
|
||
"\treq, _ := http.NewRequest(method, base+path, buf)\n"
|
||
"\treq.Header.Set(\"Content-Type\", \"application/json\")\n"
|
||
"\tif token != \"\" {\n\t\treq.Header.Set(\"X-Token\", token)\n\t}\n"
|
||
"\ttr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}\n"
|
||
"\tresp, _ := (&http.Client{Transport: tr}).Do(req)\n"
|
||
"\tdefer resp.Body.Close()\n"
|
||
"\tvar out map[string]any\n\tjson.NewDecoder(resp.Body).Decode(&out)\n\treturn out\n"
|
||
"}\n\n"
|
||
"func main() {\n"
|
||
"\tlogin := call(\"/api/login\", map[string]string{\"username\": \"admin\", \"password\": \"admin123\"}, \"\")\n"
|
||
"\ttok := login[\"data\"].(map[string]any)[\"token\"].(string)\n"
|
||
"\tinfo := call(\"/api/sysinfo\", nil, tok)\n"
|
||
"\tfmt.Println(info[\"data\"].(map[string]any)[\"hostname\"])\n"
|
||
"}\n" % base},
|
||
{'lang': 'java', 'title': pick('Java 11+(HttpClient)',
|
||
'Java 11+ (HttpClient)'), 'code':
|
||
"import java.net.URI;\nimport java.net.http.*;\nimport java.time.Duration;\n\n"
|
||
"public class Drouter {\n"
|
||
" static final String BASE = \"%s\";\n\n"
|
||
" public static void main(String[] a) throws Exception {\n"
|
||
" HttpClient c = HttpClient.newBuilder()\n"
|
||
" .sslContext(javax.net.ssl.SSLContext.getDefault())\n"
|
||
" .connectTimeout(Duration.ofSeconds(10)).build();\n"
|
||
" String login = \"{\\\"username\\\":\\\"admin\\\",\\\"password\\\":\\\"admin123\\\"}\";\n"
|
||
" HttpRequest rq = HttpRequest.newBuilder(URI.create(BASE + \"/api/login\"))\n"
|
||
" .header(\"Content-Type\", \"application/json\")\n"
|
||
" .POST(HttpRequest.BodyPublishers.ofString(login)).build();\n"
|
||
" System.out.println(c.send(rq, HttpResponse.BodyHandlers.ofString()).body());\n"
|
||
" }\n}\n" % base},
|
||
{'lang': 'php', 'title': pick('PHP(file_get_contents / curl 通用写法)',
|
||
'PHP (file_get_contents / curl)'), 'code':
|
||
"<?php\n"
|
||
"$base = '%s';\n\n"
|
||
"function call($path, $data = null, $token = null) {\n"
|
||
" global $base;\n"
|
||
" $ch = curl_init($base . $path);\n"
|
||
" $h = ['Content-Type: application/json'];\n"
|
||
" if ($token) $h[] = 'X-Token: ' . $token;\n"
|
||
" curl_setopt_array($ch, [\n"
|
||
" CURLOPT_RETURNTRANSFER => true,\n"
|
||
" CURLOPT_SSL_VERIFYPEER => false,\n"
|
||
" CURLOPT_HTTPHEADER => $h,\n"
|
||
" CURLOPT_POST => $data !== null,\n"
|
||
" CURLOPT_POSTFIELDS => $data !== null ? json_encode($data) : null,\n"
|
||
" ]);\n"
|
||
" return json_decode(curl_exec($ch), true);\n"
|
||
"}\n"
|
||
"$t = call('/api/login', ['username' => 'admin', 'password' => 'admin123'])['data']['token'];\n"
|
||
"echo call('/api/sysinfo', null, $t)['data']['os'];\n" % base},
|
||
{'lang': 'shell', 'title': pick('Shell(wget 版,无 curl 环境)',
|
||
'Shell (wget variant, no curl)'), 'code':
|
||
"#!sh\n"
|
||
"BASE=\"%s\"\n"
|
||
"TOKEN=$(wget -qO- --no-check-certificate --header='Content-Type: application/json' \\\n"
|
||
" --post-data='{\"username\":\"admin\",\"password\":\"admin123\"}' \\\n"
|
||
" \"$BASE/api/login\" | sed -n 's/.*\"token\":\"\\([^\"]*\\)\".*/\\1/p')\n"
|
||
"wget -qO- --no-check-certificate --header=\"X-Token: $TOKEN\" \"$BASE/api/sysinfo\"\n" % base},
|
||
{'lang': 'powershell', 'title': pick('PowerShell(Windows)',
|
||
'PowerShell (Windows)'), 'code':
|
||
"$base = '%s'\n"
|
||
"$login = Invoke-RestMethod -Method Post -Uri \"$base/api/login\" `\n"
|
||
" -Body (@{username='admin';password='admin123'} | ConvertTo-Json) `\n"
|
||
" -ContentType 'application/json' -SkipCertificateCheck\n"
|
||
"$info = Invoke-RestMethod -Uri \"$base/api/sysinfo\" -Headers @{'X-Token'=$login.data.token} `\n"
|
||
" -SkipCertificateCheck\n"
|
||
"$info.data.hostname\n" % base},
|
||
{'lang': 'http', 'title': pick('原始 HTTP 报文(任何 HTTP 客户端 / 工单系统 / IoT)',
|
||
'Raw HTTP messages (any HTTP client / ticketing system / IoT)'), 'code':
|
||
("POST /api/login HTTP/1.1\n"
|
||
"Host: %s\n"
|
||
"Content-Type: application/json\n"
|
||
"Content-Length: 44\n\n"
|
||
"{\"username\":\"admin\",\"password\":\"admin123\"}\n\n"
|
||
+ http_sep +
|
||
"GET /api/sysinfo HTTP/1.1\n"
|
||
"Host: %s\n"
|
||
"X-Token: " + http_tok + "\n"
|
||
) % (base.replace('https://', '').replace('http://', ''),
|
||
base.replace('https://', '').replace('http://', ''))},
|
||
],
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------ HTTP 处理
|
||
|
||
_ASSET_RE = re.compile(
|
||
r'(\b(?:src|href)\s*=\s*["\'])(/[^"\']+\.(?:js|css))(["\'])')
|
||
|
||
|
||
def _inject_asset_version(raw):
|
||
"""给入口页里的本地 js/css 引用追加 ?v=<文件mtime>,用于部署后自动破缓存。
|
||
|
||
只处理「以 / 开头、还没有查询串」的同源资源;带 ? 的(如 theme.css?v=...)
|
||
保持原样,避免覆盖前端自己维护的版本参数。
|
||
"""
|
||
try:
|
||
text = raw.decode('utf-8')
|
||
except UnicodeDecodeError:
|
||
return raw # 非 utf-8 的入口页不处理,原样返回
|
||
|
||
cache = {}
|
||
|
||
def ver(rel):
|
||
if rel not in cache:
|
||
try:
|
||
cache[rel] = int(os.path.getmtime(os.path.join(WEB_DIR, rel.lstrip('/'))))
|
||
except OSError:
|
||
cache[rel] = 0
|
||
return cache[rel]
|
||
|
||
def sub(m):
|
||
pre, url, post = m.group(1), m.group(2), m.group(3)
|
||
if '?' in url:
|
||
return m.group(0)
|
||
return '%s%s?v=%d%s' % (pre, url, ver(url), post)
|
||
|
||
# 页脚版本号。放在同一个函数里做,是因为**同一份字节只算一次哈希**:
|
||
# 拆成两次注入就得各算一遍 key,(None, hash, len, enc) 缓存会失效。
|
||
text = _ASSET_RE.sub(sub, text).replace('__APP_VERSION__', app_version())
|
||
return text.encode('utf-8')
|
||
|
||
|
||
class _Server(ThreadingHTTPServer):
|
||
"""静默掉「客户端自己先断开」这类噪声。
|
||
|
||
socketserver 默认的 handle_error 会把**任何**异常连整段 traceback 打到
|
||
stderr(也就是 journal)。而浏览器切页、关标签、取消长请求时都会抛出
|
||
BrokenPipeError / ConnectionResetError —— 那不是服务端的错,却足以把
|
||
journal 刷满,让真正的报错被淹掉(本机实测 3 天攒了几十条)。
|
||
这里只吞掉这三类「对面先挂了」的异常,其余异常照旧往外抛。
|
||
"""
|
||
|
||
daemon_threads = True
|
||
# 等待队列也别给太大:排队排到几百个再逐个超时,体验比直接拒绝更糟
|
||
request_queue_size = 64
|
||
|
||
def process_request(self, request, client_address):
|
||
"""名额用完就立刻关掉这条连接,而不是无限开线程。"""
|
||
if not _WORKER_SEM.acquire(blocking=False):
|
||
try:
|
||
request.close()
|
||
except Exception:
|
||
pass
|
||
return
|
||
try:
|
||
super().process_request(request, client_address)
|
||
except Exception:
|
||
_WORKER_SEM.release()
|
||
raise
|
||
|
||
def process_request_thread(self, request, client_address):
|
||
try:
|
||
super().process_request_thread(request, client_address)
|
||
finally:
|
||
_WORKER_SEM.release()
|
||
|
||
def handle_error(self, request, client_address):
|
||
exc = sys.exc_info()[0]
|
||
if exc is not None and issubclass(
|
||
exc, (BrokenPipeError, ConnectionResetError,
|
||
ConnectionAbortedError, TimeoutError)):
|
||
return
|
||
super().handle_error(request, client_address)
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = 'drouter/1.0'
|
||
protocol_version = 'HTTP/1.1'
|
||
# 连接级读写超时:一条连接挂在这里 30 秒没动静就断开。
|
||
# 不设的话,慢客户端能永久占住一个工作线程(MAX_WORKERS 个名额很快被吃满)。
|
||
timeout = 30
|
||
|
||
def log_message(self, fmt, *args):
|
||
pass # 静默,避免刷日志
|
||
|
||
def _serve_static(self, path):
|
||
if path in ('/', '/index.html'):
|
||
path = '/index.html'
|
||
rel = path.lstrip('/')
|
||
if '..' in rel:
|
||
return self._send_text(403, '禁止访问')
|
||
full = os.path.join(WEB_DIR, rel)
|
||
if not os.path.isfile(full):
|
||
return self._send_text(404, '资源不存在')
|
||
ctype = mimetypes.guess_type(full)[0] or 'application/octet-stream'
|
||
if full.endswith('.html'):
|
||
ctype = 'text/html; charset=utf-8'
|
||
elif full.endswith('.js'):
|
||
ctype = 'application/javascript; charset=utf-8'
|
||
elif full.endswith('.css'):
|
||
ctype = 'text/css; charset=utf-8'
|
||
|
||
# ETag 协商缓存:没变就回 304,省掉整个响应体(app.js 583KB)。
|
||
# 键里带 mtime 和大小,部署新版本后自动失效。
|
||
try:
|
||
st = os.stat(full)
|
||
etag = '"%s-%s"' % (int(st.st_mtime), st.st_size)
|
||
except Exception:
|
||
etag = ''
|
||
# ⚠️ index.html 要**额外**把版本号并进 ETag。
|
||
# 它发出去的不是文件原样,而是注入过资源指纹和 __APP_VERSION__ 的产物;
|
||
# 只按 mtime+size 算的话,光改 /opt/drouter/VERSION(文件本身一个字节
|
||
# 都没动)时 ETag 不变,客户端拿 If-None-Match 换到 304 就继续用旧副本,
|
||
# 页面底下的版本号会一直停在旧版本 —— 恰好是最该更新的地方不更新。
|
||
if full.endswith('index.html'):
|
||
etag = '"%s-%s"' % (etag.strip('"'), app_version())
|
||
# ⚠️ 缓存策略分两种,靠**有没有指纹**区分,不是靠扩展名:
|
||
# 带 ?v=<mtime>(index.html 注入的)→ URL 变了就是新文件,
|
||
# 可以放心 immutable 长缓存,浏览器第二次打开直接本地命中,
|
||
# 583KB 一秒都不传。
|
||
# 没指纹(用户手输 /app.js、收藏夹、别的页面链过来)
|
||
# → 必须 no-cache,否则部署新版本后用户拿到的是旧文件。
|
||
#
|
||
# 早先不分这两种,一律 no-cache —— 首屏每次都要重传 583KB(gzip 后 198KB),
|
||
# 而指纹明明已经注入了,白白浪费了长缓存。局域网感觉不出来,
|
||
# 走外网(cloudflared / 远程访问)就是每开一次面板多传 200KB。
|
||
# 指纹不匹配的判据在 t-gzip.py 里。
|
||
fingerprinted = 'v=' in (self.path or '')
|
||
static_cc = ('public, max-age=31536000, immutable'
|
||
if fingerprinted else 'no-cache')
|
||
if etag and (self.headers.get('If-None-Match') or '') == etag:
|
||
self.send_response(304)
|
||
if etag:
|
||
self.send_header('ETag', etag)
|
||
self.send_header('Cache-Control', static_cc)
|
||
self.end_headers()
|
||
return
|
||
|
||
with open(full, 'rb') as f:
|
||
data = f.read()
|
||
|
||
# 入口页注入资源版本号:部署后用户浏览器不必手动强刷就能拿到新前端。
|
||
# 之前只发 Cache-Control: no-cache(无 ETag/Last-Modified 校验器),
|
||
# 部分浏览器仍按启发式规则继续使用旧副本,表现为「部署完了还是老界面」。
|
||
if full.endswith('index.html'):
|
||
data = _inject_asset_version(data)
|
||
|
||
enc = None
|
||
if len(data) >= GZIP_MIN:
|
||
data, enc = _encode_body(self.headers, full, data)
|
||
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', ctype)
|
||
if enc:
|
||
self.send_header('Content-Encoding', enc)
|
||
self.send_header('Vary', 'Accept-Encoding')
|
||
if etag:
|
||
self.send_header('ETag', etag)
|
||
self.send_header('Content-Length', str(len(data)))
|
||
if full.endswith('.html'):
|
||
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
else:
|
||
self.send_header('Cache-Control', static_cc)
|
||
self.end_headers()
|
||
if self.command != 'HEAD':
|
||
self.wfile.write(data)
|
||
|
||
THEME_CSS_FILE = '/etc/drouter/generated/theme.css'
|
||
|
||
def _serve_theme_css(self, qver=None):
|
||
"""返回当前生效主题的 CSS(主题之家 #12)。
|
||
|
||
-cache 由 drouter-helper 生成后写盘,静态返回即可
|
||
-v=<ts> 查询串用于前端主动绕过浏览器缓存
|
||
- 文件不存在时返回空样式(而不是 404),保证首屏不会缺样式
|
||
"""
|
||
path = self.THEME_CSS_FILE
|
||
if not os.path.isfile(path):
|
||
data = u'/* 尚未生成任何主题,使用内置默认样式 */\n'.encode('utf-8')
|
||
else:
|
||
try:
|
||
with open(path, 'rb') as f:
|
||
data = f.read()
|
||
except Exception:
|
||
data = u'/* 主题样式读取失败,回落到默认样式 */\n'.encode('utf-8')
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'text/css; charset=utf-8')
|
||
self.send_header('Content-Length', str(len(data)))
|
||
self.send_header('Cache-Control', 'no-cache')
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
def _send_text(self, code, text):
|
||
data = ('<!doctype html><meta charset="utf-8">'
|
||
'<body style="font-family:system-ui;padding:40px">%s</body>' % text).encode('utf-8')
|
||
self.send_response(code)
|
||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||
self.send_header('Content-Length', str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
def _handle(self, method):
|
||
parsed = urlparse(self.path)
|
||
path = parsed.path
|
||
query = parse_qs(parsed.query)
|
||
# 主题样式:无需登录(登录页也要用自己的主题),只读、无副作用
|
||
if path == '/theme.css' and method == 'GET':
|
||
# v=<ts> 只在查询串里用于绕过浏览器缓存,函数本身不需要它
|
||
return self._serve_theme_css()
|
||
if path.startswith('/api/'):
|
||
try:
|
||
api = Api(self)
|
||
api.dispatch(method, path, query)
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
try:
|
||
api.json({'ok': False, 'code': 'EXCEPTION',
|
||
'msg_cn': '服务器内部错误:%s' % e}, 500)
|
||
except Exception:
|
||
pass
|
||
return
|
||
if method != 'GET':
|
||
return self._send_text(405, '不支持的请求方法')
|
||
self._serve_static(path)
|
||
|
||
def do_GET(self):
|
||
self._handle('GET')
|
||
|
||
def do_POST(self):
|
||
self._handle('POST')
|
||
|
||
def do_OPTIONS(self):
|
||
self.send_response(204)
|
||
self.send_header('Allow', 'GET, POST, OPTIONS')
|
||
self.send_header('Content-Length', '0')
|
||
self.end_headers()
|
||
|
||
|
||
# 登录成功后需要把 token 返回给前端:在 Api.json 拦截不方便,
|
||
# 因此在 dispatch 登录分支里直接注入 token。
|
||
_orig_login = Api.login
|
||
|
||
|
||
def login_with_token(self):
|
||
ip = self.h.client_address[0]
|
||
f = LOGIN_FAILS.get(ip)
|
||
if f and f['n'] >= MAX_LOGIN_FAILS and f['until'] > time.time():
|
||
left = int((f['until'] - time.time()) / 60) + 1
|
||
return self.json({'ok': False, 'code': 'LOCKED',
|
||
'msg_cn': '登录失败次数过多,请 %d 分钟后再试' % left}, 429)
|
||
b = self.body()
|
||
u = (b.get('username') or '').strip()
|
||
pw = b.get('password') or ''
|
||
with _lock, db() as c:
|
||
row = c.execute('SELECT * FROM admins WHERE username=?', (u,)).fetchone()
|
||
if not row or hash_pw(pw, row['salt']) != row['pw_hash']:
|
||
# 失败记录里那些「试过一次就再也没来」的来源地址要定期清掉:
|
||
# 公网暴露 + IPv6 大地址空间下,这个字典会长期无界增长。
|
||
if len(LOGIN_FAILS) > 2000:
|
||
now0 = time.time()
|
||
for k in [k for k, v in list(LOGIN_FAILS.items())
|
||
if not v.get('until') or v['until'] < now0]:
|
||
LOGIN_FAILS.pop(k, None)
|
||
rec = LOGIN_FAILS.setdefault(ip, {'n': 0, 'until': 0})
|
||
rec['n'] += 1
|
||
rec['until'] = time.time() + LOGIN_LOCK_MINUTES * 60 if rec['n'] >= MAX_LOGIN_FAILS else 0
|
||
audit(u or '(空)', '登录失败', ip, False, ip)
|
||
left = MAX_LOGIN_FAILS - rec['n']
|
||
return self.json({'ok': False, 'code': 'BADCRED',
|
||
'msg_cn': '用户名或密码错误' + ('(还可尝试 %d 次)' % left if left > 0 else '')}, 401)
|
||
LOGIN_FAILS.pop(ip, None)
|
||
sess = new_session(u, ip)
|
||
tok = next(k for k, v in SESSIONS.items() if v is sess)
|
||
audit(u, '登录成功', ip, True, ip)
|
||
return self.json({'ok': True, 'msg_cn': '登录成功',
|
||
'data': {'token': tok, 'user': u, 'expires_in': SESSION_MINUTES * 60}})
|
||
|
||
|
||
Api.login = login_with_token
|
||
|
||
|
||
# ------------------------------------------------------------------ 启动
|
||
|
||
def main():
|
||
init_db()
|
||
global PORT_HTTPS
|
||
PORT_HTTPS = load_web_port()
|
||
crt, key = ensure_cert()
|
||
threading.Thread(target=gc_sessions, daemon=True).start()
|
||
|
||
httpd = _Server((HOST, PORT_HTTP), Handler)
|
||
httpd.daemon_threads = True
|
||
|
||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||
try:
|
||
ctx.load_cert_chain(crt, key)
|
||
httpsd = _Server((HOST, PORT_HTTPS), Handler)
|
||
httpsd.socket = ctx.wrap_socket(httpsd.socket, server_side=True)
|
||
httpsd.daemon_threads = True
|
||
threading.Thread(target=httpsd.serve_forever, daemon=True).start()
|
||
print('HTTPS 监听 %s:%d' % (HOST, PORT_HTTPS), flush=True)
|
||
except Exception as e:
|
||
print('HTTPS 启动失败:%s' % e, flush=True)
|
||
|
||
print('HTTP 监听 %s:%d' % (HOST, PORT_HTTP), flush=True)
|
||
try:
|
||
httpd.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|