跳转到内容

rha.toml

唯一必须写内容的一份[api].token 没有默认值,少了这一段整份配置连加载都加载不了。 其余三段([storage] / [llm] / [notify])全部可省,省掉就是走默认:开 sqlite、 不接 LLM、通知拒私网。

「必须写内容」不等于「唯一必须存在」——devices.toml 也必须存在,哪怕是个空文件。 它和 rha.toml 一样被无条件读取,缺了直接报 cannot read .../devices.toml;而 bridges.tomlautomations/ 是真可选,不存在就当没配。新建配置目录时先把这两个 文件建出来:

Terminal window
mkdir -p /etc/rha
printf '[api]\ntoken = "${RHA_TOKEN}"\n' > /etc/rha/rha.toml
touch /etc/rha/devices.toml
[api]
listen = "127.0.0.1:8420"
token = "${RHA_TOKEN}"
[storage]
backend = "sqlite"
path = "/var/lib/rha/rha.db"
retention_days = 30
record = ["*"]
exclude = ["*.indicator_light_*"]
[llm]
api_key = "${ANTHROPIC_API_KEY}"
model = "claude-sonnet-5"
[notify]
allowed_hosts = ["ntfy.sh"]

${VAR} 是配置加载期的环境变量插值,rha.toml / devices.toml / bridges.toml / automations/*.toml 四份都支持(spec.d/ 不走这条路,它由型号表加载器单独读)。变量 没定义会直接报 MissingEnv 而不是静默留空——所以凭据写成 ${...} 既不用把明文提交进 仓库,也不会因为忘了设环境变量而无声降级。

[api] —— 这台 daemon 的全部门禁

Section titled “[api] —— 这台 daemon 的全部门禁”

这一段是控制面入口:rha 命令行、REST 调用、以及挂在 /mcp 上的 agent 都从这里进来, 而这个面既能读全屋状态、也能下发控制命令。所以下面三个字段合起来就是全部门禁—— listen 决定谁在网络上够得着,token 是唯一一道鉴权,mcp_allowed_hosts/mcp 额外 的一道 Host 白名单。跨机访问要动其中两个,只动一个是最常见的卡点。

整段不参与热重载,改了必须重启进程。

字段类型默认说明
listenstring127.0.0.1:8420

HTTP API(含 /mcp)监听的地址和端口。缺省 127.0.0.1:8420 —— 只有本机能连。

缺省绑 loopback 而不是 0.0.0.0: 这个端口能读全屋状态、也能下发控制命令, 门只有 token 一道, 不该默认对局域网开放。要跨机访问再显式改。

改成 0.0.0.0:8420 只放开 REST API; /mcp 另有一道 Host 白名单, 跨机直连仍会被 403 挡掉, 必须同时配 mcp_allowed_hosts

[api]不参与热重载 —— rha reload 不会重新 bind, 改了它必须重启进程 (既不报错也不生效, 很容易以为改上了)。启动时 bind 不成功就直接启动失败。

mcp_allowed_hostsarray[]

允许直连 /mcp 的 Host 白名单(rmcp 的 DNS rebinding 防护)。留空则只放行 loopback —— 此时哪怕 listen 是 0.0.0.0, 从别的机器挂 MCP 也会被 403 挡掉。 跨机使用时填对方地址栏里的 host[:port], 例 "192.168.52.40:8420"。

["*"] 则不限来源, 任意 Host 都放行(反代/隧道后面 Host 不可知时用)。 代价是 DNS rebinding 防护一并关掉, 此时唯一的门就是 token。

token*string

HTTP API 的鉴权令牌 —— 请求携带的 token 与它不一致就一律 401。 建议用 ${ENV} 插值, 不要把明文提交进配置文件。

这一段管三样东西:实体的历史时间序列、规则的评估轨迹、以及重启后用来恢复当前值 的快照。三者共用一个后端,但只有第一样受 record / exclude / retain 的范围策略 影响——所以「我把这个实体排除了,为什么重启后它还有值」不是 bug。

范围策略与轮询分层(hot / cold)是两件独立的事:「该多勤地问设备」和「要不要留曲线」 不是同一个问题。

字段类型默认说明
backendstringsqlite

选后端, 合法取值只有 "sqlite"(缺省)和 "postgres", 别的值在启动校验阶段直接报 unknown storage backend。是大小写敏感的字面量匹配, "SQLite" 会被判非法。

两种后端建表语句等价(state_history / rule_trace / snapshot), 启动时用 CREATE TABLE IF NOT EXISTS 自动建好。但换后端不会自动搬数据 —— sqlite 与 postgres 是两份互不相干的库。

enabledbooleantrue

总开关, 缺省 true。设为 false = 不开数据库, 历史 / 轨迹全部只留在内存里, 这一段的其余字段全部当没写过

它与「整段不写 [storage]」的结果恰好相反, 别搞混: 整段不写是走默认值 (开启 + sqlite), 只有显式写 enabled = false 才是关掉。想关就必须留着这一段。

关掉之后: 历史接口返回 503 storage disabled; 规则轨迹只剩内存里最近 1000 条; 重启后所有实体没有历史值可恢复(快照恢复走的是同一个存储)。

另外关掉时这一段连校验都不跑 —— 写了非法 backend 也不会报错, 等你哪天把 enabled 改回 true 才暴露。与 [[bridge]].enabled 是同一套语义。

excludearray[]

record 之后生效的排除表。例: ["*.indicator_light_*"]

语法与 record 完全相同(共用同一份 rha_core::glob)。exclude 永远压过 record —— 两边都写同一个实体的结果是不记。同样只影响历史时间序列, 不影响快照, 也不影响 retention_days 的清理。

pathstring可省略

sqlite 数据库文件的路径, 只有 backend = "sqlite" 时才被读(postgres 时静默忽略)。 缺省 history.db, 即落在配置目录下。

相对路径相对的是配置目录(--config 指的那个目录), 不是当前工作目录。

文件不存在会自动创建(WAL 模式), 但不自动建父目录 —— 父目录不存在或不可写会 导致启动失败, 错误信息带出路提示「set [storage].path to a writable location, or set enabled = false」。rha doctor 会预先探测这个父目录的可写性。

recordarray[]

记录哪些实体的历史(极简 glob, 只支持 *)。缺省 ["*"] 全记。

这是存储侧的用户策略, 与轮询分层无关 —— "该多勤地问设备"和"要不要留曲线" 是两个问题。绑在一起会让 BLE 温湿度计这类广播设备因为没人写规则而丢掉温度曲线。

空表被当作 ["*"] —— 留空是全记, 不是「什么都不记」。语法与 [[bridge]].export 完全一致但空表含义相反(bridge 的 export 必填, 不写就没有默认全导)。

这条策略只管历史时间序列, 不管快照: 被排除的实体照样写 snapshot, 所以重启后 它仍能恢复出当前值。

retention_daysinteger30

历史保留天数, 缺省 30。daemon 内有一个清理任务, 删掉 now - retention_days 之前的状态历史和规则轨迹。

0 不是「不清理」, 是启动校验错误 storage retention_days must be >= 1

三个容易误解的边界:

  • 清理任务跟着 daemon 进程走: 每次启动先立刻清一次, 之后每 24 小时一轮。 进程没跑的时候不会清, 但频繁重启不会漏清, 反而是每重启一次就清一次。
  • 设备上写 retain = "forever" 可以豁免清理, 但只豁免那台设备的状态历史 —— 规则轨迹是无条件按 cutoff 清的。
  • snapshot 表从不清理, 每个实体一行。
urlstring
Secret
可省略

postgres DSN。装的是 postgres://user:pass@host/db 这种带密码的串, 所以走 Secret —— StorageConfigderive(Debug), 裸 String 会随 任何 {:?} 一起打出来。

只有 backend = "postgres" 时才被读, 此时不写会报 storage backend postgres requires url; backend = "sqlite" 时写了它会被静默 忽略, 不报错也不生效 —— 想切 postgres 光填 url 不够, 必须同时把 backend 改掉。

建议写成 ${ENV}: 配置加载时会做环境变量插值, 变量没定义会直接报 MissingEnv。

敏感值。建议写成 ${ENV} 由环境变量插值, 不要把明文提交进配置文件。该值绝不进日志与 trace。

[llm] —— 规则里 llm 动作的插槽

Section titled “[llm] —— 规则里 llm 动作的插槽”

规则里的 llm 动作用这一段去访问模型。它是纯可选的,而**「没 配」与「配错了」的表现完全一样**:两种情况下 rha check 都全绿,运行时都静默走 fallback 分支。所以这一段配完,第一件事是看一条 trace 确认 source=llm

字段类型默认说明
api_keystring
Secret
可省略

访问 LLM 端点的密钥, 作为 x-api-key 头发送。建议用 ${ENV} 插值; 绝不进日志/trace。

它是 Secret 类型: Debug / 日志 / trace 里一律打印 <redacted>, 只有真正发请求 时才取明文。${VAR} 若环境变量不存在, 会在加载阶段直接报 MissingEnv 而不是静默 留空。

不配则每次 llm 动作直接降级 fallback, 连请求都不发(trace 的 note 里含 no api_key configured), 而 rha check 对此既不报错也不警告 —— 配置全绿、运行时 永远走 fallback。上线后请先看一条 trace 确认 source=llm

敏感值。建议写成 ${ENV} 由环境变量插值, 不要把明文提交进配置文件。该值绝不进日志与 trace。

base_urlstringhttps://api.anthropic.com

LLM 端点根地址, 缺省 https://api.anthropic.com。请求发到 {base_url}/v1/messages, 带 x-api-keyanthropic-version: 2023-06-01 头 —— 所以它必须是 Anthropic Messages API 兼容的端点(官方 API 或兼容网关)。

结尾的 / 会被去掉, 写不写都行。不能填 OpenAI 风格的 /v1/chat/completions 端点 —— 路径和鉴权头都不一样, 结果是每次都降级 fallback。指向网关时不要把凭据 放进 query。

max_tokensinteger1024

请求的 max_tokens 上限, 缺省 1024

缺省够用: 这里的调用只需要模型返回一次 choose_branch 工具调用, 输出很短, 不需要调大。

modelstringclaude-sonnet-5

请求里的模型名, 原样传给上游。缺省 claude-sonnet-5

写错不会在 check 期发现: 上游返回非 2xx, 引擎降级 fallback, 而且响应体原文不进 trace(只记字节数与 sha256 前 4 字节指纹, 防上游注入)。所以你看不到 「model not found」这种原始报错, 只能看到 llm http 404: N bytes sha256=...

这一段约束的是规则里 notify 动作llm 动作的 snapshot 能发到哪儿。

下面两个字段不是叠加,是互斥的两条路,这一点两行说明各说了一半,合起来是: allowed_hosts 留空时走 allow_private_networks(缺省拒私网、放行公网); allowed_hosts 一旦非空就切换成纯白名单,allow_private_networks 根本不再参与判断。 所以内网自建的接收端有两种配法,而选了白名单那条路就等于把公网目标(包括 ntfy.sh) 一起挡掉了。

字段类型默认说明
allow_private_networksbooleanfalse

是否允许把通知(以及 llm 动作的 snapshot)发到私网 / 环回 / 链路本地地址。 缺省 false

缺省拒绝是 SSRF 防护: 规则可以由 HTTP API / MCP 写入, 一个能任意 POST 到内网地址 的动作等于把 daemon 变成内网探针。check 期拒绝字面量私网 IP 与 localhost, 运行时再做一次 DNS 解析后的复核。

allowed_hosts 非空时这个开关根本不参与判断 —— 白名单命中直接放行(私网地址 也放行), 未命中直接拒绝, 两条路都不看它。

私网判定比直觉严: 环回、RFC1918、链路本地(169.254/fe80::)、0.0.0.0、广播、 IPv6 unique-local, 以及各种内嵌 IPv4 的 v6 编码(mapped / compatible / translated / NAT64)都算私网。

已知残留缺口(明说了没修): 运行时的 DNS 校验与实际连接是两次独立查询, 存在 DNS rebinding 的绕过空间。

allowed_hostsarray[]

通知目标主机白名单。缺省空表 = 不启用白名单, 改由 allow_private_networks 决定 (缺省拒私网、放行公网)。

非空时策略变成纯白名单: URL 的 host 与列表做精确字符串比对, 命中即放行(跳过 私网判定和 DNS 复核), 未命中一律拒绝。所以 allowed_hosts = ["nas.lan"] 就是 显式信任内网目标, 此时不需要也不影响 allow_private_networks

「精确比对」的范围很窄: 不支持通配符、不匹配子域、不带端口(只比 host)。 "ntfy.sh" 不会覆盖 "push.ntfy.sh"。URL 解析会把 host 规范成小写, 所以白名单里 写大写字母的条目永远匹配不上 —— 一律写小写。

尾点("ntfy.sh.")两边都别写, 但坏法不一样, 分清尾点写在哪一侧: 写在白名单条目 里是原样比对, allowed_hosts = ["ntfy.sh."]https://ntfy.sh/... 当场 check 失败 (host "ntfy.sh" not in notify allowlist), 根本到不了运行时; 写在 URL 的 host 里则相反 —— check 期会把尾部的 . 剥掉、运行时那道闸不剥, 于是它过得了 rha check 却在运行时被拒。