#!/usr/bin/env python3 # -*- coding: utf-8 -*- """真机验收:打印服务 CUPS / USB RAW 直通(#7)。 必须 scp 到目标机再跑(BASE 是 127.0.0.1:8443),且必须用 root(要写 /etc/cups/cupsd.conf 和启停服务)。用法: python3 /tmp/live-print.py 会真的走一遍「保存并生效」,所以开头先把现场拍下来,结尾逐项还原: * cupsd.conf —— 开头按字节存一份,结尾原样写回; * cups / cups.socket / cups-browsed 的 active + enabled —— 按原样恢复; * /etc/drouter/print.conf —— 原本没有就删掉; * 本次产生的备份、RAW 直通的 unit 与 wrapper —— 全部清理。 安全边界:不碰 5900(VNC)、不动网卡与路由、不启停任何网络/DHCP 服务。 """ import json import os import re import ssl import sys import time import subprocess import urllib.request BASE = 'https://127.0.0.1:8443' CTX = ssl._create_unverified_context() TOK = None PASS = FAIL = 0 PRINT_CONF = '/etc/drouter/print.conf' CUPSD = '/etc/cups/cupsd.conf' BAK_DIR = '/var/lib/drouter/print-backup' RAW_SERVICE = 'drouter-printer-raw.service' RAW_UNIT = '/etc/systemd/system/' + RAW_SERVICE RAW_WRAPPER = '/opt/drouter/scripts/printer-raw.sh' def call(path, payload=None, timeout=60): data = json.dumps(payload).encode('utf-8') if payload is not None else None req = urllib.request.Request(BASE + path, data=data, method='POST' if data is not None else 'GET') req.add_header('Content-Type', 'application/json') if TOK: req.add_header('X-Token', TOK) try: with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r: return json.loads(r.read().decode('utf-8')) except Exception as e: return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e} 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)) def sh(argv, timeout=60): # 超时绝不能把还原流程打断:systemctl 偶发卡住是很常见的, # 一次卡住就抛异常退出 = 把机器留在半成品状态。 try: p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) return p.returncode, p.stdout, p.stderr except subprocess.TimeoutExpired: return 124, '', '命令超时(%ds):%s' % (timeout, ' '.join(argv)) except Exception as e: return 1, '', '执行异常:%s' % e def svc(name): rc1, a, _ = sh(['systemctl', 'is-active', name]) rc2, e, _ = sh(['systemctl', 'is-enabled', name]) return (a.strip() if rc1 == 0 else 'inactive', e.strip() if rc2 == 0 else 'disabled') def listen_of(port): rc, out, _ = sh(['ss', '-lnt']) if rc != 0: return [] hits = [] for ln in out.splitlines(): if (':%d' % port) not in ln: continue m = re.search(r'(\S+):%d\b' % port, ln) if m and m.group(1) not in hits: hits.append(m.group(1)) return hits TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token') if not TOK: print('登录失败,终止') sys.exit(1) # ------------------------------------------------------------ 0. 拍下现场 had_conf = os.path.isfile(PRINT_CONF) if had_conf: os.rename(PRINT_CONF, PRINT_CONF + '.livetest-bak') print('开关状态文件已临时挪开(验收完还原)') ORIG_CUPSD = '' if os.path.isfile(CUPSD): ORIG_CUPSD = open(CUPSD, encoding='utf-8', errors='replace').read() ORIG_SVC = {n: svc(n) for n in ('cups.service', 'cups.socket', 'cups-browsed.service')} ORIG_RAW_UNIT = os.path.isfile(RAW_UNIT) ORIG_RAW_WRAPPER = os.path.isfile(RAW_WRAPPER) BAK_BEFORE = set(os.listdir(BAK_DIR)) if os.path.isdir(BAK_DIR) else set() # 现场先落盘一份:万一脚本中途崩了,也能照这份 JSON 手工还原 with open('/tmp/print-livetest-orig.json', 'w', encoding='utf-8') as f: json.dump({'cupsd': ORIG_CUPSD, 'svc': ORIG_SVC, 'had_conf': had_conf, 'raw_unit': ORIG_RAW_UNIT, 'raw_wrapper': ORIG_RAW_WRAPPER}, f, ensure_ascii=False) print('保存前:cups=%s cups.socket=%s cups-browsed=%s 631监听=%s' % (ORIG_SVC['cups.service'], ORIG_SVC['cups.socket'], ORIG_SVC['cups-browsed.service'], listen_of(631) or '无')) # ------------------------------------------------------------ 1. 状态读取 print('\n--- 状态读取 ---') r = call('/api/print', {'op': 'status'}, timeout=60) chk('status 接口可用', r.get('ok') is True, '→ %s' % r.get('msg_cn')) d = r.get('data') or {} c0 = (d.get('cfg') or {}) print(' CUPS 已装:%s 版本:%s' % (d.get('installed'), d.get('cups_version'))) print(' CUPS:%s / cups-browsed:%s / RAW:%s' % (d.get('cups'), d.get('browsed'), d.get('raw'))) print(' 631 实际监听:%s' % (d.get('listen') or '无')) print(' 队列:%s' % (', '.join(q['name'] for q in (d.get('queues') or [])) or '无')) print(' USB 打印机:%s 设备节点:%s' % (d.get('usb_printers') or '无', d.get('usb_nodes') or '无')) chk('返回 CUPS 是否安装', 'installed' in d) chk('返回三个服务状态', all(k in d for k in ('cups', 'browsed', 'raw'))) chk('返回 631 实际监听', 'listen' in d) chk('返回队列列表', 'queues' in d) chk('返回 USB 打印机与设备节点', 'usb_printers' in d and 'usb_nodes' in d) chk('返回驱动清单', len(d.get('drivers') or []) == 2) chk('返回六个开关的说明', len(d.get('items') or []) == 6) chk('返回 cupsd.conf 路径', d.get('cupsd_file') == CUPSD) chk('返回 socat 是否可用', 'socat' in d) chk('返回 avahi 状态', 'avahi_active' in d) chk('出厂默认 mode=cups', c0.get('mode') == 'cups') chk('出厂默认允许局域网访问', (c0.get('cups') or {}).get('listen') == 'lan') chk('出厂默认共享打印机', (c0.get('cups') or {}).get('share') is True) chk('出厂默认禁止远程管理', (c0.get('cups') or {}).get('remote_admin') is False) chk('出厂默认开 cups-browsed', (c0.get('cups') or {}).get('browsed') is True) chk('出厂默认 RAW 端口 9100', (c0.get('raw') or {}).get('port') == 9100) if d.get('installed'): chk('队列能被解析出来(不再靠本地化的 lpstat)', isinstance(d.get('queues'), list)) for q in (d.get('queues') or []): print(' %-40s shared=%s accepting=%s uri=%s' % (q.get('name'), q.get('shared'), q.get('accepting'), q.get('uri'))) # ------------------------------------------------------------ 2. 队列参数校验 print('\n--- 队列参数校验(这些都不该碰到 CUPS)---') bad = call('/api/print', {'op': 'queue_add', 'name': '-bad name', 'uri': 'ipp://x/'}, timeout=40) chk('非法队列名被拒', bad.get('ok') is False, '→ %s' % bad.get('msg_cn')) bad2 = call('/api/print', {'op': 'queue_add', 'name': 'ok-name', 'uri': 'ftp://x/'}, timeout=40) chk('不支持的 URI 方案被拒', bad2.get('ok') is False, '→ %s' % bad2.get('msg_cn')) bad3 = call('/api/print', {'op': 'queue_add', 'name': 'ok-name', 'uri': 'ipp://x; rm -rf /'}, timeout=40) chk('带 shell 元字符的 URI 被拒', bad3.get('ok') is False, '→ %s' % bad3.get('msg_cn')) bad4 = call('/api/print', {'op': 'job_cancel', 'id': '../../etc/passwd'}, timeout=40) chk('非法任务号被拒', bad4.get('ok') is False, '→ %s' % bad4.get('msg_cn')) bad5 = call('/api/print', {'op': 'queue_del', 'name': 'ok-name', 'confirm': 'wrong'}, timeout=40) chk('删除队列要二次确认', bad5.get('ok') is False, '→ %s' % bad5.get('msg_cn')) bad6 = call('/api/print', {'op': 'bogus_op'}, timeout=30) chk('未知 op 被拒', bad6.get('ok') is False, '→ %s' % bad6.get('msg_cn')) print('\n--- 打印任务查询 ---') j = call('/api/print', {'op': 'jobs'}, timeout=40) chk('jobs 接口可用', j.get('ok') is True, '→ %s' % j.get('msg_cn')) chk('返回任务数与列表', 'count' in (j.get('data') or {}) and 'jobs' in (j.get('data') or {})) try: # ------------------------------------------------------------ 3. 关闭模式 print('\n--- 关闭模式:所有打印服务都停掉 ---') s = call('/api/print', {'op': 'save', 'mode': 'off'}, timeout=150) chk('off 保存成功', s.get('ok') is True, '→ %s' % (s.get('msg_cn') or '')[:120]) time.sleep(2) chk('CUPS 已停', svc('cups.service')[0] != 'active', '→ %s' % (svc('cups.service'),)) chk('631 不再监听', listen_of(631) == [], '→ %s' % (listen_of(631),)) chk('9100 没有监听', listen_of(9100) == []) # ------------------------------------------------------------ 4. CUPS 模式 print('\n--- CUPS 模式:真正生效到 631 ---') s = call('/api/print', {'op': 'save', 'mode': 'cups', 'cups': {'listen': 'lan', 'share': True, 'web_iface': True, 'remote_admin': False, 'browsed': True}}, timeout=150) chk('cups 保存成功', s.get('ok') is True, '→ %s' % (s.get('msg_cn') or '')[:160]) time.sleep(3) now = listen_of(631) print(' 631 监听:%s' % (now or '无')) chk('631 已经不是只听 127.0.0.1', bool(now) and '127.0.0.1' not in now, '→ %s' % now) chk('CUPS 在运行', svc('cups.service')[0] == 'active') txt = open(CUPSD, encoding='utf-8', errors='replace').read() root_blk = '' if '' in txt: root_blk = txt.split('', 1)[1].split('', 1)[0] chk('根 Location 段收窄成 @LOCAL', 'Allow @LOCAL' in root_blk, repr(root_blk[:120])) chk('没有残留 Allow all(cupsctl 会写成放行一切)', 'Allow all' not in root_blk) rc, _o, e = sh(['cupsd', '-t'], timeout=20) chk('cupsd -t 配置校验通过', rc == 0, (e or '')[:120]) st = call('/api/print', {'op': 'status'}, timeout=60) chk('状态里读到新监听', (st.get('data') or {}).get('listen') != '') # ------------------------------------------------------------ 6. 队列增删(真做再删干净) print('\n--- 队列增删(做完立刻删掉)---') TESTQ = 'drouter-livetest' qa = call('/api/print', {'op': 'queue_add', 'name': TESTQ, 'uri': 'socket://127.0.0.1:9/', 'driver': 'raw', 'info': '验收临时队列', 'share': False}, timeout=100) chk('添加队列接口可用', qa.get('ok') is True, '→ %s' % (qa.get('msg_cn') or '')[:140]) st2 = call('/api/print', {'op': 'status'}, timeout=60) names = [q['name'] for q in ((st2.get('data') or {}).get('queues') or [])] chk('新队列出现在列表里', TESTQ in names, '→ %s' % names) if TESTQ in names: qs = call('/api/print', {'op': 'queue_set', 'name': TESTQ, 'share': True}, timeout=60) chk('设置共享成功', qs.get('ok') is True, '→ %s' % (qs.get('msg_cn') or '')[:120]) qd = call('/api/print', {'op': 'queue_del', 'name': TESTQ, 'confirm': TESTQ}, timeout=60) chk('删除队列成功', qd.get('ok') is True, '→ %s' % (qd.get('msg_cn') or '')[:120]) st3 = call('/api/print', {'op': 'status'}, timeout=60) chk('队列确实被删掉了', TESTQ not in [q['name'] for q in ((st3.get('data') or {}).get('queues') or [])]) # ------------------------------------------------------------ 5. RAW 模式(这台机器没插打印机) print('\n--- RAW 模式:互斥 + 没设备时如实报错 ---') s = call('/api/print', {'op': 'save', 'mode': 'raw', 'raw': {'device': '/dev/usb/lp0', 'port': 9100, 'bind': '0.0.0.0'}}, timeout=150) print(' 返回:%s' % (s.get('msg_cn') or '')[:200]) time.sleep(2) chk('切到 RAW 后 CUPS 被停掉(互斥生效)', svc('cups.service')[0] != 'active', '→ %s' % (svc('cups.service'),)) chk('CUPS 也被 disable 掉', svc('cups.service')[1] != 'enabled') chk('631 不再被 CUPS 占用', listen_of(631) == []) chk('RAW 的 wrapper 已生成', os.path.isfile(RAW_WRAPPER)) if os.path.isfile(RAW_WRAPPER): w = open(RAW_WRAPPER, encoding='utf-8').read() print(' wrapper:%s' % w.strip().splitlines()[-1]) chk('wrapper 用 GOPEN 开字符设备', 'GOPEN:/dev/usb/lp0' in w) chk('wrapper 监听 9100 且带 fork', 'TCP-LISTEN:9100' in w and 'fork' in w) chk('RAW 的 systemd 单元已生成', os.path.isfile(RAW_UNIT)) if os.path.isfile(RAW_UNIT): u = open(RAW_UNIT, encoding='utf-8').read() chk('单元里写全了 PATH', 'Environment=PATH=' in u and '/usr/sbin' in u) rawst = call('/api/print', {'op': 'status'}, timeout=60) rd = rawst.get('data') or {} print(' RAW 服务:%s USB 打印机:%s 设备节点:%s' % (rd.get('raw'), rd.get('usb_printers'), rd.get('usb_nodes'))) if not rd.get('usb_nodes'): chk('没有 USB 打印机时给出明确提示(不是静默失败)', '找不到' in (s.get('msg_cn') or '') or any('找不到' in (x or '') for x in ((s.get('data') or {}).get('errors') or [])), '→ %s' % ((s.get('data') or {}).get('errors') or [])) # ------------------------------------------------------------ 7. 备份与还原 print('\n--- cupsd.conf 备份与还原 ---') st4 = call('/api/print', {'op': 'status'}, timeout=60) bks = (st4.get('data') or {}).get('backups') or [] chk('产生了备份', len(bks) >= 1, '→ %d 份' % len(bks)) bad7 = call('/api/print', {'op': 'restore', 'file': '../../etc/shadow'}, timeout=40) chk('还原路径穿越被拒', bad7.get('ok') is False, '→ %s' % bad7.get('msg_cn')) if bks: rr = call('/api/print', {'op': 'restore', 'file': bks[0]['file']}, timeout=100) chk('还原接口可用', rr.get('ok') is True, '→ %s' % (rr.get('msg_cn') or '')[:120]) except Exception as e: chk("验收流程自身未崩溃", False, "→ %s" % e) print(" (异常已吞掉,继续走还原流程)") # ------------------------------------------------------------ 8. 还原现场 print('\n--- 还原现场 ---') call('/api/print', {'op': 'save', 'mode': 'cups', 'cups': {'listen': 'lan', 'share': True, 'web_iface': True, 'remote_admin': False, 'browsed': True}}, timeout=150) sh(['systemctl', 'stop', RAW_SERVICE], timeout=60) sh(['systemctl', 'disable', RAW_SERVICE], timeout=20) # 先把打印服务全部停干净再改配置:一边 restart 一边写 cupsd.conf 会让 # systemd 的 job 排队卡住(实测 systemctl restart cups.socket 卡过 60 秒)。 for name in ('cups-browsed.service', 'cups.service', 'cups.socket'): sh(['systemctl', 'stop', name], timeout=60) sh(['systemctl', 'reset-failed'], timeout=30) # cupsd.conf 按字节写回 with open(CUPSD, 'w', encoding='utf-8') as f: f.write(ORIG_CUPSD) chk('cupsd.conf 已按原样写回', open(CUPSD, encoding='utf-8', errors='replace').read() == ORIG_CUPSD) # 服务状态按原样恢复 for name, (act, en) in ORIG_SVC.items(): if en == 'enabled': sh(['systemctl', 'enable', name], timeout=30) else: sh(['systemctl', 'disable', name], timeout=30) for name, (act, en) in ORIG_SVC.items(): if act == 'active': sh(['systemctl', 'start', name], timeout=90) else: sh(['systemctl', 'stop', name], timeout=60) time.sleep(4) for name, (act, en) in ORIG_SVC.items(): cur = svc(name) chk('%s 恢复如初(原 %s/%s)' % (name, act, en), cur == (act, en), '→ %s' % (cur,)) print(' 631 监听:%s(原:%s)' % (listen_of(631) or '无', ['127.0.0.1'] if 'Listen' in ORIG_CUPSD else '?')) chk('9100 未被占用', listen_of(9100) == []) # 清理本次产生的东西 if not ORIG_RAW_UNIT and os.path.isfile(RAW_UNIT): os.remove(RAW_UNIT) if not ORIG_RAW_WRAPPER and os.path.isfile(RAW_WRAPPER): os.remove(RAW_WRAPPER) sh(['systemctl', 'daemon-reload'], timeout=30) if os.path.isdir(BAK_DIR): new = set(os.listdir(BAK_DIR)) - BAK_BEFORE for f in new: try: os.remove(os.path.join(BAK_DIR, f)) except Exception: pass print(' 本次产生的 %d 份备份已清理' % len(new)) if had_conf: os.rename(PRINT_CONF + '.livetest-bak', PRINT_CONF) print(' 开关状态文件已还原') else: try: os.remove(PRINT_CONF) print(' 本次生成的开关状态文件已清理') except Exception: pass # ------------------------------------------------------------ 9. 安全红线复核 print('\n--- 安全红线复核 ---') rc, vnc, _ = sh(['ss', '-lnt']) chk('VNC 5900 仍在监听(绝不能碰)', any(':5900' in ln for ln in vnc.splitlines())) rc, ipa, _ = sh(['ip', '-o', '-4', 'addr', 'show', 'ens18']) chk('ens18 仍是 192.168.7.3', '192.168.7.3' in ipa, ipa.strip()[:80]) rc, rt, _ = sh(['ip', 'route', 'show', 'default']) chk('默认网关仍是 192.168.7.2(没有接管路由)', '192.168.7.2' in rt, rt.strip()[:80]) for s_ in ('dnsmasq', 'kea-dhcp4-server'): chk('%s 没有被启动' % s_, svc(s_)[0] != 'active') chk('drouter-web 仍在运行', svc('drouter-web')[0] == 'active') chk('救援通道未被打开', svc('drouter-rescue')[0] != 'active') print('\n' + '=' * 60) print('通过 %d 项,失败 %d 项' % (PASS, FAIL)) sys.exit(1 if FAIL else 0)