Files
HomeAgent/cmd/waiter/config.go
JianFeeeee 71934464cf feat(waiter): 设备命令白名单改为 waiter.yaml 可配置
## 起因

白名单是源码里硬编码的正则(`homeagentAllowCmd`,18 个命令),
而 `waiter.yaml` 里**没有任何键能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr`
这些排查问题最常用的**只读**命令一律被拒。生产实测:

    device_ctl_cmdrun  device_id:waiter-fnnas  error: command not in whitelist

命令执行完全在 waiter 侧(`device.go` 的 `exec.CommandContext`),插件侧无二次
限制;触发者是 **agent**(经 device_ctl_cmdrun),所以这道闸是机器闸、不是人工确认。

## 改动

waiter.yaml 新增 `device_cmd_allowlist`(字符串数组):

    device_cmd_allowlist:
      - ls
      - find
      - grep
      - sed

- **替换**默认集而非追加:避免"以为加了 find、结果还留着 python3 -c 任意执行"
- 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门)
- 匹配只取命令名**第一段**再整词匹配:`grep -rn x .` 能过,
  而 `grepXxx` / `mygrep` 不会因 contains 蒙混过关;也跳过 `FOO=bar cmd` 的赋值前缀
- `deviceCmdAllowed` 是包级函数变量,由配置赋值 —— 与同文件既有的
  `sendBridgeResult` 同一模式

## ★ 一次真实的疏漏(判据记着)

waiter 有**两条**设备桥启动路径:
- `main.go` 的 `startDeviceBridge` —— 交互/一次性模式
- `daemon.go` 的 `startDaemonDeviceBridge` —— `waiter --daemon`(**生产两台都这么跑**)

我最初只在 `main.go` 里赋值。daemon 路径不经过那里 ⇒ 配置**完全不生效**,
而症状是"配置写了、启动也打了招呼、命令照样被拒",极难定位。
两处都接上了,并加 `TestDaemonPathAppliesAllowlist` 守住。

## 判据(5 条)

- `TestDefaultAllowlistStillBlocksDestructive`  默认集必须挡住
  `rm -rf /`、`dd`、`chmod -R 777`、`mkfs`、fork 炸弹 ——
  **这道闸存在的唯一理由**,谁把它改成"什么都不拦"这条就要失败
- `TestConfigAllowlistExtends`  配置里声明的 `find/grep/sed/sort/tr` 能过;
  配置未含的 `rm -rf /` 仍被拒(证明是"替换"不是"叠加")
- `TestEmptyConfigFallsBackToDefault`  配置为空时回退默认集,**且不放行** `rm -rf /`
- `TestCmdAllowlistFromYAML`  走**真实** `readFile` 解析 yaml(不另写一份解析,
  两处会漂移,而漂移本身就是漏洞)
- `TestDaemonPathAppliesAllowlist`  守住 daemon 路径也应用配置

## 生效方式

106/30 的 `/opt/waiter/waiter.yaml` 追加 `device_cmd_allowlist`,
并更新二进制。启动日志会打印 `device cmd allowlist: N 条(来自 waiter.yaml)`
或 `默认 N 条`,便于确认配置是否真的被读到。
2026-09-27 19:41:43 +08:00

189 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Connection struct {
Name string `yaml:"name"`
Socket string `yaml:"socket,omitempty"`
Remote string `yaml:"remote,omitempty"`
APIKey string `yaml:"api_key,omitempty"`
}
type Config struct {
Socket string `yaml:"socket"`
Remote string `yaml:"remote"`
APIKey string `yaml:"api_key"`
Default string `yaml:"default"`
Connections []Connection `yaml:"connections,omitempty"`
DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890)
DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token
DeviceAuthorized bool `yaml:"device_authorized,omitempty"` // 客户端本地授权(用户手动开启,服务端无法篡改)
// DeviceCmdAllowlist 是设备桥**命令白名单**(可执行命令名的第一段)。
//
// 留空/缺省 ⇒ 用内置默认集(见 device.go 的 defaultCmdAllowlist)。
// ★ 不是"追加"而是"替换":写了就以它为准,避免"以为加了 find、
// 结果还留着 python3 -c 任意执行"这类误判。
//
// 为什么需要它:白名单原本是源码里硬编码的正则(18 个命令),
// 而 waiter.yaml 里没有任何键能改它 ⇒ find / grep / sed / sort / tr
// 这些排查问题最常用的**只读**命令一律被拒,实测报错:
// device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist
DeviceCmdAllowlist []string `yaml:"device_cmd_allowlist,omitempty"`
}
func (c *Config) Active() *Connection {
for i := range c.Connections {
if c.Connections[i].Name == c.Default {
return &c.Connections[i]
}
}
return nil
}
func (c *Config) ApplyDefault() {
conn := c.Active()
if conn == nil {
return
}
if c.Socket == "" && c.Remote == "" {
c.Socket = conn.Socket
c.Remote = conn.Remote
c.APIKey = conn.APIKey
}
}
func discoverConfig(configPath string) *Config {
if configPath != "" {
if cfg := readFile(configPath); cfg != nil {
return cfg
}
}
candidates := configCandidates()
for _, p := range candidates {
if cfg := readFile(p); cfg != nil {
return cfg
}
}
return &Config{}
}
func configCandidates() []string {
var cands []string
home, _ := os.UserHomeDir()
if home != "" {
cands = append(cands, filepath.Join(home, ".config", "homeagent", "waiter.yaml"))
}
cands = append(cands, "/root/.config/homeagent/waiter.yaml")
cands = append(cands, filepath.Join(".", "waiter.yaml"))
if exe, err := os.Executable(); err == nil {
cands = append(cands, filepath.Join(filepath.Dir(exe), "waiter.yaml"))
}
return cands
}
func configPath() string {
home, _ := os.UserHomeDir()
if home == "" {
return ""
}
return filepath.Join(home, ".config", "homeagent", "waiter.yaml")
}
func readFile(path string) *Config {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", path, err)
return nil
}
return &cfg
}
func (c *Config) Save() {
p := configPath()
if p == "" {
return
}
os.MkdirAll(filepath.Dir(p), 0755)
data, err := yaml.Marshal(c)
if err != nil {
return
}
os.WriteFile(p, data, 0644)
}
func (c *Config) MergeCLI(socket, remote, apiKey string) {
if socket != "" {
c.Socket = socket
}
if remote != "" {
c.Remote = remote
}
if apiKey != "" {
c.APIKey = apiKey
}
}
func (c *Config) SaveConnection(name string) {
conn := Connection{
Name: name,
Socket: c.Socket,
Remote: c.Remote,
APIKey: c.APIKey,
}
for i, existing := range c.Connections {
if existing.Name == name {
c.Connections[i] = conn
c.Default = name
c.Save()
return
}
}
c.Connections = append(c.Connections, conn)
c.Default = name
c.Save()
}
func (c *Config) SwitchConnection(name string) bool {
for _, conn := range c.Connections {
if conn.Name == name {
c.Socket = conn.Socket
c.Remote = conn.Remote
c.APIKey = conn.APIKey
c.Default = name
c.Save()
return true
}
}
return false
}
func (c *Config) DeleteConnection(name string) bool {
for i, conn := range c.Connections {
if conn.Name == name {
c.Connections = append(c.Connections[:i], c.Connections[i+1:]...)
if c.Default == name {
c.Default = ""
}
c.Save()
return true
}
}
return false
}