2026-03-31 14:52:50 -03:00
|
|
|
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/<hostname>.
|
|
|
|
|
type NodeSecrets struct {
|
2026-04-24 16:57:34 -03:00
|
|
|
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"`
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 00:17:10 -03:00
|
|
|
// 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.
|
2026-03-31 14:52:50 -03:00
|
|
|
// FetchNode pulls secret/data/nodes/<hostname>.
|
|
|
|
|
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
|
|
|
|
|
}
|