package secrets import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) // NodeSecrets holds per-node secret material to inject at burn time. // These should live in Vault at secret/data/nodes/. type NodeSecrets struct { SSHPassword string `json:"ssh_password,omitempty"` SSHPasswordHash string `json:"ssh_password_hash,omitempty"` AtlasPassword string `json:"atlas_password,omitempty"` AtlasPasswordHash string `json:"atlas_password_hash,omitempty"` RootPassword string `json:"root_password,omitempty"` RootPasswordHash string `json:"root_password_hash,omitempty"` K3sToken string `json:"k3s_token,omitempty"` CloudInit string `json:"cloud_init,omitempty"` Extra map[string]string `json:"extra,omitempty"` } // Client fetches node secrets from Vault using either a token or AppRole. type Client struct { Addr string Token string RoleID string SecretID string Client *http.Client } // NewFromEnv builds a client from VAULT_ADDR, VAULT_TOKEN, VAULT_ROLE_ID, VAULT_SECRET_ID. func NewFromEnv() *Client { return &Client{ Addr: os.Getenv("VAULT_ADDR"), Token: os.Getenv("VAULT_TOKEN"), RoleID: os.Getenv("VAULT_ROLE_ID"), SecretID: os.Getenv("VAULT_SECRET_ID"), Client: &http.Client{ Timeout: 10 * time.Second, }, } } // LoginIfNeeded performs AppRole login if no token is present. func (c *Client) LoginIfNeeded(ctx context.Context) error { if c.Token != "" || c.RoleID == "" || c.SecretID == "" { return nil } body := map[string]string{"role_id": c.RoleID, "secret_id": c.SecretID} var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/v1/auth/approle/login", strings.TrimSuffix(c.Addr, "/")), &buf) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("approle login failed: %s", resp.Status) } var r struct { Auth struct { ClientToken string `json:"client_token"` } `json:"auth"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return err } if r.Auth.ClientToken == "" { return fmt.Errorf("approle login returned empty token") } c.Token = r.Auth.ClientToken return nil } // FetchNode loads per-node secret material because burn-time injection needs // a single read path that can fall back to empty secrets when Vault has no row. // FetchNode pulls secret/data/nodes/. func (c *Client) FetchNode(ctx context.Context, hostname string) (*NodeSecrets, error) { if err := c.LoginIfNeeded(ctx); err != nil { return nil, err } url := fmt.Sprintf("%s/v1/secret/data/nodes/%s", strings.TrimSuffix(c.Addr, "/"), hostname) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } if c.Token != "" { req.Header.Set("X-Vault-Token", c.Token) } resp, err := c.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return &NodeSecrets{}, nil } if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("vault fetch %s: %s: %s", hostname, resp.Status, string(b)) } var r struct { Data struct { Data NodeSecrets `json:"data"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, err } return &r.Data.Data, nil } func (c *Client) httpClient() *http.Client { if c.Client != nil { return c.Client } return http.DefaultClient }