// 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" "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"` } // 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 []Device `json:"devices"` } // Load reads and validates a config file. // // Validation is exhaustive rather than partial on purpose: an agent that starts with a missing // device or a bad port would sit there healthy and uncommandable, which is harder to notice than a // crash at boot. 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. func (c File) Validate() 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 { 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 }