// Package config loads the agent's startup file. // // The shape is deliberately small and has no host field. The local SimAdmin is reached by port // number alone: a config entry naming a host would be the one thing standing between a mistyped // value and an agent that proxies commands to somebody else's machine. package config import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "regexp" ) var nodePattern = regexp.MustCompile(`^nd_[A-Za-z0-9_-]{8,64}$`) var appPattern = regexp.MustCompile(`^ap_[A-Za-z0-9_-]{8,64}$`) // Device is a paired phone. type Device struct { ID string `json:"id"` // SignKey is the phone's Ed25519 public key, base64. The agent verifies every request against // it and holds no corresponding secret, so a phone cannot be impersonated from the relay side. SignKey string `json:"signKey"` 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. type File struct { NodeID string `json:"nodeId"` // RelayURL is https and is the only outbound destination. RelayURL string `json:"relayUrl"` // AgentToken is the relay bearer credential. It authorises nothing on its own; the challenge // signature does the identifying. AgentToken string `json:"agentToken"` // LocalPort is the SimAdmin port on 127.0.0.1. There is deliberately no host field. LocalPort int `json:"localPort"` // SignKey is the agent's Ed25519 private key, base64. SignKey string `json:"signKey"` // KxKey is the agent's X25519 private key, base64. KxKey string `json:"kxKey"` // NodePassword is the local SimAdmin password the agent logs in with so the phone never // handles or transmits it. Empty declares the node unprotected; a protected node then refuses // commands with node_auth_failed rather than looping against a lockout. NodePassword string `json:"nodePassword,omitempty"` // AuditPath is where the audit log is appended. AuditPath string `json:"auditPath"` // 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"` // 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 // defaults to NodePassword; an unprotected node needs an explicit value here or pairing is // refused. It is secret material and lives in the config file like the private keys do. ProvisionSecret string `json:"provisionSecret,omitempty"` } // Validate checks every field the agent depends on. // // 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) { return errors.New("nodeId must look like nd_xxxxxxxx") } if c.RelayURL == "" { return errors.New("relayUrl is required") } if c.AgentToken == "" { return errors.New("agentToken is required") } if c.LocalPort <= 0 || c.LocalPort > 65535 { return fmt.Errorf("localPort %d is out of range", c.LocalPort) } if c.SignKey == "" { return errors.New("signKey is required") } if c.KxKey == "" { return errors.New("kxKey is required") } if c.AuditPath == "" { return errors.New("auditPath is required") } if len(c.Devices) == 0 && !pairMode { return errors.New("devices is empty, so no phone could ever be authorised") } seen := map[string]bool{} for _, device := range c.Devices { if !appPattern.MatchString(device.ID) { return fmt.Errorf("device id %q must look like ap_xxxxxxxx", device.ID) } if device.SignKey == "" { return fmt.Errorf("device %q has no signKey", device.ID) } if seen[device.ID] { return fmt.Errorf("device %q is listed twice", device.ID) } seen[device.ID] = true } 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 }