小鸿 AI 的 OpenHarmony WS63 固件能够连上服务端并不等于后端已经形成可重复、可回滚、可审计的部署闭环。真实工程同时存在 Python 源码默认端口、systemd 启动参数、旧 Nginx 片段、部署脚本和环境变量五个配置面。只看其中一个文件很容易写出“服务运行在 18088”或“Nginx 已代理/hajimi/”这样的错误结论。本文依据当前本地后端目录中的server.py、deploy_remote.py、xiaohong-yutian.service与nginx-xiaohong.conf拆解一次部署实际做了什么、失败后能回滚什么以及哪些安全边界仍未补齐。最重要的现状先说在前面Python 未传参时默认监听127.0.0.1:18088当前 systemd unit 显式改为0.0.0.0:8002仓库里的旧 Nginx 片段仍把/xiaohong/转到127.0.0.1:18088而服务端当前 canonical prefix 是/hajimi。旧片段不能直接当作当前可部署配置。本文不连接生产服务器、不读取本地凭据文件也不执行上传、重启、Nginx reload 或公网探测。所有真实主机、密码、API key、WebSocket token、音色标识和公网地址都不进入正文。下面所说的“通过”仅指本地源码 AST 解析与确定性 smoke不代表线上 systemd、Nginx 或 OpenHarmony 实机已经在本轮验证。先建立配置真相表不能用一个端口概括整个项目server.py的命令行入口把 host 默认设为环境变量HOST没有配置时为127.0.0.1port 默认读取PORT没有配置时为18088。这只描述“直接运行 Python 且不传参数”的行为。async def main() - None: parser argparse.ArgumentParser() parser.add_argument(--host, defaultenv(HOST, 127.0.0.1)) parser.add_argument(--port, typeint, defaultint(env(PORT, 18088))) args parser.parse_args() server await asyncio.start_server(handle_client, args.host, args.port) sockets , .join(str(sock.getsockname()) for sock in server.sockets or []) print(fHajimi backend listening on {sockets}, flushTrue) async with server: await server.serve_forever()systemd unit 没有采用这组默认值而是在ExecStart中显式传入--host 0.0.0.0 --port 8002。命令行参数优先于HOST与PORT所以由该 unit 启动时进程实际监听 8002而且监听所有 IPv4 接口。除非主机防火墙或安全组限制外部客户端可能绕过 Nginx 直接访问 8002。旧 Nginx 片段是第三套口径upstream 为127.0.0.1:18088路径为/xiaohong/。服务端目前同时接受/hajimi与兼容别名/xiaohong所以旧路径在 Python 路由层仍可能响应但是 upstream 端口与 systemd 不一致canonical prefix 也没有进入该片段。这里不是“二选一都可以”而是文件之间出现了版本漂移。systemd unit 已能守护进程但还不是完整的服务加固当前 unit 很短真实内容如下[Unit] DescriptionHajimi Voice Companion backend Afternetwork.target [Service] Typesimple WorkingDirectory/opt/yutian-backend EnvironmentFile/opt/yutian-backend/.env ExecStart/opt/yutian-backend/.venv/bin/python /opt/yutian-backend/server.py --host 0.0.0.0 --port 8002 Restartalways RestartSec3 Userubuntu [Install] WantedBymulti-user.target它明确了工作目录、环境文件、虚拟环境解释器、服务用户和自动重启策略。进程异常退出后systemd 会等待 3 秒重启服务不是 root 运行这是正确的基础边界。部署脚本把 unit 以 root:root、0644 安装到系统目录再执行daemon-reload。但当前 unit 还没有NoNewPrivileges、PrivateTmp、ProtectSystem、ProtectHome、能力集限制、资源上限、启动超时和显式日志策略。是否能直接加入这些项需要先列清服务必须读取的模型目录、字体、.env以及是否需要写缓存或临时文件不能复制一份“加固模板”后让 TTS 模型加载失败。另一个更直接的选择是让 Python 只绑定127.0.0.1:8002由 Nginx 作为唯一入口是否这样改取决于设备是否必须直连 8002。Afternetwork.target只表达排序不保证公网、DNS、代理隧道或 Fish Audio 已经可用。由于 TTS 有离线回退服务可以先启动但 health 和后续模型 smoke 仍应区分“进程已启动”“本地模型就绪”“云端路径可用”三个状态。部署脚本先在临时目录做 Python 与协议预检deploy_remote.py通过 SSH 建立连接以时间戳创建远端临时目录然后用 SFTP 上传 Python 服务、字体、测试脚本和 systemd unit。Nginx 配置不在上传列表中这一点非常关键运行当前部署脚本不会安装或修复 Nginx也不会执行nginx -t或 reload。正式覆盖前脚本在远端临时目录运行 Python 语法编译、协议冒烟和回答多样性冒烟。真实命令拼接如下run( ssh, fcd {shlex.quote(remote_temp)} python3 -m py_compile server.py asr_service.py tts_service.py protocol_smoke.py response_variety_smoke.py tts_model_smoke.py python3 protocol_smoke.py python3 response_variety_smoke.py, )这一步可以提前发现语法错误、缺失上传文件、JSON/Opus 协议编排回归和回答规则回归却没有使用正式虚拟环境也不加载真实 ASR/TTS 模型。临时目录中的python3若与/opt/yutian-backend/.venv/bin/python版本或依赖不同预检通过仍可能在正式环境失败。反过来如果系统 Python 缺某个只存在于 venv 的依赖也可能在尚未覆盖线上文件前安全失败。临时目录使用带时间戳的固定前缀清理时经过shlex.quote()比对未解析变量执行广泛删除安全。但清理位于finally只能说明脚本尽力删除本次临时上传SSH 会话中断、宿主进程被强杀或权限异常时仍需要定期清理遗留的/tmp部署目录。备份发生在覆盖前但备份集还没有形成可验证制品通过临时预检后脚本在应用目录下创建带时间戳的 backups 子目录保存旧的server.py、asr_service.py、tts_service.py、font.bin、.env和 systemd unit。不存在的旧文件会被跳过。这个顺序是合理的先验证候选再保存现状最后才覆盖。然而当前备份没有 manifest没有为备份文件记录 SHA-256也没有记录文件所有者、mode、当前 systemd 状态或 Nginx 配置。.env会进入备份意味着备份目录本身含密钥脚本只对正式.env执行chmod 600没有显式设置 backup 目录的保留期限、访问权限与销毁策略。长期累积的历史.env往往比当前.env更容易被忽略。更稳妥的备份制品应至少包含时间戳、部署来源版本、每个文件的长度与哈希、unit 内容、环境变量键名清单但不输出值、备份目录权限以及回滚后的再验证结果。若将备份复制到其他主机还需加密并限制解密权限。本文只提出边界没有读取或迁移任何真实备份。环境变量与凭据处理已有 chmod但仍有明文和主机校验风险当前脚本从工作站上的固定凭据文件读取 hostname、username、password 和 port通过 password authentication 登录并使用AutoAddPolicy接受未知 SSH host key。文章不复述该文件位置和内容。自动接受 host key 对个人临时环境方便但会削弱首次连接时的主机身份校验生产部署更合适的做法是预置并校验 known_hosts使用专用部署密钥或短期凭据并限制该账号的 sudo 能力。服务所需的 API key 没有写进 systemd unit而是从部署进程环境读取后写入远端.env。正式.env被调整为服务用户所有、权限 600这比把密钥提交进 Git 或固件安全。但upsert_env_values()仍通过远端 shell 命令执行sed和printf敏感值会进入构造出的命令字符串若异常日志、调试器或命令审计完整记录参数就可能泄露。更安全的方式是上传权限受控的临时环境文件校验后原子替换或接入系统 secret 管理。部署脚本中还存在具体公网入口、模型名、音色 reference 和代理设置的固定值。它们并非都等同于密码但属于部署环境细节不应复制到公开文章或固件。代码层应逐步把环境差异移到独立、权限受控且可审计的配置清单中公开示例只保留变量名和占位符。覆盖与重启之间不是一笔完整事务失败窗口要说清楚备份完成后脚本先更新.env再用install覆盖三个 Python 文件、字体和 systemd unit执行daemon-reload然后继续补齐字体、TTS 与 ASR 配置。真正的try/except回滚保护从systemctl restart才开始。这意味着如果更新.env、执行install、daemon-reload或补环境变量时抛出异常控制流会直接进入外层finally清理临时目录而不会执行代码中的自动回滚块。只有 restart、health 或模型 smoke 阶段失败才会复制备份并重启旧服务。因此当前脚本具有“后半段自动回滚”不是从备份开始到验证结束的全事务部署。另一个边界是新文件。如果部署前某个目标不存在备份目录中也没有它回滚命令只在备份文件存在时复制回来不会删除本次新建的目标。对于第一次部署这可能留下部分新文件和.env。要实现严格回滚需要在 manifest 中记录每个目标部署前是absent还是present回滚时分别删除或恢复。部署后的sha256sum目前只打印远端server.py、ASR、TTS 和字体哈希没有把它们与本地候选的预期哈希做机器比较也没有覆盖 unit 与.env。打印哈希便于人工留档但不能自动阻止“上传了别的文件”或“安装后被再次修改”。当前健康检查验证了模型状态却绕过了 Nginx 和 canonical 路径服务重启后脚本固定等待 2 秒再请求127.0.0.1:8002上的 legacy/xiaohong/health。它要求顶层ok为真、ASR 与 TTS 的model_ready为真并要求当前 TTS provider 为 Fish随后才在正式 venv 中运行 TTS 模型 smoke 和带 ASR 的模型 smoke。_, health_text, _ run( ssh, curl -fsS --max-time 5 http://127.0.0.1:8002/xiaohong/health, ) health json.loads(health_text) if not health.get(ok): raise RuntimeError(funexpected health response: {health_text}) if not health.get(asr, {}).get(model_ready): raise RuntimeError(fASR model is not ready: {health_text}) if not health.get(tts, {}).get(model_ready): raise RuntimeError(fTTS model is not ready: {health_text}) if health.get(tts, {}).get(provider) ! fish: raise RuntimeError(fFish TTS is not active: {health_text})这组 gate 比只检查进程 active 更有价值但仍有三个盲区。第一它走本机直连后端没有经过 Nginx所以无法发现 upstream 端口错误、WebSocket Upgrade 丢失、TLS 证书问题或公网路径错误。第二它使用兼容前缀不能证明 canonical/hajimi/health已被入口层发布。第三health 的model_ready主要说明配置和模型文件存在真实云端请求、真实设备握手、Opus 往返和 CI1302 播放仍需额外验证。固定等待 2 秒也可能对首次加载模型的慢机器过短。更稳定的部署 gate 是有限次数、带退避的 health 轮询并在超时后保存systemctl status与最近日志不能无限重试因为配置错误不会靠等待自动恢复。自动回滚会恢复核心文件但必须再次证明旧版本可用restart、health 或模型 smoke 抛出异常时脚本从本次 backup 目录复制旧代码、字体、.env与 unit执行daemon-reload并重启服务然后把原异常继续抛出。这个动作保留了失败信号没有用“回滚命令执行过”冒充部署成功。但回滚命令使用checkFalse即使某个复制或 restart 失败脚本也不会用第二个异常覆盖最初错误。这样有利于保留原始失败原因却也意味着自动化调用方不能只看“触发了 rollback”就认为旧服务恢复。正确做法是在回滚后增加独立验证检查 service active、请求旧版本 health、读取版本或哈希并把“部署失败且回滚成功”与“部署失败且回滚也失败”分成不同退出状态。回滚还没有覆盖 Nginx因为部署流程本来就不修改 Nginx。如果未来把 Nginx 更新纳入脚本必须在 reload 前备份实际启用的站点文件执行nginx -t用原子 symlink 或 install 切换再通过 Nginx 入口验证 HTTP 与 WebSocket不能只把一个仓库片段复制到某个目录后宣称生效。仓库旧 Nginx 片段真实存在但不能直接上线当前旧片段如下它具备 WebSocket 所需的 HTTP/1.1、Upgrade、Connection、Host 和长读超时但 upstream 仍为 18088路径仍为兼容命名并且没有展示 TLS、访问控制、请求大小限制、日志脱敏或限流。location /xiaohong/ws { proxy_pass http://127.0.0.1:18088/xiaohong/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } location /xiaohong/ { proxy_pass http://127.0.0.1:18088/xiaohong/; proxy_set_header Host $host; }如果以当前 systemd 的 8002 和 canonical/hajimi为准迁移方向可以是让 Nginx 把/hajimi/转到本机 8002并单独保留/xiaohong/作为有期限的兼容入口。下面是“待评审配置示意”不是当前仓库代码也没有在服务器应用# 配置示意未部署、未执行 nginx -t location /hajimi/ws { proxy_pass http://127.0.0.1:8002/hajimi/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } location /hajimi/ { proxy_pass http://127.0.0.1:8002/hajimi/; proxy_set_header Host $host; }真正切换时先在未对外的候选配置上跑nginx -t再 reload而不是 restart随后分别验证/hajimi/health、字体 Range、OTA 返回 URL、WebSocket 101 与一条完整设备会话。兼容路径是否继续保留要看旧固件流量和升级覆盖率不应凭文章直接删除。当前最重要的安全边界是鉴权、TLS、Host 与直接端口暴露服务端配置了 WebSocket token并在 OTA 响应中下发 token但当前handle_http()对 WebSocket Upgrade 只检查 path 和Upgradeheaderhandle_websocket()直接计算 accept 并返回 101没有读取或校验 Authorization。换句话说“存在 token 配置”不等于“服务端已经鉴权”。正式入口需要在应用层或可信反向代理层验证 token处理轮换、过期、重放和失败限速。当前示例仍是 HTTP/WS不是 HTTPS/WSS。语音、设备标识、回答文本和 token 都不应长期明文跨公网传输。TLS 终止可以放在 Nginx但设备证书信任、时间校验、续期与弱网重连必须一起测试。若 8002 继续绑定0.0.0.0还要用主机防火墙或安全组禁止公网绕过 TLS 入口。request_public_base_urls()会在 Host header 通过字符正则时用它拼出 OTA 中的 HTTP/WS 地址。Nginx 当前又把客户端 Host 传给后端。正则能排除部分非法字符却不是主机 allowlist生产环境应固定公开基址或只接受预期域名避免设备拿到由非预期 Host 派生的连接地址。/chat、OTA、字体与 health 当前没有统一的认证、限流和访问范围。health 还会返回模型、provider、voice 列表和公开 WS URL适合内网探针但未必适合完整暴露给公网。Nginx 可以把浅层 liveness 与详细 diagnostics 分开详细信息只允许受控来源访问。一条可落地的发布顺序应把部署、验证和回滚做成同一闭环基于当前工程下一次真正部署可按以下顺序执行。先冻结候选文件并记录本地 SHA-256不读取无关凭据通过已知 host key 建立 SSH上传到唯一临时目录在目标 venv 中执行 AST/py_compile、协议 smoke 和依赖检查创建带 manifest 的权限受控备份原子安装代码与环境文件安装并检查 unit若包含 Nginx 变更则先nginx -t再 reload。之后用退避轮询检查 systemd 和本机/hajimi/health再从 Nginx 入口验证 canonical health、字体 Range、OTA 与 WebSocket Upgrade。模型 smoke 通过后最后用一台 OpenHarmony WS63 设备完成 hello、Opus 上行、ASR、回答、TTS 下行和 CI1302 播放。所有 gate 通过才标记发布成功。任一 gate 失败时按 manifest 恢复或删除目标文件恢复 unit 与 Nginx执行 daemon-reload / nginx reload / service restart再跑旧版本的相同 health 和入口验证。只有旧版本验证通过状态才是“发布失败、回滚成功”否则必须进入人工处置并保留现场。备份在观察窗口结束后按策略销毁尤其不能无限保留含密钥的旧.env。本轮验证结果与不能据此宣称的结论本轮对server.py、tts_service.py和deploy_remote.py做了 Python AST 解析三个文件通过重新运行protocol_smoke.py与response_variety_smoke.py确定性协议和回答规则通过。逐文件长度与 SHA-256 已写入源码快照后端目录不是 Git 仓库因此快照使用文件哈希而不是虚构提交号。本轮没有调用deploy_remote.py没有读取其凭据来源没有 SSH 登录没有覆盖远端文件没有执行systemctl、nginx -t或 reload也没有访问公网 health。systemd 8002 是 unit 文件的启动事实旧 Nginx 18088 是仓库片段事实两者不一致是静态证据本文没有把任何一方冒充线上现状。因此当前可以确认的是部署脚本包含上传前预检、备份、环境更新、安装、重启、详细 health、模型 smoke 和后半段异常回滚同时也确认自动回滚窗口不覆盖前半段安装错误、Nginx 未纳入部署、WebSocket token 尚未在服务端校验、TLS 与端口隔离未闭环。把这些边界写清才是下一轮安全修复和真实上线验收的起点。