一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的 Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、 访问控制、DDNS、文件共享、Docker 管理等。 核心设计原则:不重新实现任何协议 PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。 Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。 面板删掉后,系统仍是一台配置完好的 Debian 路由器。 安全默认值 · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务 · 所有改网络的动作都需要用户在面板上亲手点「应用」 · 避开 5900 端口,不触碰 VNC / X 会话 · 配置写入前先做语法预检,失败则回滚 架构 · backend/ Python 3 标准库,零第三方依赖(9 个模块) · web/ 原生 JS/CSS,无构建步骤 · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess · 鉴权走 X-Token 请求头 打包 · packaging/build-deb.sh → drouter_1.0.0_all.deb(含 6 个 systemd 单元) · scripts/make-offline-deps.sh → 236 个依赖 .deb + 本地仓库索引 · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装 · packaging/build-docker.sh → 容器镜像(体验用) 测试 40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配), 全部挂在 devtools/sync.sh 的预检链里。 许可:MIT
827 lines
37 KiB
Python
827 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""AC / AP 管理中心 OpenSOHO(#10):静态契约 + 纯函数 + 安装/卸载逻辑单测。
|
||
|
||
盯五件事:
|
||
① 两把密钥的分工不能搞混 —— 共享密钥改了 AP 要重注册(可接受),
|
||
加密密钥改了历史配置直接解不开(不可接受)。所以升级、改端口、改开关
|
||
都绝不能重新生成加密密钥,只有卸载清除才动它;
|
||
② 解包必须挡住 zip slip —— 压缩包是外部下载的,成员名带 ../ 不能落到
|
||
目标目录之外;
|
||
③ 端口钳制不能把 0 当成「没填」—— int(x or 8090) 的 falsy 陷阱;
|
||
④ 构建保护模式下 install / svc / save 只写盘不启服务;
|
||
⑤ 不碰安全红线:5900、dnsmasq、nft、默认路由。
|
||
"""
|
||
import ast
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
import json
|
||
import shutil
|
||
import stat
|
||
import zipfile
|
||
import tempfile
|
||
import urllib
|
||
import urllib.request
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
|
||
WEB = os.path.join(ROOT, 'backend', 'drouter-web.py')
|
||
APP = os.path.join(ROOT, 'web', 'app.js')
|
||
|
||
PASS = FAIL = 0
|
||
|
||
|
||
def chk(name, cond, extra=''):
|
||
global PASS, FAIL
|
||
if cond:
|
||
PASS += 1
|
||
print('[OK] %s %s' % (name, extra))
|
||
else:
|
||
FAIL += 1
|
||
print('[FAIL] %s %s' % (name, extra))
|
||
|
||
|
||
HELPER_SRC = io.open(HELPER, encoding='utf-8').read()
|
||
WEB_SRC = io.open(WEB, encoding='utf-8').read()
|
||
APP_SRC = io.open(APP, encoding='utf-8').read()
|
||
TREE = ast.parse(HELPER_SRC)
|
||
|
||
|
||
def node(name):
|
||
for n in TREE.body:
|
||
if isinstance(n, ast.Assign):
|
||
for t in n.targets:
|
||
if isinstance(t, ast.Name) and t.id == name:
|
||
return n
|
||
# 元组解包赋值(如 `A, B = 1, 2`):目标名散在 Tuple 里
|
||
if isinstance(t, ast.Tuple):
|
||
for e in t.elts:
|
||
if isinstance(e, ast.Name) and e.id == name:
|
||
return n
|
||
elif isinstance(n, ast.FunctionDef) and n.name == name:
|
||
return n
|
||
return None
|
||
|
||
|
||
def build(names, ns=None):
|
||
ns = ns if ns is not None else {}
|
||
body = []
|
||
for nm in names:
|
||
n = node(nm)
|
||
if n is None:
|
||
raise AssertionError('源码里找不到 %s' % nm)
|
||
body.append(n)
|
||
mod = ast.Module(body=body, type_ignores=[])
|
||
ast.fix_missing_locations(mod)
|
||
exec(compile(mod, '<opensoho>', 'exec'), ns)
|
||
return ns
|
||
|
||
|
||
# ---------------------------------------------------------------- 测试替身
|
||
CALLS = []
|
||
ENVS = []
|
||
CWDS = []
|
||
SH_OUT = {}
|
||
URLS = []
|
||
HTTP_BODY = {}
|
||
BUILD_MODE = [False]
|
||
OPENED = []
|
||
|
||
|
||
def fake_sh(argv, timeout=10, input_data=None, cwd=None, env=None):
|
||
CALLS.append(list(argv))
|
||
ENVS.append(dict(env or {}))
|
||
CWDS.append(cwd)
|
||
a = list(argv)
|
||
if a[:2] == ['uname', '-m']:
|
||
return (0, SH_OUT.get('uname', 'x86_64'), '')
|
||
if a[:2] == ['systemctl', 'is-active']:
|
||
return (0, SH_OUT.get('active', 'inactive'), '')
|
||
if a[:2] == ['systemctl', 'is-enabled']:
|
||
return (0, SH_OUT.get('enabled', 'disabled'), '')
|
||
if a[0] == 'ip':
|
||
return (0, SH_OUT.get('ip_out', ''), '')
|
||
if a[0] == 'ss':
|
||
return (0, SH_OUT.get('ss_out', ''), '')
|
||
if a[0] == 'id':
|
||
return (0, '', '')
|
||
if len(a) >= 2 and a[1] == '-v':
|
||
return (0, SH_OUT.get('ver', 'opensoho version 0.15.2'), '')
|
||
return (0, 'ok', '')
|
||
|
||
|
||
def _ok(data=None, msg='ok', code='OK'):
|
||
return {'ok': True, 'msg_cn': msg, 'code': code, 'data': data or {}}
|
||
|
||
|
||
def _fail(msg, code='ERR', data=None):
|
||
return {'ok': False, 'msg_cn': msg, 'code': code, 'data': data or {}}
|
||
|
||
|
||
def _log(*a, **k):
|
||
return None
|
||
|
||
|
||
def _in_build_mode():
|
||
return BUILD_MODE[0]
|
||
|
||
|
||
class _FakeResp(object):
|
||
def __init__(self, body):
|
||
self._b = body if isinstance(body, bytes) else body.encode()
|
||
|
||
def read(self, n=None):
|
||
return self._b if n is None else self._b[:n]
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *a):
|
||
return False
|
||
|
||
|
||
class _FakeRequestMod(object):
|
||
@staticmethod
|
||
def Request(url, data=None, headers=None, method=None):
|
||
return (url, data, headers, method)
|
||
|
||
@staticmethod
|
||
def urlopen(arg, timeout=None, context=None):
|
||
u = arg[0] if isinstance(arg, tuple) else arg
|
||
URLS.append(u)
|
||
body = HTTP_BODY.get(u)
|
||
if body is None:
|
||
raise IOError('stub: no body for %s' % u)
|
||
return _FakeResp(body)
|
||
|
||
|
||
class _ShimURLLib(object):
|
||
request = _FakeRequestMod
|
||
|
||
|
||
# zipfile 已经在文件头真实导入过了,这里替换 sys.modules 不会影响它
|
||
_real_urllib = sys.modules.get('urllib')
|
||
sys.modules['urllib'] = _ShimURLLib
|
||
|
||
|
||
def _fake_open(path, mode='r', *a, **k):
|
||
OPENED.append(path)
|
||
return io.open(path, mode, *a, **k)
|
||
|
||
|
||
# Windows 的 os.chmod 只认「只读」那一位,0600 / 0755 都落不下去。
|
||
# 真机是 Linux,所以这里不能把「位不对」当成源码 bug —— 落盘校验只在 POSIX 上做,
|
||
# 本机改为静态校验源码里确实写了正确的 mode。
|
||
IS_WIN = (os.name == 'nt')
|
||
|
||
|
||
def perm_ok(path, want):
|
||
if IS_WIN:
|
||
return True
|
||
return (os.stat(path).st_mode & 0o777) == want
|
||
|
||
|
||
def mode_str(path):
|
||
return oct(os.stat(path).st_mode & 0o777)
|
||
|
||
|
||
# ---------------------------------------------------------------- 构造命名空间
|
||
TMP = tempfile.mkdtemp(prefix='t-opensoho-')
|
||
|
||
NS = build([
|
||
'OH_DEFAULTS', 'OH_ITEMS', 'OH_COLLECTIONS',
|
||
'OH_SECRET_ENV', 'OH_ENC_ENV', 'OH_SECRET_LEN', 'OH_ENC_LEN',
|
||
'OH_ADMIN_EMAIL', 'OH_API_LATEST', 'OH_PORT_MIN', 'OH_PORT_MAX',
|
||
'OH_BIND_CHOICES', 'OH_UNIT', 'OH_SERVICE', 'OH_USER',
|
||
'OH_DIR', 'OH_BIN', 'OH_DATA', 'OH_CONF', 'OH_ENV', 'OH_ADMIN',
|
||
'_oh_rand', '_oh_norm', '_oh_load', '_oh_save',
|
||
'_oh_read_env', '_oh_write_env', '_oh_ensure_env', '_oh_env',
|
||
'_oh_admin_load', '_oh_admin_save',
|
||
'_oh_installed', '_oh_service', '_oh_version', '_oh_arch',
|
||
'_oh_cli', '_oh_wait_up',
|
||
'_oh_unit_body', '_oh_write_unit',
|
||
'_oh_listen_now', '_oh_lan_ip', '_oh_health',
|
||
'_oh_api', '_oh_login', '_oh_ensure_admin', '_oh_admin_token',
|
||
'_oh_local_candidates', '_oh_ensure_user',
|
||
'_oh_latest_release', '_oh_download', '_oh_extract_bin',
|
||
'_oh_status', '_oh_install_op', '_oh_uninstall_op', '_oh_svc_op',
|
||
'_oh_save_op', '_oh_secret_op', '_oh_probe_op', '_oh_pick',
|
||
'act_opensoho',
|
||
], {
|
||
're': re, 'os': os, 'json': json, 'shutil': shutil, 'stat': stat,
|
||
'time': __import__('time'), 'sh': fake_sh,
|
||
'ok': _ok, 'fail': _fail, 'log': _log, 'in_build_mode': _in_build_mode,
|
||
'tempfile': tempfile,
|
||
})
|
||
|
||
# 路径常量一律重定向到临时目录 —— 绝不抽源码里的路径常量节点(会覆盖注入值,
|
||
# 让测试读写真实路径造成跨次污染)
|
||
NS['OH_DIR'] = os.path.join(TMP, 'opt', 'opensoho')
|
||
NS['OH_BIN'] = os.path.join(NS['OH_DIR'], 'opensoho')
|
||
NS['OH_DATA'] = os.path.join(TMP, 'var', 'opensoho')
|
||
NS['OH_CONF'] = os.path.join(TMP, 'etc', 'opensoho.conf')
|
||
NS['OH_ENV'] = os.path.join(TMP, 'etc', 'opensoho.env')
|
||
NS['OH_ADMIN'] = os.path.join(TMP, 'etc', 'opensoho.admin')
|
||
NS['OH_UNIT'] = os.path.join(TMP, 'etc', 'opensoho.service')
|
||
for _d in (os.path.dirname(NS['OH_CONF']), NS['OH_DIR'], NS['OH_DATA']):
|
||
os.makedirs(_d, exist_ok=True)
|
||
|
||
DEFAULTS = NS['OH_DEFAULTS']
|
||
ITEMS = NS['OH_ITEMS']
|
||
COLLECTIONS = NS['OH_COLLECTIONS']
|
||
SECRET_ENV = NS['OH_SECRET_ENV']
|
||
ENC_ENV = NS['OH_ENC_ENV']
|
||
SECRET_LEN = NS['OH_SECRET_LEN']
|
||
ENC_LEN = NS['OH_ENC_LEN']
|
||
BIND_CHOICES = NS['OH_BIND_CHOICES']
|
||
|
||
oh_rand = NS['_oh_rand']
|
||
oh_norm = NS['_oh_norm']
|
||
oh_load = NS['_oh_load']
|
||
oh_save = NS['_oh_save']
|
||
read_env = NS['_oh_read_env']
|
||
write_env = NS['_oh_write_env']
|
||
ensure_env = NS['_oh_ensure_env']
|
||
oh_env = NS['_oh_env']
|
||
admin_load = NS['_oh_admin_load']
|
||
admin_save = NS['_oh_admin_save']
|
||
oh_installed = NS['_oh_installed']
|
||
oh_service = NS['_oh_service']
|
||
oh_version = NS['_oh_version']
|
||
oh_arch = NS['_oh_arch']
|
||
unit_body = NS['_oh_unit_body']
|
||
write_unit = NS['_oh_write_unit']
|
||
oh_cli = NS['_oh_cli']
|
||
oh_wait_up = NS['_oh_wait_up']
|
||
listen_now = NS['_oh_listen_now']
|
||
oh_health = NS['_oh_health']
|
||
oh_api = NS['_oh_api']
|
||
oh_login = NS['_oh_login']
|
||
oh_admintoken = NS['_oh_admin_token']
|
||
local_cands = NS['_oh_local_candidates']
|
||
ensure_user = NS['_oh_ensure_user']
|
||
latest_release = NS['_oh_latest_release']
|
||
extract_bin = NS['_oh_extract_bin']
|
||
oh_status = NS['_oh_status']
|
||
install_op = NS['_oh_install_op']
|
||
uninstall_op = NS['_oh_uninstall_op']
|
||
svc_op = NS['_oh_svc_op']
|
||
save_op = NS['_oh_save_op']
|
||
secret_op = NS['_oh_secret_op']
|
||
probe_op = NS['_oh_probe_op']
|
||
oh_pick = NS['_oh_pick']
|
||
act = NS['act_opensoho']
|
||
|
||
|
||
def main():
|
||
print('================ OpenSOHO AC/AP 单测 ================')
|
||
|
||
print('\n--- 出厂默认值 ---')
|
||
chk('默认监听 0.0.0.0(AP 要从局域网注册进来)',
|
||
DEFAULTS['bind'] == '0.0.0.0', DEFAULTS['bind'])
|
||
chk('默认端口 8090', DEFAULTS['port'] == 8090, str(DEFAULTS['port']))
|
||
chk('默认开机自启', DEFAULTS['autostart'] is True)
|
||
chk('默认自动接管新 AP', DEFAULTS['enable_new_devices'] is True)
|
||
chk('默认开启配置静态加密', DEFAULTS['encryption'] is True)
|
||
chk('共享密钥长度要求 32', SECRET_LEN == 32, str(SECRET_LEN))
|
||
chk('加密密钥长度 32(--encryptionEnv 硬性要求)', ENC_LEN == 32)
|
||
chk('监听地址只给三个白名单选项',
|
||
sorted(BIND_CHOICES) == ['0.0.0.0', '127.0.0.1', '::'], str(BIND_CHOICES))
|
||
|
||
print('\n--- 说明条目(每项都要讲清为什么与影响) ---')
|
||
chk('说明条目不少于 6 项', len(ITEMS) >= 6, str(len(ITEMS)))
|
||
for it in ITEMS:
|
||
chk('说明 %s 有 why 与 impact' % it['key'],
|
||
bool(it.get('why')) and len(it.get('impact') or []) >= 2)
|
||
|
||
print('\n--- 集合名必须是实测到的真实名字 ---')
|
||
names = [c[0] for c in COLLECTIONS]
|
||
for must in ('devices', 'clients', 'radios', 'wifi_ssids', 'wifi_aps'):
|
||
chk('集合含 %s' % must, must in names)
|
||
chk('没有用文档里那个不存在的 wifi 集合名',
|
||
'wifi' not in names, str(names))
|
||
|
||
print('\n--- 端口钳制(int(x or 默认) 的 falsy 陷阱) ---')
|
||
chk('端口 0 不被悄悄回落成默认', oh_norm({'port': 0})['port'] == 1024,
|
||
str(oh_norm({'port': 0})['port']))
|
||
chk('端口 1 被抬到 1024', oh_norm({'port': 1})['port'] == 1024)
|
||
chk('端口 70000 被压到 65535', oh_norm({'port': 70000})['port'] == 65535)
|
||
chk('端口 8091 原样保留', oh_norm({'port': 8091})['port'] == 8091)
|
||
chk('端口 None 用默认 8090', oh_norm({'port': None})['port'] == 8090)
|
||
chk('端口空串用默认 8090', oh_norm({'port': ''})['port'] == 8090)
|
||
chk('端口是垃圾字符串时用默认 8090', oh_norm({'port': 'abc'})['port'] == 8090)
|
||
chk('端口是字符串数字也能用', oh_norm({'port': '9090'})['port'] == 9090)
|
||
|
||
print('\n--- 其它字段收敛 ---')
|
||
chk('非法监听地址回落 0.0.0.0', oh_norm({'bind': 'evil'})['bind'] == '0.0.0.0')
|
||
chk('合法监听地址保留', oh_norm({'bind': '127.0.0.1'})['bind'] == '127.0.0.1')
|
||
chk('autostart 被收成 bool', oh_norm({'autostart': 0})['autostart'] is False)
|
||
chk('缺失字段用默认值补齐',
|
||
oh_norm({}) == dict(DEFAULTS), str(oh_norm({})))
|
||
|
||
print('\n--- 配置读写往返 ---')
|
||
oh_save({'bind': '127.0.0.1', 'port': 9099, 'autostart': False,
|
||
'enable_new_devices': False, 'encryption': False})
|
||
back = oh_load()
|
||
chk('保存后能原样读回', back == {'bind': '127.0.0.1', 'port': 9099,
|
||
'autostart': False, 'enable_new_devices': False,
|
||
'encryption': False}, str(back))
|
||
chk('配置文件真的落盘了', os.path.isfile(NS['OH_CONF']))
|
||
os.unlink(NS['OH_CONF'])
|
||
chk('删掉后读回出厂默认', oh_load() == dict(DEFAULTS))
|
||
# 坏文件不能让页面白屏
|
||
with open(NS['OH_CONF'], 'w') as f:
|
||
f.write('{ this is not json')
|
||
chk('配置损坏时回落默认值而不是崩溃', oh_load() == dict(DEFAULTS))
|
||
os.unlink(NS['OH_CONF'])
|
||
|
||
print('\n--- systemd 单元 ---')
|
||
u = unit_body(DEFAULTS)
|
||
chk('单元指定了专用用户', 'User=opensoho' in u)
|
||
chk('单元指定了专用组', 'Group=opensoho' in u)
|
||
chk('密钥走 EnvironmentFile 而不是 Environment=(单元是 0644)',
|
||
'EnvironmentFile=' in u and 'Environment=OPENSOHO' not in u)
|
||
chk('单元显式写了 /usr/sbin 进 PATH',
|
||
'/usr/sbin' in u)
|
||
chk('单元带了 --dir(默认值随运行用户变,必须显式给)', '--dir' in u)
|
||
chk('单元带了数据目录绝对路径', NS['OH_DATA'] in u)
|
||
# 【实测踩坑】pb_migrations 是解压到「工作目录」而不是 --dir 的,
|
||
# 工作目录指向 root 所有的二进制目录会让服务用户写不进去、启动秒退。
|
||
chk('工作目录指向数据目录而不是二进制目录',
|
||
('WorkingDirectory=%s' % NS['OH_DATA']) in u
|
||
and ('WorkingDirectory=%s' % NS['OH_DIR']) not in u,
|
||
[l for l in u.splitlines() if l.startswith('WorkingDirectory')])
|
||
# 只看常量赋值本身,别被注释里「为什么不用 /var/lib/drouter/...」的说明误伤
|
||
_m = re.search(r"^OH_DATA = '([^']+)'", HELPER_SRC, re.M)
|
||
chk('数据目录常量不在 /var/lib/drouter 下(父目录 0750 会让服务用户穿不进去)',
|
||
bool(_m) and not _m.group(1).startswith('/var/lib/drouter'),
|
||
_m.group(1) if _m else '未找到 OH_DATA')
|
||
chk('默认监听 0.0.0.0:8090', '--http 0.0.0.0:8090' in u)
|
||
chk('默认开启自动接管', '--enableNewDevices=true' in u)
|
||
chk('默认带加密密钥环境变量', '--encryptionEnv=OPENSOHO_SETTINGS_KEY' in u)
|
||
chk('有 Install 段', 'WantedBy=multi-user.target' in u)
|
||
chk('有失败重启', 'Restart=on-failure' in u)
|
||
chk('做了最小权限收敛',
|
||
'NoNewPrivileges=yes' in u and 'ProtectSystem=full' in u)
|
||
u2 = unit_body({'bind': '127.0.0.1', 'port': 9999,
|
||
'enable_new_devices': False, 'encryption': False})
|
||
chk('改监听与端口会进 ExecStart', '--http 127.0.0.1:9999' in u2)
|
||
chk('关闭自动接管会写成 false', '--enableNewDevices=false' in u2)
|
||
chk('关闭加密就不带 --encryptionEnv', '--encryptionEnv' not in u2)
|
||
write_unit(DEFAULTS)
|
||
chk('写单元后文件存在', os.path.isfile(NS['OH_UNIT']))
|
||
chk('单元文件权限 0644', perm_ok(NS['OH_UNIT'], 0o644), mode_str(NS['OH_UNIT']))
|
||
chk('源码里显式 chmod 了单元文件', 'os.chmod(OH_UNIT, 0o644)' in HELPER_SRC)
|
||
|
||
print('\n--- 两把密钥 ---')
|
||
s1, e1 = ensure_env()
|
||
chk('首次生成共享密钥长度达标', len(s1) == SECRET_LEN, str(len(s1)))
|
||
chk('首次生成加密密钥长度 32', len(e1) == ENC_LEN, str(len(e1)))
|
||
chk('共享密钥是字母数字(方便往 LuCI 粘贴)', s1.isalnum())
|
||
s2, e2 = ensure_env()
|
||
chk('第二次调用共享密钥不变', s2 == s1)
|
||
chk('【关键】第二次调用加密密钥绝不变——变了历史配置就解不开', e2 == e1)
|
||
chk('密钥文件权限是 0600(里面有 Wi-Fi 配置的加密密钥)',
|
||
perm_ok(NS['OH_ENV'], 0o600), mode_str(NS['OH_ENV']))
|
||
chk('源码里显式 chmod 了密钥文件为 0600',
|
||
'os.chmod(OH_ENV, 0o600)' in HELPER_SRC)
|
||
got = read_env()
|
||
chk('能读回共享密钥', got[0] == s1)
|
||
chk('能读回加密密钥', got[1] == e1)
|
||
# 手工塞一个短密钥,应该被判定为弱并重新生成
|
||
write_env('short', e1)
|
||
s3, e3 = ensure_env()
|
||
chk('共享密钥太短会被重新生成', len(s3) == SECRET_LEN and s3 != 'short')
|
||
chk('重生成共享密钥时加密密钥依然不动', e3 == e1)
|
||
write_env(s1, e1)
|
||
# 解析健壮性:注释、引号、空行都不能让它崩
|
||
with open(NS['OH_ENV'], 'w') as f:
|
||
f.write('# comment\n\n%s="%s"\n%s=%s\n' % (SECRET_ENV, s1, ENC_ENV, e1))
|
||
chk('能解析带注释与引号的环境文件', read_env() == (s1, e1))
|
||
write_env(s1, e1)
|
||
envd = oh_env()
|
||
chk('给子命令的环境里带了共享密钥', envd.get(SECRET_ENV) == s1)
|
||
chk('给子命令的环境里带了加密密钥', envd.get(ENC_ENV) == e1)
|
||
# 连 --help 都要求共享密钥已设置,所以绝不能给空串
|
||
chk('环境里的共享密钥不会是空串(空了连 -v 都跑不了)',
|
||
bool(envd.get(SECRET_ENV)))
|
||
|
||
print('\n--- 控制台管理员凭据 ---')
|
||
admin_save({'email': 'admin@drouter.local', 'password': 'pw', 'token': 't'})
|
||
chk('凭据文件权限 0600', perm_ok(NS['OH_ADMIN'], 0o600), mode_str(NS['OH_ADMIN']))
|
||
chk('源码里显式 chmod 了凭据文件为 0600',
|
||
'os.chmod(OH_ADMIN, 0o600)' in HELPER_SRC)
|
||
chk('能读回凭据', admin_load().get('email') == 'admin@drouter.local')
|
||
with open(NS['OH_ADMIN'], 'w') as f:
|
||
f.write('not json')
|
||
chk('凭据文件损坏时返回空而不是崩溃', admin_load() == {})
|
||
os.unlink(NS['OH_ADMIN'])
|
||
|
||
print('\n--- 健康检查(/api/health 免鉴权) ---')
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = \
|
||
'{"message":"API is healthy.","code":200,"data":{}}'
|
||
okh, msg = oh_health(8090)
|
||
chk('健康响应被判定为正常', okh is True and msg == 'ok', msg)
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
|
||
chk('只有 code:200 也算正常', oh_health(8090)[0] is True)
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = 'garbage'
|
||
okh2, msg2 = oh_health(8090)
|
||
chk('异常响应被判定为不健康', okh2 is False, msg2)
|
||
HTTP_BODY.clear()
|
||
okh3, msg3 = oh_health(8090)
|
||
chk('连不上被判定为不健康且不抛异常', okh3 is False and '连不上' in msg3, msg3)
|
||
|
||
print('\n--- REST 调用 ---')
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/devices/records?perPage=1'] = \
|
||
'{"items":[],"totalItems":0}'
|
||
d, err = oh_api('GET', '/api/collections/devices/records?perPage=1', 8090, 'tok')
|
||
chk('GET 能拿到解析后的 dict', d is not None and d.get('totalItems') == 0, err)
|
||
HTTP_BODY.clear()
|
||
d2, err2 = oh_api('GET', '/api/x', 8090, 'tok')
|
||
chk('失败时返回 None 与错误串而不是抛异常', d2 is None and bool(err2))
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
|
||
'{"token":"abc.def.ghi"}'
|
||
tok, e = oh_login(8090, 'a@b.c', 'pw')
|
||
chk('登录能取到 token', tok == 'abc.def.ghi', e)
|
||
HTTP_BODY.clear()
|
||
tok2, e2 = oh_login(8090, 'a@b.c', 'pw')
|
||
chk('登录失败时返回空 token 与错误', tok2 == '' and bool(e2))
|
||
|
||
print('\n--- 字段取值(不硬猜字段名) ---')
|
||
chk('取第一个非空候选', oh_pick({'mac': 'aa:bb'}, ('name', 'mac')) == 'aa:bb')
|
||
chk('候选全空时返回空串', oh_pick({'x': 1}, ('name', 'mac')) == '')
|
||
chk('空值被跳过继续找下一个',
|
||
oh_pick({'name': '', 'mac': 'cc'}, ('name', 'mac')) == 'cc')
|
||
chk('None 入参不崩', oh_pick(None, ('name',)) == '')
|
||
|
||
print('\n--- 解包:zip slip 防护 ---')
|
||
zpath = os.path.join(TMP, 'evil.zip')
|
||
with zipfile.ZipFile(zpath, 'w') as z:
|
||
z.writestr('opensoho', b'\x7fELF-fake-binary')
|
||
z.writestr('../../../../tmp/pwned-by-opensoho', b'evil')
|
||
z.writestr('some/dir/note.txt', b'hi')
|
||
outdir = os.path.join(TMP, 'extracted')
|
||
got = extract_bin(zpath, outdir)
|
||
chk('解出了 opensoho 二进制', os.path.basename(got) == 'opensoho')
|
||
chk('二进制带执行权限',
|
||
True if IS_WIN else (os.stat(got).st_mode & 0o111) != 0)
|
||
chk('源码里显式 chmod 了解出的二进制为 0755',
|
||
'os.chmod(dst, 0o755)' in HELPER_SRC)
|
||
chk('【关键】带 ../ 的成员没有落到目标目录之外',
|
||
not os.path.exists('/tmp/pwned-by-opensoho'))
|
||
chk('只解了那一个文件,没有把整个压缩包铺开',
|
||
not os.path.exists(os.path.join(outdir, 'some')))
|
||
badzip = os.path.join(TMP, 'bad.zip')
|
||
with zipfile.ZipFile(badzip, 'w') as z:
|
||
z.writestr('readme.txt', b'nothing here')
|
||
try:
|
||
extract_bin(badzip, os.path.join(TMP, 'extracted2'))
|
||
chk('压缩包里没二进制时要报错', False)
|
||
except Exception as ex:
|
||
chk('压缩包里没二进制时要报错', '没有 opensoho' in str(ex), str(ex)[:60])
|
||
raw = os.path.join(TMP, 'opensoho')
|
||
with open(raw, 'wb') as f:
|
||
f.write(b'\x7fELF-fake')
|
||
got2 = extract_bin(raw, os.path.join(TMP, 'extracted3'))
|
||
chk('裸二进制也能直接装', os.path.isfile(got2))
|
||
|
||
print('\n--- 最新版查询 ---')
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY[NS['OH_API_LATEST']] = json.dumps({
|
||
'tag_name': 'v0.15.2',
|
||
'assets': [{'name': 'opensoho_0.15.2_linux_amd64.zip',
|
||
'browser_download_url': 'https://x/opensoho.zip'},
|
||
{'name': 'opensoho_0.15.2_linux_arm64.zip',
|
||
'browser_download_url': 'https://x/opensoho-arm.zip'}]})
|
||
ver, url = latest_release('amd64')
|
||
chk('能取到版本号且去掉了 v 前缀', ver == '0.15.2', str(ver))
|
||
chk('按架构挑对了 amd64 包', url == 'https://x/opensoho.zip', str(url))
|
||
ver2, url2 = latest_release('arm64')
|
||
chk('按架构挑对了 arm64 包', url2 == 'https://x/opensoho-arm.zip', str(url2))
|
||
HTTP_BODY.clear()
|
||
v3, u3 = latest_release('amd64')
|
||
chk('查不到时返回空版本与中文错误', v3 == '' and 'GitHub' in u3, u3[:50])
|
||
chk('架构映射 x86_64 → amd64', oh_arch() == 'amd64')
|
||
SH_OUT['uname'] = 'aarch64'
|
||
chk('架构映射 aarch64 → arm64', oh_arch() == 'arm64')
|
||
SH_OUT['uname'] = 'mips'
|
||
chk('不支持的架构返回空', oh_arch() == '')
|
||
SH_OUT['uname'] = 'x86_64'
|
||
|
||
print('\n--- 子命令执行(cwd 与属主) ---')
|
||
CALLS[:] = []
|
||
ENVS[:] = []
|
||
CWDS[:] = []
|
||
oh_cli([NS['OH_BIN'], '-v'], timeout=20)
|
||
chk('子命令把共享密钥带进了环境',
|
||
any(e.get(SECRET_ENV) for e in ENVS))
|
||
chk('子命令把 cwd 设成了数据目录(否则 root 会在 / 下留 pb_migrations)',
|
||
NS['OH_DATA'] in CWDS, str([c for c in CWDS if c]))
|
||
chk('跑完把属主改回服务用户(不改下次服务起不来)',
|
||
any(c[:2] == ['chown', '-R'] for c in CALLS),
|
||
str([c for c in CALLS if c[:1] == ['chown']]))
|
||
chk('chown 的目标是 opensoho:opensoho',
|
||
any(c[:2] == ['chown', '-R'] and 'opensoho:opensoho' in c for c in CALLS))
|
||
chk('chown 的对象是数据目录',
|
||
any(c[:2] == ['chown', '-R'] and NS['OH_DATA'] in c for c in CALLS))
|
||
|
||
print('\n--- 启动确认(systemctl 返回 0 不代表活着) ---')
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
|
||
SH_OUT['active'] = 'active'
|
||
up, why = oh_wait_up({'port': 8090}, tries=2)
|
||
chk('服务在跑且 health 正常 → 判定起来了', up is True, why)
|
||
SH_OUT['active'] = 'inactive'
|
||
up2, why2 = oh_wait_up({'port': 8090}, tries=1)
|
||
chk('服务没起来 → 判定没起来', up2 is False)
|
||
chk('失败说明里给了排查命令', 'journalctl' in why2, why2[:80])
|
||
SH_OUT['active'] = 'active'
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = 'garbage'
|
||
up3, why3 = oh_wait_up({'port': 8090}, tries=2)
|
||
chk('服务在跑但端口没应答 → 判定没起来并说清端口',
|
||
up3 is False and '8090' in why3, why3[:80])
|
||
SH_OUT['active'] = 'inactive'
|
||
HTTP_BODY.clear()
|
||
|
||
print('\n--- 安装(离线:从本机文件) ---')
|
||
# 造一个可用的假二进制
|
||
with open(NS['OH_BIN'], 'wb') as f:
|
||
f.write(b'\x7fELF-fake-opensoho')
|
||
os.chmod(NS['OH_BIN'], 0o755)
|
||
chk('已安装判定为真', oh_installed() is True)
|
||
CALLS[:] = []
|
||
SH_OUT['active'] = 'active'
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
|
||
r = install_op({'local_path': raw})
|
||
chk('从本机文件安装成功', r['ok'] is True, r.get('msg_cn'))
|
||
chk('安装时建了 systemd 单元', os.path.isfile(NS['OH_UNIT']))
|
||
chk('安装时确保了密钥文件', os.path.isfile(NS['OH_ENV']))
|
||
chk('安装时重新加载了 systemd',
|
||
any(c[:2] == ['systemctl', 'daemon-reload'] for c in CALLS))
|
||
chk('安装后启动了服务',
|
||
any(c[:2] == ['systemctl', 'restart'] for c in CALLS))
|
||
chk('安装时先建了专用系统用户',
|
||
any(c[:1] == ['id'] for c in CALLS))
|
||
# 【实测踩坑】升级时服务正在跑,二进制被内核占用,直接覆盖会报
|
||
# 「Text file busy」,必须先停再换。
|
||
i_stop = next((i for i, c in enumerate(CALLS) if c[:2] == ['systemctl', 'stop']), -1)
|
||
i_restart = next((i for i, c in enumerate(CALLS)
|
||
if c[:2] == ['systemctl', 'restart']), -1)
|
||
chk('换二进制前先把服务停了', i_stop >= 0)
|
||
chk('停服务发生在重启之前', 0 <= i_stop < i_restart,
|
||
'stop=%d restart=%d' % (i_stop, i_restart))
|
||
CALLS[:] = []
|
||
r2 = install_op({'local_path': '/nope/definitely-not-here'})
|
||
chk('本地文件不存在时报 NOT_FOUND',
|
||
r2['ok'] is False and r2.get('code') == 'NOT_FOUND', r2.get('msg_cn'))
|
||
chk('【关键】文件不存在时不重启服务',
|
||
not any(c[0] == 'systemctl' and 'restart' in c for c in CALLS))
|
||
chk('安装失败但原本在跑 → 把服务拉回去,不留停止状态',
|
||
any(c[:2] == ['systemctl', 'start'] for c in CALLS))
|
||
r3 = install_op({})
|
||
chk('不带 local_path 时尝试联网下载(本机连不上 GitHub 会明确报错)',
|
||
r3['ok'] is False and r3.get('code') in ('NO_RELEASE', 'DL_FAIL'),
|
||
'%s / %s' % (r3.get('code'), (r3.get('msg_cn') or '')[:60]))
|
||
|
||
print('\n--- 构建保护模式:只写盘不启服务 ---')
|
||
BUILD_MODE[0] = True
|
||
CALLS[:] = []
|
||
r4 = install_op({'local_path': raw})
|
||
chk('保护模式下安装仍然成功(只是不启服务)', r4['ok'] is True, r4.get('msg_cn'))
|
||
chk('保护模式下不启服务',
|
||
not any(c[0] == 'systemctl' and 'restart' in c for c in CALLS))
|
||
chk('保护模式下提示了原因', '构建保护模式' in (r4.get('msg_cn') or ''))
|
||
CALLS[:] = []
|
||
r5 = svc_op({'op2': 'start'})
|
||
chk('保护模式下 start 被拦住',
|
||
r5['ok'] is False and r5.get('code') == 'BUILD_MODE_BLOCKED', r5.get('msg_cn'))
|
||
chk('保护模式下拦住时不调 systemctl start',
|
||
not any(c[:2] == ['systemctl', 'start'] for c in CALLS))
|
||
CALLS[:] = []
|
||
save_op({'bind': '127.0.0.1', 'port': 8123})
|
||
chk('保护模式下保存只写盘不重启',
|
||
not any(c[0] == 'systemctl' and 'restart' in c for c in CALLS))
|
||
chk('保护模式下保存仍然落了配置',
|
||
oh_load()['port'] == 8123, str(oh_load()['port']))
|
||
BUILD_MODE[0] = False
|
||
oh_save(dict(DEFAULTS))
|
||
|
||
print('\n--- 服务操作 ---')
|
||
CALLS[:] = []
|
||
r6 = svc_op({'op2': 'restart'})
|
||
chk('重启成功', r6['ok'] is True, r6.get('msg_cn'))
|
||
r7 = svc_op({'op2': 'bogus'})
|
||
chk('非法服务操作被拒', r7['ok'] is False and r7.get('code') == 'BAD_OP')
|
||
CALLS[:] = []
|
||
svc_op({'op2': 'enable'})
|
||
chk('enable 会把 autostart 落进配置', oh_load()['autostart'] is True)
|
||
svc_op({'op2': 'disable'})
|
||
chk('disable 会把 autostart 落进配置', oh_load()['autostart'] is False)
|
||
oh_save(dict(DEFAULTS))
|
||
|
||
print('\n--- 重新生成共享密钥 ---')
|
||
before_s, before_e = read_env()
|
||
CALLS[:] = []
|
||
r8 = secret_op({})
|
||
after_s, after_e = read_env()
|
||
chk('共享密钥确实换了', after_s != before_s)
|
||
chk('【关键】加密密钥绝不能被换掉', after_e == before_e)
|
||
chk('提示了 AP 要重新注册', '重新注册' in (r8.get('msg_cn') or ''),
|
||
r8.get('msg_cn'))
|
||
write_env(before_s, before_e)
|
||
|
||
print('\n--- 管理员凭据:首次要能自己建出来 ---')
|
||
if os.path.isfile(NS['OH_ADMIN']):
|
||
os.unlink(NS['OH_ADMIN'])
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
|
||
'{"token":"T1"}'
|
||
tok, e = oh_admintoken(8090)
|
||
chk('【关键】没有已存凭据时能现场建出管理员(首次安装后就是这种情况)',
|
||
tok == 'T1', e)
|
||
chk('凭据已落盘', os.path.isfile(NS['OH_ADMIN']))
|
||
chk('邮箱用的是默认管理员账号',
|
||
admin_load().get('email') == 'admin@drouter.local',
|
||
str(admin_load().get('email')))
|
||
chk('密码是随机生成的', len(admin_load().get('password') or '') >= 12)
|
||
URLS[:] = []
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/devices/records?perPage=1'] = \
|
||
'{"totalItems":0}'
|
||
tok2, _e2 = oh_admintoken(8090)
|
||
chk('已有有效 token 时直接复用', tok2 == 'T1')
|
||
chk('复用时不重复登录',
|
||
not any('auth-with-password' in u for u in URLS), str(URLS))
|
||
# token 失效(401)时要能用留存的密码重新换一个
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
|
||
'{"token":"T2"}'
|
||
tok3, _e3 = oh_admintoken(8090)
|
||
chk('token 失效时用留存密码重新换', tok3 == 'T2', str(tok3))
|
||
|
||
print('\n--- 子命令必须带同一个加密参数 ---')
|
||
# 库是 serve 带 --encryptionEnv 建的,superuser upsert 不带同一个参数就会报
|
||
# 「missing encryption key」,管理员永远建不出来。
|
||
CALLS[:] = []
|
||
oh_save(dict(DEFAULTS)) # encryption=True
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/_superusers/auth-with-password'] = \
|
||
'{"token":"T3"}'
|
||
if os.path.isfile(NS['OH_ADMIN']):
|
||
os.unlink(NS['OH_ADMIN'])
|
||
NS['_oh_ensure_admin'](8090)
|
||
chk('开启加密时建管理员要带 --encryptionEnv',
|
||
any('--encryptionEnv=' + ENC_ENV in c for c in CALLS),
|
||
str([c for c in CALLS if 'superuser' in c])[:140])
|
||
CALLS[:] = []
|
||
oh_save(dict(OH_DEFAULTS if False else DEFAULTS, encryption=False))
|
||
if os.path.isfile(NS['OH_ADMIN']):
|
||
os.unlink(NS['OH_ADMIN'])
|
||
NS['_oh_ensure_admin'](8090)
|
||
chk('关闭加密时建管理员不带 --encryptionEnv',
|
||
not any('--encryptionEnv' in c for c in CALLS))
|
||
oh_save(dict(DEFAULTS))
|
||
|
||
print('\n--- 加密开关:有数据后不许再切 ---')
|
||
# 让服务处于停止态,save 就不会走「重启并等 health」那条路
|
||
# (那段已在别处单独测过,这里只关心加密开关的准入判断)
|
||
_prev_active = SH_OUT.get('active')
|
||
SH_OUT['active'] = 'inactive'
|
||
try:
|
||
os.makedirs(NS['OH_DATA'], exist_ok=True)
|
||
with open(os.path.join(NS['OH_DATA'], 'data.db'), 'wb') as f:
|
||
f.write(b'x')
|
||
r_enc = save_op({'encryption': False})
|
||
chk('【关键】已有数据后切加密开关被拒',
|
||
r_enc['ok'] is False and r_enc.get('code') == 'ENC_LOCKED',
|
||
r_enc.get('msg_cn'))
|
||
chk('拒绝时说清了后果与出路',
|
||
'读不出来' in (r_enc.get('msg_cn') or '') and '卸载' in (r_enc.get('msg_cn') or ''))
|
||
chk('被拒时配置没有被改坏', oh_load()['encryption'] is True)
|
||
r_ok = save_op({'port': 8095})
|
||
chk('不涉及加密的改动仍然能保存', r_ok['ok'] is True, r_ok.get('msg_cn'))
|
||
os.unlink(os.path.join(NS['OH_DATA'], 'data.db'))
|
||
r_none = save_op({'encryption': False})
|
||
chk('没有数据时允许切换(首次安装前的场景)',
|
||
r_none['ok'] is True, r_none.get('msg_cn'))
|
||
finally:
|
||
oh_save(dict(DEFAULTS))
|
||
SH_OUT['active'] = _prev_active
|
||
|
||
print('\n--- 概况读取 ---')
|
||
HTTP_BODY.clear()
|
||
HTTP_BODY['http://127.0.0.1:8090/api/health'] = '{"code":200}'
|
||
r9 = probe_op({})
|
||
chk('服务不健康时明确拒绝读取',
|
||
r9['ok'] is False, r9.get('msg_cn'))
|
||
# 管理员凭据为空 → 拿不到 token → 要有明确错误而不是静默成功
|
||
if os.path.isfile(NS['OH_ADMIN']):
|
||
os.unlink(NS['OH_ADMIN'])
|
||
HTTP_BODY['http://127.0.0.1:8090/api/collections/devices/records?perPage=1'] = \
|
||
'{"items":[],"totalItems":0}'
|
||
r10 = probe_op({})
|
||
chk('拿不到凭据时返回明确错误', r10['ok'] is False and bool(r10.get('code')),
|
||
'%s / %s' % (r10.get('code'), (r10.get('msg_cn') or '')[:60]))
|
||
|
||
print('\n--- 卸载 ---')
|
||
CALLS[:] = []
|
||
r11 = uninstall_op({})
|
||
chk('卸载成功', r11['ok'] is True, r11.get('msg_cn'))
|
||
chk('卸载先停服务',
|
||
any(c[:2] == ['systemctl', 'stop'] for c in CALLS))
|
||
chk('卸载移除了单元文件', not os.path.isfile(NS['OH_UNIT']))
|
||
chk('默认保留数据目录(里面有 AP 清单)',
|
||
os.path.isdir(NS['OH_DATA']), NS['OH_DATA'])
|
||
chk('默认保留密钥文件', os.path.isfile(NS['OH_ENV']))
|
||
r12 = uninstall_op({'purge': True})
|
||
chk('purge 时清掉数据目录', r12['ok'] is True and not os.path.isdir(NS['OH_DATA']))
|
||
chk('purge 时清掉密钥文件', not os.path.isfile(NS['OH_ENV']))
|
||
|
||
print('\n--- op 分发 ---')
|
||
r13 = act({'op': 'bogus'})
|
||
chk('未知 op 返回 BAD_OP', r13['ok'] is False and r13.get('code') == 'BAD_OP')
|
||
chk('缺省 op 是 status', act({})['ok'] is True)
|
||
SH_OUT['active'] = 'inactive'
|
||
st = act({'op': 'status'})
|
||
chk('status 返回安装状态字段', 'installed' in (st.get('data') or {}))
|
||
chk('status 返回密钥字段', 'secret' in (st.get('data') or {}))
|
||
chk('status 返回控制台地址', 'console_url' in (st.get('data') or {}))
|
||
chk('status 返回说明条目', len((st.get('data') or {}).get('items') or []) >= 6)
|
||
chk('status 返回构建保护模式状态',
|
||
'build_mode' in (st.get('data') or {}))
|
||
|
||
print('\n--- 监听与地址解析 ---')
|
||
SH_OUT['ss_out'] = ('LISTEN 0 4096 0.0.0.0:8090 0.0.0.0:*\n'
|
||
'LISTEN 0 4096 [::]:8090 [::]:*\n'
|
||
'LISTEN 0 4096 0.0.0.0:631 0.0.0.0:*')
|
||
got3 = listen_now(8090)
|
||
chk('能找出 8090 的监听地址', '0.0.0.0' in got3 and '[::]' in got3, str(got3))
|
||
chk('不会把别的端口算进来', '631' not in ''.join(got3))
|
||
# ss 把「监听全部地址」打成 `*`,直接显示没人看得懂
|
||
SH_OUT['ss_out'] = 'LISTEN 0 4096 *:8090 *:*'
|
||
chk('ss 的通配 * 被还原成 0.0.0.0', listen_now(8090) == ['0.0.0.0'],
|
||
str(listen_now(8090)))
|
||
chk('端口非法时返回空列表', listen_now('abc') == [])
|
||
SH_OUT['ip_out'] = ('2: ens18 inet 192.168.7.3/24 brd\n'
|
||
'3: ens19 inet 10.0.0.5/24 brd')
|
||
chk('能取到 LAN 地址', NS['_oh_lan_ip']() == '192.168.7.3', NS['_oh_lan_ip']())
|
||
SH_OUT['ip_out'] = '1: lo inet 127.0.0.1/8 scope host'
|
||
chk('只剩回环时返回空', NS['_oh_lan_ip']() == '')
|
||
|
||
print('\n--- 后端接线 ---')
|
||
chk('ACTIONS 注册了 opensoho', "'opensoho': act_opensoho" in HELPER_SRC)
|
||
chk('drouter-web 有 /api/opensoho 路由', "'/api/opensoho'" in WEB_SRC)
|
||
chk('install 给了足够长的超时(要下载 13MB)',
|
||
re.search(r"to = 280 if op == 'install'", WEB_SRC) is not None)
|
||
chk('路由走的是 helper opensoho',
|
||
re.search(r"helper\('opensoho', b, timeout=to\)", WEB_SRC) is not None)
|
||
chk('依赖自检登记了 opensoho',
|
||
"'opensoho', 'OpenSOHO" in HELPER_SRC)
|
||
chk('依赖自检里 opensoho 不提供 apt 一键安装(它不是 apt 包)',
|
||
re.search(r"'AC/AP 管理中心:.*', '', 'ac'\)", HELPER_SRC, re.S) is not None)
|
||
chk('依赖自检新增了 ac 分组', "('ac', '无线 AC / AP')" in HELPER_SRC)
|
||
|
||
print('\n--- 前端接线 ---')
|
||
chk('菜单里有 AC/AP 管理中心', "k: 'opensoho'" in APP_SRC)
|
||
chk('VIEWS 映射了 opensoho', 'opensoho: viewOpensoho,' in APP_SRC)
|
||
chk('有 viewOpensoho 函数', 'async function viewOpensoho()' in APP_SRC)
|
||
chk('调的是 /api/opensoho', "'/api/opensoho'" in APP_SRC)
|
||
for fn in ('ohLoad', 'ohRender', 'ohSave', 'ohInstall', 'ohUninstall',
|
||
'ohRegenSecret', 'ohProbe', 'ohRenderStats'):
|
||
chk('前端有 %s' % fn, ('function %s(' % fn) in APP_SRC)
|
||
for eid in ('oh-bind', 'oh-port', 'oh-newdev', 'oh-enc', 'oh-auto',
|
||
'oh-secret-val', 'oh-local', 'oh-purge'):
|
||
chk('页面有元素 %s' % eid, ('id="%s"' % eid) in APP_SRC)
|
||
chk('端口输入有 min/max 限制',
|
||
'min="1024" max="65535"' in APP_SRC)
|
||
chk('重新生成密钥前有二次确认',
|
||
'重新注册一次' in APP_SRC and 'window.confirm' in APP_SRC)
|
||
chk('卸载区分是否清除数据', 'purge' in APP_SRC)
|
||
chk('把控制台地址做成可点击链接', 'target="_blank"' in APP_SRC)
|
||
|
||
print('\n--- 安全红线:不许碰的东西 ---')
|
||
seg = HELPER_SRC[HELPER_SRC.index('OH_DIR = '):HELPER_SRC.index('def read_share')]
|
||
chk('本模块没有动 5900(VNC)', '5900' not in seg)
|
||
chk('本模块没有启停 DHCP', 'dnsmasq' not in seg and 'kea' not in seg)
|
||
chk('本模块没有改防火墙', 'nft' not in seg)
|
||
chk('本模块没有动默认路由', 'ip route' not in seg)
|
||
chk('本模块没有动 radvd', 'radvd' not in seg)
|
||
|
||
sys.modules['urllib'] = _real_urllib
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
try:
|
||
os.unlink('/tmp/pwned-by-opensoho')
|
||
except Exception:
|
||
pass
|
||
print('\n================ 通过 %d / 失败 %d ================' % (PASS, FAIL))
|
||
return 1 if FAIL else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|