package main import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" ) // AgentConfig holds user-editable settings for the agent. type AgentConfig struct { Server string `json:"server"` HeadscaleURL string `json:"headscale_url"` HeadscaleAuthKey string `json:"headscale_auth_key"` NodeID string `json:"node_id"` DataDir string `json:"data_dir"` // ProxyURL is an optional HTTP(S) proxy used for all outbound agent traffic // (WebSocket, update checks, downloads). ProxyURL string `json:"proxy_url,omitempty"` // ProxyMode controls how the proxy is used: // - "disabled" : never use the proxy. // - "auto" : the agent tries direct connections first and falls back to // the proxy after a few failures (useful when moving between // home network and school network). // - "enabled" : always use the proxy. ProxyMode string `json:"proxy_mode,omitempty"` } const configFileName = "studioE5-config.json" // defaultServerURL is the production WebSocket endpoint baked into the agent. // It can be overridden by the config file for self-hosted or test setups. const defaultServerURL = "wss://studioe5.edudeploy.com/api/websocket" // uniqueNodeID returns a stable-ish unique identifier for this machine. // It combines the hostname with a short random suffix so every install is distinct. func uniqueNodeID() string { h, err := os.Hostname() if err != nil || h == "" { h = "node" } b := make([]byte, 4) if _, err := rand.Read(b); err == nil { return fmt.Sprintf("%s-%s", h, hex.EncodeToString(b)) } return fmt.Sprintf("%s-%d", h, os.Getpid()) } // defaultConfig returns sensible defaults for a first run. // The user only needs to provide an activation code; Headscale credentials are // delivered by the server during activation. func defaultConfig(dataDir string) *AgentConfig { return &AgentConfig{ Server: defaultServerURL, HeadscaleURL: "", HeadscaleAuthKey: "", NodeID: uniqueNodeID(), DataDir: dataDir, } } // mergeWithDefaults fills missing fields from disk with sensible defaults. func mergeWithDefaults(cfg *AgentConfig, dataDir string) *AgentConfig { defaults := defaultConfig(dataDir) if cfg == nil { return defaults } if cfg.Server == "" { cfg.Server = defaults.Server } if cfg.NodeID == "" { cfg.NodeID = defaults.NodeID } if cfg.DataDir == "" { cfg.DataDir = defaults.DataDir } return cfg } // configPath returns the absolute path to the config file. func configPath(dataDir string) string { return filepath.Join(dataDir, configFileName) } // loadOrCreateConfig loads the config file. If it does not exist, it creates // one with default values and returns it (the caller can then open the settings UI). func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) { cp := configPath(dataDir) if _, err := os.Stat(cp); err == nil { data, err := os.ReadFile(cp) if err != nil { return nil, false, err } cfg := &AgentConfig{} if err := json.Unmarshal(data, cfg); err != nil { return nil, false, err } cfg = mergeWithDefaults(cfg, dataDir) return cfg, false, nil } cfg := defaultConfig(dataDir) if err := saveConfig(dataDir, cfg); err != nil { return nil, true, err } return cfg, true, nil } // saveConfig writes the config file to disk. func saveConfig(dataDir string, cfg *AgentConfig) error { cp := configPath(dataDir) if err := os.MkdirAll(dataDir, 0755); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(cp, data, 0600) }