feat(agent): 配对尝试记录来源 IP
"十次失败"说明窗口关了,"十次来自 10.0.0.7 的失败"说明有人在试——后者才是可行动 的那句话,而它是尝试次数上限无法提供的信息。每次尝试与每次成功现在都带 peer。 只记 IP 不记端口:端口每次都是新的临时号,带上会让同一台机器的两次尝试看起来像 两台,而"有几台机器在试"正是要回答的问题。 测试断言失败尝试带上了 peer,且是去掉端口后的主机。变异验证:去掉 peer,测试失败。 Go 全包、Node 120 项、Swift 107 项通过。
This commit is contained in:
+20
-5
@@ -19,6 +19,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -179,7 +180,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.authorize(body.Password) {
|
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")
|
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
||||||
return
|
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 {
|
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
|
// 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.
|
// 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")
|
writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing")
|
||||||
return
|
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
|
// second phone means re-running --pair, which is the right amount of friction for a socket
|
||||||
// that grants device trust.
|
// that grants device trust.
|
||||||
h.closeLocked()
|
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{
|
writeJSON(w, provisionResponse{
|
||||||
NodeID: h.nodeID,
|
NodeID: h.nodeID,
|
||||||
AppID: deviceID,
|
AppID: deviceID,
|
||||||
@@ -230,7 +231,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.authorize(body.Password) {
|
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")
|
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -240,7 +241,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.closeLocked()
|
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})
|
writeJSON(w, map[string]any{"destroyed": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +280,20 @@ func mustPublic(private []byte) []byte {
|
|||||||
return key
|
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) {
|
func writeJSON(w http.ResponseWriter, body any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -303,3 +304,43 @@ func TestTheDefaultBindIsALiteralAndIsReachableFromTheLAN(t *testing.T) {
|
|||||||
DefaultAdminHost, "0.0.0.0")
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user