feat(agent): 配对窗口可显式绑定地址,默认仍是内网可达

配对窗口是 agent 唯一持有的监听端口,而它授予的是设备信任:谁能带节点密码打到
/provision,谁就拿到一把 agent 会听命的密钥。此前它固定绑所有接口,且没有任何办法
收窄——一台本身能从公网路由到的节点(公网 VPS 上的 SimAdmin、放在非可信网络里的
设备)在窗口开启期间就把这个面暴露给了整个互联网。

现在加 --pair-bind,只接受 IP 字面量。不接受主机名是刻意的:绑定时解析名字意味着
监听地址取决于 DNS 当时说什么,那不是运维做过的决定。

默认值仍是绑所有接口,这个我改过一次又改回来了,原因值得记下来:我先把默认设成
127.0.0.1 并写了理由,然后发现配对按设计走内网、手机要直接打到节点,回环监听从
另一台机器根本连不上——那不是一个加固措施,而是一个永远不工作的流程,用户对这个
功能的第一次体验会是"连接被拒"且没有解释。所以默认保持内网可达,把"节点能从公网
路由到时请收窄"写进部署文档,并在 --pair-bind 上给出显式入口。

测试断言默认值同时满足两件事:是 IP 字面量(不是名字),且不是回环(手机要能连)。

Go 全包、Node 120 项、Swift 107 项通过。
This commit is contained in:
chick
2026-09-17 07:47:25 +08:00
parent 0f983f046a
commit b5f548f1ad
3 changed files with 55 additions and 7 deletions
+12 -4
View File
@@ -15,6 +15,7 @@ import (
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
@@ -47,6 +48,7 @@ func main() {
once = flag.Bool("once", false, "run a single poll cycle and exit")
pairMode = flag.Bool("pair", false, "open the temporary pairing window on the LAN and exit when it closes")
pairPort = flag.Int("pair-port", pair.DefaultAdminPort, "port for the pairing window's management listener")
pairBind = flag.String("pair-bind", pair.DefaultAdminHost, "address the pairing window binds to")
)
flag.Parse()
@@ -55,7 +57,7 @@ func main() {
var err error
if *pairMode {
err = runPair(*configPath, *pairPort)
err = runPair(*configPath, *pairPort, *pairBind)
} else {
err = run(*configPath, *check, *once)
}
@@ -71,7 +73,7 @@ func main() {
// the invariant "the agent binds no socket" is a property of the daemon; the pairing window is a
// deliberate, operator-started exception with three independent shutdowns (first pairing, attempt
// ceiling, deadline). Pairing is run again by the operator for each additional phone.
func runPair(configPath string, adminPort int) error {
func runPair(configPath string, adminPort int, adminHost string) error {
cfg, err := config.LoadForPairing(configPath)
if err != nil {
return err
@@ -82,13 +84,19 @@ func runPair(configPath string, adminPort int) error {
if err != nil {
return err
}
if net.ParseIP(adminHost) == nil {
// A name here would be resolved at bind time, and the window is the one place the agent holds a
// listener: an operator who typed a hostname would get whatever that name resolved to, which is
// not a decision they made deliberately. The default is a literal for that reason.
return fmt.Errorf("pair-bind must be an IP literal, got %q", adminHost)
}
server := &http.Server{
Addr: ":" + strconv.Itoa(adminPort),
Addr: net.JoinHostPort(adminHost, strconv.Itoa(adminPort)),
Handler: handler.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("pairing window open", "port", adminPort, "node", cfg.NodeID)
slog.Info("pairing window open", "bind", adminHost, "port", adminPort, "node", cfg.NodeID)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("pairing listener failed", "error", err)
handler.Close()