package websocket import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "runtime" "strings" "time" "github.com/fosrl/newt/logger" ) // getConfigPath returns the resolved config/credentials file path. Path // resolution (CLI flag / env var / OS default) is now owned by the calling // binary's root config loader; this is a fallback used only when a caller // (e.g. a test) doesn't supply an explicit path via WithConfigFile. func getConfigPath(clientType string, overridePath string) string { if overridePath != "" { return overridePath } configFile := os.Getenv("CONFIG_FILE") if configFile == "" { var configDir string switch runtime.GOOS { case "darwin": configDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", clientType+"-client") case "windows": logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "olm") configDir = filepath.Join(logDir, clientType+"-client") default: // linux and others configDir = filepath.Join(os.Getenv("HOME"), ".config", clientType+"-client") } if err := os.MkdirAll(configDir, 0755); err != nil { logger.Debug("Failed to create config directory: %v", err) } return filepath.Join(configDir, "config.json") } return configFile } // loadConfig no longer merges credentials in from the file: the caller // resolves ID/Secret/Endpoint/TlsClientCert/ProvisioningKey/Name (from its // own settings file, env, and CLI flags) before constructing the Client. This // only figures out whether the file needs to be (re)written so that the // caller-provided values get cached to disk on first successful connect. func (c *Client) loadConfig() error { configPath := getConfigPath(c.clientType, c.configFilePath) data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { logger.Info("Config file does not exist at %s, will create it with provided values", configPath) c.configNeedsSave = true return nil } return err } if len(bytes.TrimSpace(data)) == 0 { logger.Info("Config file at %s is empty, will initialize it with provided values", configPath) c.configNeedsSave = true } return nil } // saveConfig persists the credential fields (id, secret, endpoint, // tlsClientCert, provisioningKey, name) into the config file. It's a // read-modify-write against the raw JSON so that any other settings a user // has placed in the same file (mtu, dns, etc.) are preserved rather than // clobbered. func (c *Client) saveConfig() error { if !c.configNeedsSave { logger.Debug("Config has not changed, skipping save") return nil } configPath := getConfigPath(c.clientType, c.configFilePath) existing := map[string]interface{}{} if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 { if err := json.Unmarshal(data, &existing); err != nil { logger.Warn("Existing config file at %s is not valid JSON, other settings in it will be lost: %v", configPath, err) existing = map[string]interface{}{} } } existing["id"] = c.config.ID existing["secret"] = c.config.Secret existing["endpoint"] = c.config.Endpoint setOrDelete := func(key, value string) { if value != "" { existing[key] = value } else { delete(existing, key) } } setOrDelete("tlsClientCert", c.config.TlsClientCert) setOrDelete("provisioningKey", c.config.ProvisioningKey) setOrDelete("name", c.config.Name) data, err := json.MarshalIndent(existing, "", " ") if err != nil { return err } logger.Info("Saving config to: %s", configPath) err = os.WriteFile(configPath, data, 0644) if err == nil { c.configNeedsSave = false // Reset flag after successful save } return err } // interpolateString replaces {{env.VAR}} tokens in s with the corresponding // environment variable values. Tokens that do not match a supported scheme are // left unchanged, mirroring the blueprint interpolation logic. func interpolateString(s string) string { re := regexp.MustCompile(`\{\{([^}]+)\}\}`) return re.ReplaceAllStringFunc(s, func(match string) string { inner := strings.TrimSpace(match[2 : len(match)-2]) if strings.HasPrefix(inner, "env.") { varName := strings.TrimPrefix(inner, "env.") return os.Getenv(varName) } return match }) } // EnsureProvisioned exchanges a provisioning key for permanent credentials // synchronously, if one is configured and credentials aren't already present. // Callers that need the resolved ID/Secret before Connect() runs (which only // provisions lazily, in the background, on first connection attempt) should // call this first. func (c *Client) EnsureProvisioned() error { return c.provisionIfNeeded() } // provisionIfNeeded checks whether a provisioning key is present and, if so, // exchanges it for a newt ID and secret by calling the registration endpoint. // On success the config is updated in-place and flagged for saving so that // subsequent runs use the permanent credentials directly. func (c *Client) provisionIfNeeded() error { if c.config.ProvisioningKey == "" { return nil } // If we already have both credentials there is nothing to provision. if c.config.ID != "" && c.config.Secret != "" { logger.Debug("Credentials already present, skipping provisioning") return nil } logger.Info("Provisioning key found – exchanging for newt credentials...") baseURL, err := url.Parse(c.baseURL) if err != nil { return fmt.Errorf("failed to parse base URL for provisioning: %w", err) } baseEndpoint := strings.TrimRight(baseURL.String(), "/") // Interpolate any {{env.VAR}} tokens in the name before sending. name := interpolateString(c.config.Name) reqBody := map[string]interface{}{ "provisioningKey": c.config.ProvisioningKey, } if name != "" { reqBody["name"] = name } jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal provisioning request: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext( ctx, "POST", baseEndpoint+"/api/v1/auth/newt/register", bytes.NewBuffer(jsonData), ) if err != nil { return fmt.Errorf("failed to create provisioning request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-CSRF-Token", "x-csrf-protection") // Mirror the TLS setup used by getToken so mTLS / self-signed CAs work. var tlsCfg *tls.Config if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" { tlsCfg, err = c.setupTLS() if err != nil { return fmt.Errorf("failed to setup TLS for provisioning: %w", err) } } if os.Getenv("SKIP_TLS_VERIFY") == "true" { if tlsCfg == nil { tlsCfg = &tls.Config{} } tlsCfg.InsecureSkipVerify = true logger.Debug("TLS certificate verification disabled for provisioning via SKIP_TLS_VERIFY") } httpClient := &http.Client{} if tlsCfg != nil { httpClient.Transport = &http.Transport{TLSClientConfig: tlsCfg} } resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("provisioning request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) logger.Debug("Provisioning response body: %s", string(body)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("provisioning endpoint returned status %d: %s", resp.StatusCode, string(body)) } var provResp ProvisioningResponse if err := json.Unmarshal(body, &provResp); err != nil { return fmt.Errorf("failed to decode provisioning response: %w", err) } if !provResp.Success { return fmt.Errorf("provisioning failed: %s", provResp.Message) } if provResp.Data.NewtID == "" || provResp.Data.Secret == "" { return fmt.Errorf("provisioning response is missing newt ID or secret") } logger.Info("Successfully provisioned – newt ID: %s", provResp.Data.NewtID) // Persist the returned credentials and clear the one-time provisioning key // so subsequent runs authenticate normally. c.config.ID = provResp.Data.NewtID c.config.Secret = provResp.Data.Secret c.config.ProvisioningKey = "" c.config.Name = "" c.configNeedsSave = true c.justProvisioned = true // Save immediately so that if the subsequent connection attempt fails the // provisioning key is already gone from disk and the next retry uses the // permanent credentials instead of trying to provision again. if err := c.saveConfig(); err != nil { logger.Error("Failed to save config after provisioning: %v", err) } return nil }