小鸿 AI 的 OpenHarmony WS63 固件能够连上服务端,并不等于后端已经形成可重复、可回滚、可审计的部署闭环。真实工程同时存在 Python 源码默认端口、systemd 启动参数、旧 Nginx 片段、部署脚本和环境变量五个配置面。只看其中一个文件,很容易写出“服务运行在 18088”或“Nginx 已代理 /hajimi/”这样的错误结论。

本文依据当前本地后端目录中的 server.pydeploy_remote.pyxiaohong-yutian.servicenginx-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.1;port 默认读取 PORT,没有配置时为 18088。这只描述“直接运行 Python 且不传参数”的行为。

async def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default=env("HOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(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(f"Hajimi backend listening on {sockets}", flush=True)
    async with server:
        await server.serve_forever()

systemd unit 没有采用这组默认值,而是在 ExecStart 中显式传入 --host 0.0.0.0 --port 8002。命令行参数优先于 HOSTPORT,所以由该 unit 启动时,进程实际监听 8002,而且监听所有 IPv4 接口。除非主机防火墙或安全组限制,外部客户端可能绕过 Nginx 直接访问 8002。

旧 Nginx 片段是第三套口径:upstream 为 127.0.0.1:18088,路径为 /xiaohong/。服务端目前同时接受 /hajimi 与兼容别名 /xiaohong,所以旧路径在 Python 路由层仍可能响应;但是 upstream 端口与 systemd 不一致,canonical prefix 也没有进入该片段。这里不是“二选一都可以”,而是文件之间出现了版本漂移。

systemd unit 已能守护进程,但还不是完整的服务加固

当前 unit 很短,真实内容如下:

[Unit]
Description=Hajimi Voice Companion backend
After=network.target

[Service]
Type=simple
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
Restart=always
RestartSec=3
User=ubuntu

[Install]
WantedBy=multi-user.target

它明确了工作目录、环境文件、虚拟环境解释器、服务用户和自动重启策略。进程异常退出后,systemd 会等待 3 秒重启;服务不是 root 运行,这是正确的基础边界。部署脚本把 unit 以 root:root、0644 安装到系统目录,再执行 daemon-reload

但当前 unit 还没有 NoNewPrivilegesPrivateTmpProtectSystemProtectHome、能力集限制、资源上限、启动超时和显式日志策略。是否能直接加入这些项,需要先列清服务必须读取的模型目录、字体、.env,以及是否需要写缓存或临时文件,不能复制一份“加固模板”后让 TTS 模型加载失败。另一个更直接的选择是让 Python 只绑定 127.0.0.1:8002,由 Nginx 作为唯一入口;是否这样改取决于设备是否必须直连 8002。

After=network.target 只表达排序,不保证公网、DNS、代理隧道或 Fish Audio 已经可用。由于 TTS 有离线回退,服务可以先启动,但 health 和后续模型 smoke 仍应区分“进程已启动”“本地模型就绪”“云端路径可用”三个状态。

部署脚本先在临时目录做 Python 与协议预检

deploy_remote.py 通过 SSH 建立连接,以时间戳创建远端临时目录,然后用 SFTP 上传 Python 服务、字体、测试脚本和 systemd unit。Nginx 配置不在上传列表中,这一点非常关键:运行当前部署脚本不会安装或修复 Nginx,也不会执行 nginx -t 或 reload。

正式覆盖前,脚本在远端临时目录运行 Python 语法编译、协议冒烟和回答多样性冒烟。真实命令拼接如下:

run(
    ssh,
    f"cd {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.pyasr_service.pytts_service.pyfont.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 命令执行 sedprintf,敏感值会进入构造出的命令字符串;若异常日志、调试器或命令审计完整记录参数,就可能泄露。更安全的方式是上传权限受控的临时环境文件,校验后原子替换,或接入系统 secret 管理。

部署脚本中还存在具体公网入口、模型名、音色 reference 和代理设置的固定值。它们并非都等同于密码,但属于部署环境细节,不应复制到公开文章或固件。代码层应逐步把环境差异移到独立、权限受控且可审计的配置清单中;公开示例只保留变量名和占位符。

覆盖与重启之间不是一笔完整事务,失败窗口要说清楚

备份完成后,脚本先更新 .env,再用 install 覆盖三个 Python 文件、字体和 systemd unit,执行 daemon-reload,然后继续补齐字体、TTS 与 ASR 配置。真正的 try/except 回滚保护从 systemctl restart 才开始。

这意味着:如果更新 .env、执行 installdaemon-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(f"unexpected health response: {health_text}")
if not health.get("asr", {}).get("model_ready"):
    raise RuntimeError(f"ASR model is not ready: {health_text}")
if not health.get("tts", {}).get("model_ready"):
    raise RuntimeError(f"TTS model is not ready: {health_text}")
if health.get("tts", {}).get("provider") != "fish":
    raise RuntimeError(f"Fish 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 并重启服务,然后把原异常继续抛出。这个动作保留了失败信号,没有用“回滚命令执行过”冒充部署成功。

但回滚命令使用 check=False,即使某个复制或 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 和 Upgrade header,handle_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.pytts_service.pydeploy_remote.py 做了 Python AST 解析,三个文件通过;重新运行 protocol_smoke.pyresponse_variety_smoke.py,确定性协议和回答规则通过。逐文件长度与 SHA-256 已写入源码快照,后端目录不是 Git 仓库,因此快照使用文件哈希而不是虚构提交号。

本轮没有调用 deploy_remote.py,没有读取其凭据来源,没有 SSH 登录,没有覆盖远端文件,没有执行 systemctlnginx -t 或 reload,也没有访问公网 health。systemd 8002 是 unit 文件的启动事实,旧 Nginx 18088 是仓库片段事实;两者不一致是静态证据,本文没有把任何一方冒充线上现状。

因此,当前可以确认的是部署脚本包含上传前预检、备份、环境更新、安装、重启、详细 health、模型 smoke 和后半段异常回滚;同时也确认自动回滚窗口不覆盖前半段安装错误、Nginx 未纳入部署、WebSocket token 尚未在服务端校验、TLS 与端口隔离未闭环。把这些边界写清,才是下一轮安全修复和真实上线验收的起点。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐