metis/pkg/secrets/vault.go

128 lines
3.4 KiB
Go
Raw Normal View History

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 {
SSHPassword string `json:"ssh_password,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/<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
}