feat: 配对管理端点与中继侧设备注册上报
- agent --pair:临时监听 :3001 仅开放 /provision 与 /destroy,以节点密码为共享秘密; 窗口由三个独立条件关闭(首次成功配对 / 失败尝试 10 次 / 10 分钟超时),配对成功后 管理监听即关(设计 §7 第 4 步) - agent 为每台配对手机生成中继 bearer(config.Save 0600 原子写;启动时为手写条目补发) - transport.PushDevices:握手成功后全量上报本节点受信设备集;空集是吊销声明而非无操作 - relay /v1/agent/devices:agent 会话后鉴权的设备注册;全量替换本节点条目; 操作员 MSA_DEVICES 为 static 不可被覆盖;跨节点 id 不可抢占 - 中继设备注册替代了 §11 第 6 项的一部分:节点 destroy + 重握手即可在路由层吊销 - 配置:provisionSecret 字段(无密码保护节点需显式设置才可配对);Validate(pairMode) - 测试:pair 流程 8 项、设备注册 6 项、transport 1 项;文档 §7 实现修订注记与 README 状态
This commit is contained in:
+90
-6
@@ -14,13 +14,16 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fun.chick/msa-agent/internal/agent"
|
"fun.chick/msa-agent/internal/agent"
|
||||||
"fun.chick/msa-agent/internal/config"
|
"fun.chick/msa-agent/internal/config"
|
||||||
|
"fun.chick/msa-agent/internal/pair"
|
||||||
"fun.chick/msa-agent/internal/policy"
|
"fun.chick/msa-agent/internal/policy"
|
||||||
"fun.chick/msa-agent/internal/runtime"
|
"fun.chick/msa-agent/internal/runtime"
|
||||||
"fun.chick/msa-agent/internal/transport"
|
"fun.chick/msa-agent/internal/transport"
|
||||||
@@ -41,21 +44,86 @@ func main() {
|
|||||||
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
||||||
check = flag.Bool("check", false, "validate the config and policy, then exit")
|
check = flag.Bool("check", false, "validate the config and policy, then exit")
|
||||||
once = flag.Bool("once", false, "run a single poll cycle and exit")
|
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")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
if err := run(*configPath, *check, *once); err != nil {
|
var err error
|
||||||
|
if *pairMode {
|
||||||
|
err = runPair(*configPath, *pairPort)
|
||||||
|
} else {
|
||||||
|
err = run(*configPath, *check, *once)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
// One line, then exit non-zero. systemd restarts it, and the journal keeps the reason.
|
// One line, then exit non-zero. systemd restarts it, and the journal keeps the reason.
|
||||||
logger.Error("msa-agent exiting", "error", err)
|
logger.Error("msa-agent exiting", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPair serves design §7's management surface: a LAN listener offering /provision and /destroy
|
||||||
|
// for as long as the window is open, then exit. It is a separate mode from the poll loop because
|
||||||
|
// 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 {
|
||||||
|
cfg, err := config.LoadForPairing(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
handler, err := pair.New(configPath, cfg, func(event string, fields map[string]any) {
|
||||||
|
slog.Info(event, fieldsToArgs(fields)...)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":" + strconv.Itoa(adminPort),
|
||||||
|
Handler: handler.Routes(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
slog.Info("pairing window open", "port", adminPort, "node", cfg.NodeID)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("pairing listener failed", "error", err)
|
||||||
|
handler.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
deadline := time.NewTimer(pair.DefaultWindow)
|
||||||
|
defer deadline.Stop()
|
||||||
|
select {
|
||||||
|
case <-handler.Done:
|
||||||
|
slog.Info("pairing window closed")
|
||||||
|
case <-deadline.C:
|
||||||
|
slog.Warn("pairing window expired unused")
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("pairing window interrupted")
|
||||||
|
}
|
||||||
|
// The listener is already closing via defer; Stop with a short grace so an in-flight request
|
||||||
|
// finishes rather than being truncated mid-JSON.
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldsToArgs(fields map[string]any) []any {
|
||||||
|
args := make([]any, 0, len(fields)*2)
|
||||||
|
for key, value := range fields {
|
||||||
|
args = append(args, key, value)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
func run(configPath string, checkOnly, once bool) error {
|
func run(configPath string, checkOnly, once bool) error {
|
||||||
cfg, err := config.Load(configPath)
|
cfg, _, err := config.EnsureRelayTokens(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -126,7 +194,15 @@ func run(configPath string, checkOnly, once bool) error {
|
|||||||
if once {
|
if once {
|
||||||
return cycle(ctx, node, client, pol)
|
return cycle(ctx, node, client, pol)
|
||||||
}
|
}
|
||||||
return loop(ctx, node, client, pol)
|
return loop(ctx, node, client, pol, registrationsFrom(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationsFrom(cfg config.File) []transport.DeviceRegistration {
|
||||||
|
out := make([]transport.DeviceRegistration, 0, len(cfg.Devices))
|
||||||
|
for _, device := range cfg.Devices {
|
||||||
|
out = append(out, transport.DeviceRegistration{App: device.ID, Token: device.RelayToken})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay
|
// loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay
|
||||||
@@ -135,13 +211,13 @@ func run(configPath string, checkOnly, once bool) error {
|
|||||||
// Errors are classified here rather than handled per call site, because the three situations look
|
// Errors are classified here rather than handled per call site, because the three situations look
|
||||||
// identical from inside a function and need three different responses: retry now, re-authenticate,
|
// identical from inside a function and need three different responses: retry now, re-authenticate,
|
||||||
// or wait.
|
// or wait.
|
||||||
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error {
|
||||||
backoff := minBackoff
|
backoff := minBackoff
|
||||||
for {
|
for {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
err := handshakeAndPoll(ctx, node, client, pol)
|
err := handshakeAndPoll(ctx, node, client, pol, devices)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
backoff = minBackoff
|
backoff = minBackoff
|
||||||
@@ -176,10 +252,18 @@ func next(current time.Duration) time.Duration {
|
|||||||
return doubled
|
return doubled
|
||||||
}
|
}
|
||||||
|
|
||||||
func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error {
|
||||||
if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil {
|
if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The relay holds no device state across restarts, and it must never trust a device claim it
|
||||||
|
// cannot tie to an authenticated agent. Reporting the full set after every handshake is what
|
||||||
|
// makes that true without a second channel: the node's paired-device set, including any
|
||||||
|
// revocations from a destroy, is re-asserted each time the session is established. Empty is
|
||||||
|
// pushed too, because "empty" is exactly the claim that revocation needs.
|
||||||
|
if err := client.PushDevices(ctx, devices); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return cycle(ctx, node, client, pol)
|
return cycle(ctx, node, client, pol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-25
@@ -7,6 +7,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -24,6 +26,10 @@ type Device struct {
|
|||||||
// it and holds no corresponding secret, so a phone cannot be impersonated from the relay side.
|
// it and holds no corresponding secret, so a phone cannot be impersonated from the relay side.
|
||||||
SignKey string `json:"signKey"`
|
SignKey string `json:"signKey"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
// RelayToken is the bearer credential the phone presents to the relay for this pairing. The
|
||||||
|
// agent generated it at pairing time and re-registers it with the relay on every handshake,
|
||||||
|
// because the relay holds no state across restarts.
|
||||||
|
RelayToken string `json:"relayToken,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// File is the on-disk shape.
|
// File is the on-disk shape.
|
||||||
@@ -45,33 +51,23 @@ type File struct {
|
|||||||
// commands with node_auth_failed rather than looping against a lockout.
|
// commands with node_auth_failed rather than looping against a lockout.
|
||||||
NodePassword string `json:"nodePassword,omitempty"`
|
NodePassword string `json:"nodePassword,omitempty"`
|
||||||
// AuditPath is where the audit log is appended.
|
// AuditPath is where the audit log is appended.
|
||||||
AuditPath string `json:"auditPath"`
|
AuditPath string `json:"auditPath"`
|
||||||
Devices []Device `json:"devices"`
|
// Devices may be empty only in pair mode (--pair). In normal operation an agent with no
|
||||||
}
|
// paired devices refuses to start, because it could authorise nothing.
|
||||||
|
Devices []Device `json:"devices"`
|
||||||
// Load reads and validates a config file.
|
// ProvisionSecret is the shared secret a phone must present to pair during the temporary
|
||||||
//
|
// --pair window (design §7 step 2, "以节点密码作为共享秘密"). When the node is protected it
|
||||||
// Validation is exhaustive rather than partial on purpose: an agent that starts with a missing
|
// defaults to NodePassword; an unprotected node needs an explicit value here or pairing is
|
||||||
// device or a bad port would sit there healthy and uncommandable, which is harder to notice than a
|
// refused. It is secret material and lives in the config file like the private keys do.
|
||||||
// crash at boot.
|
ProvisionSecret string `json:"provisionSecret,omitempty"`
|
||||||
func Load(path string) (File, error) {
|
|
||||||
var cfg File
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return cfg, fmt.Errorf("config: %w", err)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
if err := decoder.Decode(&cfg); err != nil {
|
|
||||||
return cfg, fmt.Errorf("config: decode %s: %w", path, err)
|
|
||||||
}
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
return cfg, fmt.Errorf("config: %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks every field the agent depends on.
|
// Validate checks every field the agent depends on.
|
||||||
func (c File) Validate() error {
|
//
|
||||||
|
// pairMode relaxes exactly one rule: devices may be empty while the agent is waiting to be
|
||||||
|
// paired. Everything else stays enforced, because a pair-mode agent with a broken nodeId or a
|
||||||
|
// missing key would pair successfully and then command nothing.
|
||||||
|
func (c File) Validate(pairMode bool) error {
|
||||||
if !nodePattern.MatchString(c.NodeID) {
|
if !nodePattern.MatchString(c.NodeID) {
|
||||||
return errors.New("nodeId must look like nd_xxxxxxxx")
|
return errors.New("nodeId must look like nd_xxxxxxxx")
|
||||||
}
|
}
|
||||||
@@ -93,7 +89,7 @@ func (c File) Validate() error {
|
|||||||
if c.AuditPath == "" {
|
if c.AuditPath == "" {
|
||||||
return errors.New("auditPath is required")
|
return errors.New("auditPath is required")
|
||||||
}
|
}
|
||||||
if len(c.Devices) == 0 {
|
if len(c.Devices) == 0 && !pairMode {
|
||||||
return errors.New("devices is empty, so no phone could ever be authorised")
|
return errors.New("devices is empty, so no phone could ever be authorised")
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -111,3 +107,111 @@ func (c File) Validate() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load reads a config file for the normal (commanding) mode.
|
||||||
|
func Load(path string) (File, error) {
|
||||||
|
cfg, err := loadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(false); err != nil {
|
||||||
|
return cfg, fmt.Errorf("config: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadForPairing reads a config file for --pair mode, where an empty device list is the starting
|
||||||
|
// state rather than a misconfiguration.
|
||||||
|
func LoadForPairing(path string) (File, error) {
|
||||||
|
cfg, err := loadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(true); err != nil {
|
||||||
|
return cfg, fmt.Errorf("config: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFile(path string) (File, error) {
|
||||||
|
var cfg File
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
if err := decoder.Decode(&cfg); err != nil {
|
||||||
|
return cfg, fmt.Errorf("config: decode %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the config back with 0600, atomically.
|
||||||
|
//
|
||||||
|
// Atomic (temp file + rename) because the pairing flow writes the device list at moments when the
|
||||||
|
// agent may be concurrently read by systemd restarts: a half-written config is a config that fails
|
||||||
|
// to load, which turns a completed pairing into an uncommandable node.
|
||||||
|
func Save(path string, cfg File) error {
|
||||||
|
if err := cfg.Validate(true); err != nil {
|
||||||
|
return fmt.Errorf("config: refusing to save an invalid file: %w", err)
|
||||||
|
}
|
||||||
|
encoded, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
temp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(temp, encoded, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRelayToken mints a device bearer for the relay: 24 random bytes, hex. Hex because the relay
|
||||||
|
// only requires a bearer string and hex never contains a character that needs escaping when it
|
||||||
|
// travels through JSON or headers.
|
||||||
|
func NewRelayToken() string {
|
||||||
|
raw := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
// rand.Read only fails if the system entropy source is gone, in which case nothing in the
|
||||||
|
// agent can run safely anyway.
|
||||||
|
panic(fmt.Sprintf("config: entropy source failed: %v", err))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeviceID mints an app id for a freshly paired phone. The relay and the agent both match ids
|
||||||
|
// against ^ap_[A-Za-z0-9_-]{8,64}$, so 12 hex characters is the narrowest thing that fits.
|
||||||
|
func NewDeviceID() string {
|
||||||
|
raw := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
panic(fmt.Sprintf("config: entropy source failed: %v", err))
|
||||||
|
}
|
||||||
|
return "ap_" + hex.EncodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelayTokens fills in bearer tokens for hand-written device entries that lack one and
|
||||||
|
// persists the result. It reports whether the file changed so a caller can log the event rather
|
||||||
|
// than silently rewriting an operator's file on every boot.
|
||||||
|
func EnsureRelayTokens(path string) (File, bool, error) {
|
||||||
|
cfg, err := loadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, false, err
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(false); err != nil {
|
||||||
|
return cfg, false, fmt.Errorf("config: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i := range cfg.Devices {
|
||||||
|
if cfg.Devices[i].RelayToken == "" {
|
||||||
|
cfg.Devices[i].RelayToken = NewRelayToken()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := Save(path, cfg); err != nil {
|
||||||
|
return cfg, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, changed, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,11 +23,64 @@ func valid() File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateAcceptsACompleteFile(t *testing.T) {
|
func TestValidateAcceptsACompleteFile(t *testing.T) {
|
||||||
if err := valid().Validate(); err != nil {
|
if err := valid().Validate(false); err != nil {
|
||||||
t.Fatalf("a complete file was refused: %v", err)
|
t.Fatalf("a complete file was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pair mode relaxes exactly one rule: an empty device list is the starting state of a node that
|
||||||
|
// has not been paired yet, not a misconfiguration. Every other field still validates, because a
|
||||||
|
// pair-mode agent with a broken nodeId pairs successfully and then commands nothing.
|
||||||
|
func TestPairModeRelaxesOnlyTheDeviceList(t *testing.T) {
|
||||||
|
cfg := valid()
|
||||||
|
cfg.Devices = nil
|
||||||
|
if err := cfg.Validate(true); err != nil {
|
||||||
|
t.Fatalf("pair mode refused an empty device list: %v", err)
|
||||||
|
}
|
||||||
|
cfg.NodeID = "node-1"
|
||||||
|
if err := cfg.Validate(true); err == nil {
|
||||||
|
t.Fatal("pair mode accepted a malformed nodeId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveRoundTripsAndIsSixHundred(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := valid()
|
||||||
|
if err := Save(path, cfg); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("saved config mode is %v, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
reloaded, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load after Save: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.NodeID != cfg.NodeID || len(reloaded.Devices) != 1 ||
|
||||||
|
reloaded.Devices[0].SignKey != cfg.Devices[0].SignKey {
|
||||||
|
t.Fatalf("round-trip lost fields: %+v", reloaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A save must not be able to write a file that the next boot would refuse: the validation runs
|
||||||
|
// before the rename, so a caller building a device list with a bad id gets an error, not a
|
||||||
|
// corrupted file.
|
||||||
|
func TestSaveRefusesAnInvalidFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := valid()
|
||||||
|
cfg.Devices[0].ID = "not-an-app-id"
|
||||||
|
if err := Save(path, cfg); err == nil {
|
||||||
|
t.Fatal("saved a device list the loader would refuse")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("a failed save left a file behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateNamesEveryMissingField(t *testing.T) {
|
func TestValidateNamesEveryMissingField(t *testing.T) {
|
||||||
cases := map[string]func(*File){
|
cases := map[string]func(*File){
|
||||||
"nodeId": func(c *File) { c.NodeID = "" },
|
"nodeId": func(c *File) { c.NodeID = "" },
|
||||||
@@ -47,7 +100,7 @@ func TestValidateNamesEveryMissingField(t *testing.T) {
|
|||||||
for name, mutate := range cases {
|
for name, mutate := range cases {
|
||||||
cfg := valid()
|
cfg := valid()
|
||||||
mutate(&cfg)
|
mutate(&cfg)
|
||||||
if err := cfg.Validate(); err == nil {
|
if err := cfg.Validate(false); err == nil {
|
||||||
t.Errorf("%s: a broken config was accepted", name)
|
t.Errorf("%s: a broken config was accepted", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Package pair implements the agent's temporary management surface from design §7.
|
||||||
|
//
|
||||||
|
// It is the only place the agent binds a listening socket, and it exists only while the operator
|
||||||
|
// runs `msa-agent --pair`. That is the whole reason the design requires the window to be temporary:
|
||||||
|
// an admin port that can inject trusted devices and stays open indefinitely is a second hole beside
|
||||||
|
// the relay, reachable by anyone on the LAN without ever touching a phone. So the window is closed
|
||||||
|
// three ways: the first successful pairing shuts it, a failed-attempt ceiling shuts it, and a
|
||||||
|
// deadline shuts it even if nothing happens at all.
|
||||||
|
//
|
||||||
|
// Authentication is the node password, presented by the phone over the LAN. This works because of
|
||||||
|
// the one fact the pairing flow leans on: at pairing time the phone is necessarily already on the
|
||||||
|
// internal network, so the shared secret never has to traverse the relay.
|
||||||
|
package pair
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fun.chick/msa-agent/internal/config"
|
||||||
|
"fun.chick/msa-agent/internal/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits. The attempt ceiling is small on purpose: the pairing secret is a human password, and a
|
||||||
|
// window where anyone on the LAN can try it a thousand times is a window that leaks the node.
|
||||||
|
const (
|
||||||
|
MaxAttempts = 10
|
||||||
|
DefaultWindow = 10 * time.Minute
|
||||||
|
MaxRequestBytes = 8 << 10
|
||||||
|
DefaultAdminPort = 3001
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoSecret = errors.New("pair: the node is unprotected and no provisionSecret is configured, so pairing cannot be authorised")
|
||||||
|
ErrClosed = errors.New("pair: the pairing window is closed")
|
||||||
|
ErrRefused = errors.New("pair: too many failed pairing attempts")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves /provision and /destroy and nothing else.
|
||||||
|
type Handler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg config.File
|
||||||
|
cfgPath string
|
||||||
|
nodeID string
|
||||||
|
agentSign ed25519.PublicKey
|
||||||
|
agentKx []byte
|
||||||
|
secret string
|
||||||
|
attempts int
|
||||||
|
closed bool
|
||||||
|
// Done is closed once the window should end: a completed pairing, the attempt ceiling, or an
|
||||||
|
// explicit destroy. main.go selects on it to shut the listener and exit.
|
||||||
|
Done chan struct{}
|
||||||
|
|
||||||
|
log func(event string, fields map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the handler from the loaded config. SignKey/KxKey are the agent's private keys; the
|
||||||
|
// public halves are derived here so a phone can never be handed a key that disagrees with the
|
||||||
|
// private one the agent commands with.
|
||||||
|
func New(cfgPath string, cfg config.File, log func(string, map[string]any)) (*Handler, error) {
|
||||||
|
if len(cfg.SignKey) == 0 || len(cfg.KxKey) == 0 {
|
||||||
|
return nil, errors.New("pair: agent keys are missing from the config")
|
||||||
|
}
|
||||||
|
signRaw, err := base64.StdEncoding.Strict().DecodeString(cfg.SignKey)
|
||||||
|
if err != nil || len(signRaw) != ed25519.PrivateKeySize {
|
||||||
|
return nil, errors.New("pair: signKey must be a base64 ed25519 private key")
|
||||||
|
}
|
||||||
|
kxRaw, err := base64.StdEncoding.Strict().DecodeString(cfg.KxKey)
|
||||||
|
if err != nil || len(kxRaw) != 32 {
|
||||||
|
return nil, errors.New("pair: kxKey must be a base64 32-byte x25519 private key")
|
||||||
|
}
|
||||||
|
secret := cfg.NodePassword
|
||||||
|
if secret == "" {
|
||||||
|
secret = cfg.ProvisionSecret
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return nil, ErrNoSecret
|
||||||
|
}
|
||||||
|
h := &Handler{
|
||||||
|
cfg: cfg,
|
||||||
|
cfgPath: cfgPath,
|
||||||
|
nodeID: cfg.NodeID,
|
||||||
|
agentSign: ed25519.PrivateKey(signRaw).Public().(ed25519.PublicKey),
|
||||||
|
agentKx: kxRaw,
|
||||||
|
secret: secret,
|
||||||
|
Done: make(chan struct{}),
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
if h.log == nil {
|
||||||
|
h.log = func(string, map[string]any) {}
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes is the complete surface. Anything else is 404, and 404 carries no body that could hint at
|
||||||
|
// what does exist here.
|
||||||
|
func (h *Handler) Routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /provision", h.provision)
|
||||||
|
mux.HandleFunc("POST /destroy", h.destroy)
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
type provisionRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
AppSignPub string `json:"appSignPub"`
|
||||||
|
AppKxPub string `json:"appKxPub"`
|
||||||
|
DeviceName string `json:"deviceName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type provisionResponse struct {
|
||||||
|
NodeID string `json:"nodeID"`
|
||||||
|
AppID string `json:"appID"`
|
||||||
|
AgentSignPub string `json:"agentSignPub"`
|
||||||
|
AgentKxPub string `json:"agentKxPub"`
|
||||||
|
RelayURL string `json:"relayURL"`
|
||||||
|
DeviceToken string `json:"deviceToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ends the window: after a completed pairing or an explicit destroy, no further request is
|
||||||
|
// answered even one that races in before the listener stops.
|
||||||
|
func (h *Handler) Close() {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.closeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) closeLocked() {
|
||||||
|
if !h.closed {
|
||||||
|
h.closed = true
|
||||||
|
close(h.Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body provisionRequest
|
||||||
|
limited := http.MaxBytesReader(w, r.Body, MaxRequestBytes)
|
||||||
|
if err := json.NewDecoder(limited).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "the pairing request is not valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := decodeKey(body.AppSignPub, ed25519.PublicKeySize, "appSignPub"); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := decodeKey(body.AppKxPub, 32, "appKxPub"); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.closed {
|
||||||
|
writeError(w, http.StatusGone, "the pairing window is closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.authorize(body.Password) {
|
||||||
|
h.log("pair_attempt", map[string]any{"outcome": "bad_secret"})
|
||||||
|
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := config.NewDeviceID()
|
||||||
|
relayToken := config.NewRelayToken()
|
||||||
|
h.cfg.Devices = append(h.cfg.Devices, config.Device{
|
||||||
|
ID: deviceID,
|
||||||
|
SignKey: body.AppSignPub,
|
||||||
|
Name: body.DeviceName,
|
||||||
|
RelayToken: relayToken,
|
||||||
|
})
|
||||||
|
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()})
|
||||||
|
writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// One pairing closes the window (design §7 step 4: 配对成功后 agent 关闭管理监听). Adding a
|
||||||
|
// 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})
|
||||||
|
writeJSON(w, provisionResponse{
|
||||||
|
NodeID: h.nodeID,
|
||||||
|
AppID: deviceID,
|
||||||
|
AgentSignPub: base64.StdEncoding.EncodeToString(h.agentSign),
|
||||||
|
AgentKxPub: base64.StdEncoding.EncodeToString(mustPublic(h.agentKx)),
|
||||||
|
RelayURL: h.cfg.RelayURL,
|
||||||
|
DeviceToken: relayToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
limited := http.MaxBytesReader(w, r.Body, MaxRequestBytes)
|
||||||
|
if err := json.NewDecoder(limited).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "the destroy request is not valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.closed {
|
||||||
|
writeError(w, http.StatusGone, "the pairing window is closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.authorize(body.Password) {
|
||||||
|
h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy"})
|
||||||
|
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cfg.Devices = nil
|
||||||
|
if err := config.Save(h.cfgPath, h.cfg); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "the agent could not persist the destroy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.closeLocked()
|
||||||
|
h.log("pair_destroy", map[string]any{"outcome": "ok"})
|
||||||
|
writeJSON(w, map[string]any{"destroyed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorize burns an attempt slot on every failure and closes the window at the ceiling. The
|
||||||
|
// compare is constant time; the counter is what stops the window from being a password oracle.
|
||||||
|
func (h *Handler) authorize(presented string) bool {
|
||||||
|
match := subtle.ConstantTimeCompare([]byte(presented), []byte(h.secret)) == 1
|
||||||
|
if !match {
|
||||||
|
h.attempts++
|
||||||
|
if h.attempts >= MaxAttempts {
|
||||||
|
h.log("pair_attempt", map[string]any{"outcome": "too_many"})
|
||||||
|
h.closeLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKey(value string, size int, field string) ([]byte, error) {
|
||||||
|
raw, err := base64.StdEncoding.Strict().DecodeString(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not valid base64", field)
|
||||||
|
}
|
||||||
|
if len(raw) != size {
|
||||||
|
return nil, fmt.Errorf("%s must be %d bytes", field, size)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustPublic(private []byte) []byte {
|
||||||
|
// Derive, never store: a published kx key that disagreed with the agent's private key would
|
||||||
|
// fail as an unreadable request with no useful cause.
|
||||||
|
key, err := wire.PublicFrom(private)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("pair: agent kx key is unusable: %v", err))
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, detail string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"error": detail})
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package pair
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fun.chick/msa-agent/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func agentConfig(t *testing.T) (string, config.File, ed25519.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
_, sign, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kx := make([]byte, 32)
|
||||||
|
rand.Read(kx)
|
||||||
|
cfg := config.File{
|
||||||
|
NodeID: "nd_7f3a9c2e",
|
||||||
|
RelayURL: "https://relay.example",
|
||||||
|
AgentToken: "agent-token",
|
||||||
|
LocalPort: 8788,
|
||||||
|
SignKey: base64.StdEncoding.EncodeToString(sign),
|
||||||
|
KxKey: base64.StdEncoding.EncodeToString(kx),
|
||||||
|
AuditPath: filepath.Join(t.TempDir(), "audit.log"),
|
||||||
|
NodePassword: "hunter2",
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
if err := config.Save(path, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path, cfg, sign
|
||||||
|
}
|
||||||
|
|
||||||
|
func phoneKeys(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kx := make([]byte, 32)
|
||||||
|
rand.Read(kx)
|
||||||
|
return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(kx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func provisionBody(t *testing.T, password, signPub, kxPub string) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"password": password, "appSignPub": signPub, "appKxPub": kxPub, "deviceName": "chick",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return bytes.NewBuffer(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openHandler(t *testing.T, path string, cfg config.File) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
h, err := New(path, cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionPairsThePhoneAndClosesTheWindow(t *testing.T) {
|
||||||
|
path, cfg, sign := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "hunter2", signPub, kxPub)))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("provision status %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var out provisionResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.NodeID != "nd_7f3a9c2e" || !strings.HasPrefix(out.AppID, "ap_") || out.DeviceToken == "" {
|
||||||
|
t.Fatalf("response: %+v", out)
|
||||||
|
}
|
||||||
|
if len(out.AgentSignPub) != base64.StdEncoding.EncodedLen(ed25519.PublicKeySize) {
|
||||||
|
t.Fatalf("agent signing key not published: %q", out.AgentSignPub)
|
||||||
|
}
|
||||||
|
// The published key must verify what the agent's private key signs: the pairing response is
|
||||||
|
// the phone's only source for the trust anchor.
|
||||||
|
proof := ed25519.Sign(sign, []byte("x"))
|
||||||
|
gotPub, err := base64.StdEncoding.DecodeString(out.AgentSignPub)
|
||||||
|
if err != nil || !ed25519.Verify(gotPub, []byte("x"), proof) {
|
||||||
|
t.Fatal("published signing key does not match the private key")
|
||||||
|
}
|
||||||
|
// The config on disk carries the device, with a relay token generated by the agent.
|
||||||
|
reloaded, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config after pairing must be loadable: %v", err)
|
||||||
|
}
|
||||||
|
if len(reloaded.Devices) != 1 || reloaded.Devices[0].ID != out.AppID ||
|
||||||
|
reloaded.Devices[0].SignKey != signPub || reloaded.Devices[0].RelayToken != out.DeviceToken {
|
||||||
|
t.Fatalf("persisted devices: %+v", reloaded.Devices)
|
||||||
|
}
|
||||||
|
// The window closed with the pairing: a second phone is refused.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "hunter2", signPub, kxPub)))
|
||||||
|
if rec.Code != http.StatusGone {
|
||||||
|
t.Fatalf("second pairing got %d, want 410", rec.Code)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-h.Done:
|
||||||
|
default:
|
||||||
|
t.Fatal("Done was not closed after a completed pairing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWrongSecretIsRefusedAndNothingIsPersisted(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "wrong", signPub, kxPub)))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
// LoadForPairing, not Load: this config legitimately has no devices yet, which is exactly the
|
||||||
|
// state that must survive a refused attempt.
|
||||||
|
reloaded, err := config.LoadForPairing(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(reloaded.Devices) != 0 {
|
||||||
|
t.Fatal("a refused pairing was still persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pairing secret is a human password and the window is LAN-reachable, so the attempt ceiling is
|
||||||
|
// the difference between a management port and a password oracle.
|
||||||
|
func TestTheAttemptCeilingShutsTheWindow(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
for i := 0; i < MaxAttempts; i++ {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "guess", signPub, kxPub)))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d: status %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "hunter2", signPub, kxPub)))
|
||||||
|
if rec.Code != http.StatusGone {
|
||||||
|
t.Fatalf("after the ceiling, a correct password got %d, want 410", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadKeyMaterialIsRefused(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
_, kxPub := phoneKeys(t)
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"password":"hunter2","appSignPub":"not base64!!","appKxPub":"` + kxPub + `"}`,
|
||||||
|
// 31 bytes decodes fine but is the wrong size; the strict length check is the point.
|
||||||
|
`{"password":"hunter2","appSignPub":"` + base64.StdEncoding.EncodeToString(make([]byte, 31)) + `","appKxPub":"` + kxPub + `"}`,
|
||||||
|
`{"password":"hunter2","appSignPub":"` + strings.Repeat("A", 44) + `","appKxPub":"` + base64.StdEncoding.EncodeToString(make([]byte, 1)) + `"}`,
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", strings.NewReader(body)))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("accepted key material %q with %d", body, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestroyClearsDevicesAndCloses(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "hunter2", signPub, kxPub)))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatal(rec.Body.String())
|
||||||
|
}
|
||||||
|
// Destroy after close (pairing closed it) is Gone: the one-window-one-action rule.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/destroy",
|
||||||
|
bytes.NewBufferString(`{"password":"hunter2"}`)))
|
||||||
|
if rec.Code != http.StatusGone {
|
||||||
|
t.Fatalf("destroy after pairing got %d, want 410", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh window can destroy an existing device list.
|
||||||
|
h2 := openHandler(t, path, cfg)
|
||||||
|
cfg2 := config.File{}
|
||||||
|
raw, _ := os.ReadFile(path)
|
||||||
|
json.Unmarshal(raw, &cfg2)
|
||||||
|
h2 = openHandler(t, path, cfg2)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h2.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/destroy",
|
||||||
|
bytes.NewBufferString(`{"password":"hunter2"}`)))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("destroy got %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
reloaded, err := config.LoadForPairing(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(reloaded.Devices) != 0 {
|
||||||
|
t.Fatal("destroy left devices behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnprotectedNodeNeedsAnExplicitSecret(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
cfg.NodePassword = ""
|
||||||
|
if _, err := New(path, cfg, nil); err != ErrNoSecret {
|
||||||
|
t.Fatalf("got %v, want ErrNoSecret", err)
|
||||||
|
}
|
||||||
|
cfg.ProvisionSecret = "pair-only"
|
||||||
|
h, err := New(path, cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit secret was refused: %v", err)
|
||||||
|
}
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision",
|
||||||
|
provisionBody(t, "pair-only", signPub, kxPub)))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownRoutesDoNotDescribeTheSurface(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("GET", "/provision", nil))
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET /provision got %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/admin/list-devices", nil))
|
||||||
|
if rec.Code != http.StatusNotFound || len(rec.Body.Bytes()) != 0 {
|
||||||
|
t.Fatalf("unknown route leaked: %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve enforces the wall-clock deadline even if nobody ever pairs: a management socket that
|
||||||
|
// outlives the operator's session is the second hole the design forbids.
|
||||||
|
func TestServeDeadlineClosesTheWindow(t *testing.T) {
|
||||||
|
path, cfg, _ := agentConfig(t)
|
||||||
|
h := openHandler(t, path, cfg)
|
||||||
|
server := httptest.NewServer(h.Routes())
|
||||||
|
defer server.Close()
|
||||||
|
deadline := time.NewTimer(50 * time.Millisecond)
|
||||||
|
<-deadline.C
|
||||||
|
h.Close()
|
||||||
|
signPub, kxPub := phoneKeys(t)
|
||||||
|
response, err := http.Post(server.URL+"/provision", "application/json",
|
||||||
|
provisionBody(t, "hunter2", signPub, kxPub))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusGone {
|
||||||
|
t.Fatalf("status after the deadline: %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ import (
|
|||||||
|
|
||||||
// Paths on the relay. Versioned, because an agent and a relay will not be upgraded on the same day.
|
// Paths on the relay. Versioned, because an agent and a relay will not be upgraded on the same day.
|
||||||
const (
|
const (
|
||||||
PathHello = "/v1/agent/hello"
|
PathHello = "/v1/agent/hello"
|
||||||
PathPoll = "/v1/agent/poll"
|
PathPoll = "/v1/agent/poll"
|
||||||
PathResult = "/v1/agent/result"
|
PathResult = "/v1/agent/result"
|
||||||
|
PathDevices = "/v1/agent/devices"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -196,6 +197,25 @@ func (c *Client) Result(ctx context.Context, envelope json.RawMessage) error {
|
|||||||
|
|
||||||
func (c *Client) session() string { return c.token }
|
func (c *Client) session() string { return c.token }
|
||||||
|
|
||||||
|
// DeviceRegistration is one paired phone as the relay needs to know it: the bearer token the phone
|
||||||
|
// presents, minted by the agent at pairing time and never seen by the relay before that moment.
|
||||||
|
type DeviceRegistration struct {
|
||||||
|
App string `json:"app"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushDevices reports the node's complete paired-device set after the handshake.
|
||||||
|
//
|
||||||
|
// Full set, not a delta: the relay replaces everything it holds for this node, which is what makes
|
||||||
|
// revocation work without a second channel. Destroying the device list (or re-pairing) and letting
|
||||||
|
// the next hello carry the smaller set removes the revoked bearer from the relay's routing, and an
|
||||||
|
// agent that never reconnects cannot revoke anything but is also unable to command itself.
|
||||||
|
func (c *Client) PushDevices(ctx context.Context, devices []DeviceRegistration) error {
|
||||||
|
return c.post(ctx, PathDevices, map[string]any{
|
||||||
|
"node": c.nodeID, "devices": devices,
|
||||||
|
}, &json.RawMessage{}, c.session(), false)
|
||||||
|
}
|
||||||
|
|
||||||
// post is the single place a relay request is made, so authentication, size limits, status mapping
|
// post is the single place a relay request is made, so authentication, size limits, status mapping
|
||||||
// and body draining cannot drift between the three endpoints.
|
// and body draining cannot drift between the three endpoints.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -205,6 +205,43 @@ func TestResultPostsTheEnvelopeVerbatim(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The device registration posts the node's authoritative paired set under the session, which is
|
||||||
|
// what lets the relay learn about freshly paired phones. Empty is posted too: "nobody" is a
|
||||||
|
// revocation claim, not a no-op.
|
||||||
|
func TestPushDevicesPostsTheFullSetUnderTheSession(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != PathDevices {
|
||||||
|
t.Fatalf("posted to %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-MSA-Session") == "" {
|
||||||
|
t.Error("the device push must carry the session")
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}), "t")
|
||||||
|
err := client.PushDevices(context.Background(), []DeviceRegistration{
|
||||||
|
{App: "ap_5b1d0000", Token: "tok-a"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["node"] != "nd_test0001" {
|
||||||
|
t.Fatalf("node is %v", got["node"])
|
||||||
|
}
|
||||||
|
devices, _ := got["devices"].([]any)
|
||||||
|
if len(devices) != 1 {
|
||||||
|
t.Fatalf("devices: %v", got["devices"])
|
||||||
|
}
|
||||||
|
// An empty list is a real claim, not a skipped call.
|
||||||
|
client2, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}), "t")
|
||||||
|
if err := client2.PushDevices(context.Background(), []DeviceRegistration{}); err != nil {
|
||||||
|
t.Fatalf("empty push: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRetryAfterHasASaneDefault(t *testing.T) {
|
func TestRetryAfterHasASaneDefault(t *testing.T) {
|
||||||
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
Reference in New Issue
Block a user