// 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" "syscall" ) 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"` // RelayCAFile is a PEM bundle to trust instead of the system store when verifying the relay's // certificate. It exists for a relay behind a private CA; the default is the system store, which // is what a public certificate needs. Setting it pins trust rather than disabling verification. RelayCAFile string `json:"relayCAFile,omitempty"` // SelfEnrolled records that this node registered itself with the relay using a one-time code, // rather than being written into the relay's agents.json by an operator. // // It exists so `uninstall` can tell the operator which of two removals applies, because the agent // cannot do either: it holds no admin token, by design — a node is not an operator, and giving it // one would let a compromised node remove its neighbours. So the node records which half of the // relay's state mentions it, and the operator is told exactly what to delete. SelfEnrolled bool `json:"selfEnrolled,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, preserving the file's owner. // // 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. // // Ownership is preserved because the deployment document tells the operator to run `--pair` with // sudo, and the installed config belongs to the `msa-agent` user. Writing a temp file as root and // renaming it over the config would leave it root-owned 0600, and the systemd unit — which runs as // `msa-agent` — could then no longer read it: the pairing would appear to succeed and the node would // come up unable to load its own configuration. The chown is only attempted when the process can // perform it (root), and a failure is an error rather than a warning, because silently leaving the // file unreadable by the service is the failure this is here to prevent. 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') uid, gid, hadOwner := ownerOf(path) temp := path + ".tmp" if err := os.WriteFile(temp, encoded, 0o600); err != nil { return err } if hadOwner && os.Geteuid() == 0 { if err := os.Chown(temp, uid, gid); err != nil { // The temp file is removed so a failed save leaves no stray copy of the config — it // holds the agent's private keys and the node password. os.Remove(temp) return fmt.Errorf("config: preserving ownership of %s: %w", path, err) } } return os.Rename(temp, path) } // ownerOf reports the uid and gid a rewrite of path must keep. ok is false when the file does not // exist yet, which is the first-write case: there is no owner to preserve, and the file is created // by whoever is running. func ownerOf(path string) (uid, gid int, ok bool) { info, err := os.Stat(path) if err != nil { return 0, 0, false } stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return 0, 0, false } return int(stat.Uid), int(stat.Gid), true } // 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 }