291 lines
8.7 KiB
Go
291 lines
8.7 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/heicode/manager/common"
|
|
)
|
|
|
|
type secretStoreClient struct {
|
|
vaultURL string
|
|
vaultHost string
|
|
tokenEndpoint string
|
|
clientID string
|
|
client *http.Client
|
|
}
|
|
|
|
func newSecretStoreClientFromEnv() (secretStoreClient, error) {
|
|
vaultURL := strings.TrimRight(strings.TrimSpace(os.Getenv("AZURE_KEY_VAULT_URL")), "/")
|
|
if vaultURL == "" {
|
|
return secretStoreClient{}, errors.New("AZURE_KEY_VAULT_URL is not configured")
|
|
}
|
|
parsed, err := url.Parse(vaultURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return secretStoreClient{}, errors.New("AZURE_KEY_VAULT_URL must be an absolute URL")
|
|
}
|
|
if parsed.Scheme != "https" && !strings.HasPrefix(parsed.Host, "127.0.0.1") && !strings.HasPrefix(parsed.Host, "localhost") {
|
|
return secretStoreClient{}, errors.New("AZURE_KEY_VAULT_URL must use https")
|
|
}
|
|
tokenEndpoint := strings.TrimSpace(os.Getenv("AZURE_MANAGED_IDENTITY_TOKEN_URL"))
|
|
if tokenEndpoint == "" {
|
|
tokenEndpoint = "http://169.254.169.254/metadata/identity/oauth2/token"
|
|
}
|
|
return secretStoreClient{
|
|
vaultURL: vaultURL,
|
|
vaultHost: parsed.Host,
|
|
tokenEndpoint: tokenEndpoint,
|
|
clientID: strings.TrimSpace(os.Getenv("AZURE_CLIENT_ID")),
|
|
client: &http.Client{Timeout: 10 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
func (s secretStoreClient) putSecret(name string, data map[string]any) (string, error) {
|
|
if len(data) == 0 {
|
|
return "", errors.New("secret data required")
|
|
}
|
|
value, err := common.Marshal(data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body, err := common.Marshal(map[string]any{
|
|
"value": string(value),
|
|
"contentType": "application/json",
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token, err := s.accessToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
endpoint := fmt.Sprintf("%s/secrets/%s?api-version=7.4", s.vaultURL, url.PathEscape(name))
|
|
req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
message := readSecretStoreError(resp.Body)
|
|
if message == "" {
|
|
message = resp.Status
|
|
}
|
|
return "", fmt.Errorf("Azure Key Vault secret write failed: %s", message)
|
|
}
|
|
return s.secretRef(name), nil
|
|
}
|
|
|
|
func (s secretStoreClient) secretRef(name string) string {
|
|
return fmt.Sprintf("azkv://%s/secrets/%s", s.vaultHost, name)
|
|
}
|
|
|
|
func (s secretStoreClient) getJSONSecret(secretRef string) (map[string]any, error) {
|
|
name, err := s.secretNameFromRef(secretRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := s.accessToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint := fmt.Sprintf("%s/secrets/%s?api-version=7.4", s.vaultURL, url.PathEscape(name))
|
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
message := readSecretStoreError(resp.Body)
|
|
if message == "" {
|
|
message = resp.Status
|
|
}
|
|
return nil, fmt.Errorf("Azure Key Vault secret read failed: %s", message)
|
|
}
|
|
var payload struct {
|
|
Value string `json:"value"`
|
|
}
|
|
if err := common.DecodeJson(resp.Body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(payload.Value) == "" {
|
|
return nil, errors.New("Azure Key Vault secret value is empty")
|
|
}
|
|
var data map[string]any
|
|
if err := common.UnmarshalJsonStr(payload.Value, &data); err != nil {
|
|
return nil, errors.New("Azure Key Vault secret value must be JSON")
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, errors.New("Azure Key Vault secret value is empty")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (s secretStoreClient) secretNameFromRef(secretRef string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(secretRef))
|
|
if err != nil || parsed.Scheme != "azkv" || parsed.Host == "" {
|
|
return "", errors.New("secret_ref must use azkv://<vault>/secrets/<name>")
|
|
}
|
|
if !strings.EqualFold(parsed.Host, s.vaultHost) {
|
|
return "", errors.New("secret_ref vault does not match configured Azure Key Vault")
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
if len(parts) < 2 || parts[0] != "secrets" || strings.TrimSpace(parts[1]) == "" {
|
|
return "", errors.New("secret_ref must use azkv://<vault>/secrets/<name>")
|
|
}
|
|
return parts[1], nil
|
|
}
|
|
|
|
func (s secretStoreClient) accessToken() (string, error) {
|
|
u, err := url.Parse(s.tokenEndpoint)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid Azure managed identity token endpoint: %w", err)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("api-version") == "" {
|
|
q.Set("api-version", "2018-02-01")
|
|
}
|
|
if q.Get("resource") == "" {
|
|
q.Set("resource", "https://vault.azure.net")
|
|
}
|
|
if s.clientID != "" && q.Get("client_id") == "" {
|
|
q.Set("client_id", s.clientID)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Metadata", "true")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("Azure managed identity token request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
message := readSecretStoreError(resp.Body)
|
|
if message == "" {
|
|
message = resp.Status
|
|
}
|
|
return "", fmt.Errorf("Azure managed identity token request failed: %s", message)
|
|
}
|
|
var payload struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if err := common.DecodeJson(resp.Body, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(payload.AccessToken) == "" {
|
|
return "", errors.New("Azure managed identity token response missing access_token")
|
|
}
|
|
return payload.AccessToken, nil
|
|
}
|
|
|
|
func readSecretStoreError(body io.Reader) string {
|
|
var payload struct {
|
|
Errors []string `json:"errors"`
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := common.DecodeJson(body, &payload); err != nil {
|
|
return ""
|
|
}
|
|
if payload.Error.Message != "" {
|
|
if payload.Error.Code != "" {
|
|
return payload.Error.Code + ": " + payload.Error.Message
|
|
}
|
|
return payload.Error.Message
|
|
}
|
|
return strings.Join(payload.Errors, "; ")
|
|
}
|
|
|
|
func (s secretStoreClient) probe() (int, error) {
|
|
token, err := s.accessToken()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
endpoint := s.vaultURL + "/secrets?api-version=7.4&maxresults=1"
|
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// secretStoreStatusResponse is the public-safe envelope rendered by
|
|
// the admin status page. It never includes secret names or values.
|
|
type secretStoreStatusResponse struct {
|
|
Configured bool `json:"configured"` // env vars present?
|
|
Reachable bool `json:"reachable"` // Key Vault data plane responded?
|
|
Provider string `json:"provider,omitempty"`
|
|
AuthMethod string `json:"auth_method,omitempty"`
|
|
Message string `json:"message,omitempty"` // human-readable status
|
|
CheckedAt int64 `json:"checked_at"` // unix ms (server clock)
|
|
}
|
|
|
|
// GetSecretStoreStatus returns a sanitized Azure Key Vault status snapshot.
|
|
func GetSecretStoreStatus(c *gin.Context) {
|
|
out := secretStoreStatusResponse{
|
|
CheckedAt: time.Now().UnixMilli(),
|
|
Provider: "azure_key_vault",
|
|
AuthMethod: "managed_identity",
|
|
}
|
|
client, err := newSecretStoreClientFromEnv()
|
|
if err != nil {
|
|
out.Configured = false
|
|
out.Message = err.Error()
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
|
return
|
|
}
|
|
out.Configured = true
|
|
|
|
status, err := client.probe()
|
|
if err != nil {
|
|
out.Reachable = false
|
|
out.Message = "Azure Key Vault probe failed: " + err.Error()
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
|
return
|
|
}
|
|
out.Reachable = true
|
|
switch {
|
|
case status == http.StatusForbidden:
|
|
out.Message = "Azure Key Vault reachable, but managed identity lacks list permission; secret writes can still work if set permission is granted"
|
|
case status == http.StatusUnauthorized:
|
|
out.Message = "Azure Key Vault reachable, but managed identity was not authorized"
|
|
case status >= http.StatusOK && status < http.StatusMultipleChoices:
|
|
out.Message = "Azure Key Vault is reachable"
|
|
default:
|
|
out.Message = fmt.Sprintf("Azure Key Vault returned HTTP %d", status)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
|
}
|