From 5feb5bfe7030ee574b94ce4afd6432c7d5244874 Mon Sep 17 00:00:00 2001 From: chick Date: Thu, 17 Sep 2026 07:58:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E9=85=8D=E5=AF=B9=E5=B0=9D?= =?UTF-8?q?=E8=AF=95=E8=AE=B0=E5=BD=95=E6=9D=A5=E6=BA=90=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "十次失败"说明窗口关了,"十次来自 10.0.0.7 的失败"说明有人在试——后者才是可行动 的那句话,而它是尝试次数上限无法提供的信息。每次尝试与每次成功现在都带 peer。 只记 IP 不记端口:端口每次都是新的临时号,带上会让同一台机器的两次尝试看起来像 两台,而"有几台机器在试"正是要回答的问题。 测试断言失败尝试带上了 peer,且是去掉端口后的主机。变异验证:去掉 peer,测试失败。 Go 全包、Node 120 项、Swift 107 项通过。 --- internal/pair/pair.go | 25 ++++++++++++++++++----- internal/pair/pair_test.go | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/internal/pair/pair.go b/internal/pair/pair.go index 447c09c..c18c7ec 100644 --- a/internal/pair/pair.go +++ b/internal/pair/pair.go @@ -19,6 +19,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "sync" "time" @@ -179,7 +180,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) { return } if !h.authorize(body.Password) { - h.log("pair_attempt", map[string]any{"outcome": "bad_secret"}) + h.log("pair_attempt", map[string]any{"outcome": "bad_secret", "peer": peerOf(r)}) writeError(w, http.StatusUnauthorized, "the shared secret did not match") return } @@ -195,7 +196,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) { if err := config.Save(h.cfgPath, h.cfg); err != nil { // The device was not added if the save failed, so a retry is safe and the phone must not // be handed a keypair the agent will not honour. - h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error()}) + h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error(), "peer": peerOf(r)}) writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing") return } @@ -203,7 +204,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) { // second phone means re-running --pair, which is the right amount of friction for a socket // that grants device trust. h.closeLocked() - h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID}) + h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID, "peer": peerOf(r)}) writeJSON(w, provisionResponse{ NodeID: h.nodeID, AppID: deviceID, @@ -230,7 +231,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) { return } if !h.authorize(body.Password) { - h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy"}) + h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy", "peer": peerOf(r)}) writeError(w, http.StatusUnauthorized, "the shared secret did not match") return } @@ -240,7 +241,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) { return } h.closeLocked() - h.log("pair_destroy", map[string]any{"outcome": "ok"}) + h.log("pair_destroy", map[string]any{"outcome": "ok", "peer": peerOf(r)}) writeJSON(w, map[string]any{"destroyed": true}) } @@ -279,6 +280,20 @@ func mustPublic(private []byte) []byte { return key } +// peerOf reports the caller's address, for the log only. +// +// It is the one piece of context the attempt ceiling cannot supply: "ten failed pairings" says the +// window closed, and "ten failed pairings from 10.0.0.7" says someone is trying. Only the IP is kept, +// never the port, because the port is a fresh ephemeral number on every attempt and would make two +// attempts from one host look like two hosts. +func peerOf(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + func writeJSON(w http.ResponseWriter, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/internal/pair/pair_test.go b/internal/pair/pair_test.go index 3e524cd..371e541 100644 --- a/internal/pair/pair_test.go +++ b/internal/pair/pair_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -303,3 +304,43 @@ func TestTheDefaultBindIsALiteralAndIsReachableFromTheLAN(t *testing.T) { DefaultAdminHost, "0.0.0.0") } } + +// The log has to name the caller. "Ten failed pairings" tells an operator the window closed; "ten +// failed pairings from 10.0.0.7" tells them someone is trying, which is the only actionable form of +// the same fact and the one thing the attempt ceiling cannot supply. +func TestAPairingAttemptIsLoggedWithItsPeer(t *testing.T) { + path, cfg, _ := agentConfig(t) + var mu sync.Mutex + var attempts []map[string]any + h, err := New(path, cfg, func(event string, fields map[string]any) { + if event != "pair_attempt" { + return + } + mu.Lock() + defer mu.Unlock() + attempts = append(attempts, fields) + }) + if err != nil { + t.Fatalf("New: %v", err) + } + signPub, kxPub := phoneKeys(t) + + request := httptest.NewRequest("POST", "/provision", provisionBody(t, "wrong", signPub, kxPub)) + // A caller behind a NAT, which is the shape a real attempt has. + request.RemoteAddr = "10.0.0.7:54321" + h.Routes().ServeHTTP(httptest.NewRecorder(), request) + + mu.Lock() + defer mu.Unlock() + if len(attempts) != 1 { + t.Fatalf("expected one logged attempt, got %d", len(attempts)) + } + if attempts[0]["outcome"] != "bad_secret" { + t.Fatalf("outcome is %v, want bad_secret", attempts[0]["outcome"]) + } + // The host without its ephemeral port: the port is a fresh number on every attempt, so keeping it + // would make two attempts from one host look like two hosts. + if attempts[0]["peer"] != "10.0.0.7" { + t.Fatalf("peer is %v, want the host alone", attempts[0]["peer"]) + } +}