xworkmate-bridge/internal/shared/vault.go
2026-04-09 09:49:48 +08:00

326 lines
7.8 KiB
Go

package shared
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type VaultKVResult struct {
Operation string `json:"operation"`
Mount string `json:"mount"`
Path string `json:"path"`
Data map[string]any `json:"data,omitempty"`
Keys []string `json:"keys,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func HandleVaultKVTool(arguments map[string]any) (string, error) {
request, err := buildVaultKVRequest(arguments)
if err != nil {
return "", err
}
result, err := executeVaultKVRequest(request)
if err != nil {
return "", err
}
encoded, err := json.MarshalIndent(result, "", " ")
if err != nil {
return "", err
}
return string(encoded), nil
}
type vaultKVRequest struct {
baseURL string
token string
namespace string
operation string
mount string
path string
data map[string]any
cas int
}
func buildVaultKVRequest(arguments map[string]any) (vaultKVRequest, error) {
baseURL := strings.TrimSpace(EnvOrDefault("VAULT_SERVER_URL", ""))
if baseURL == "" {
return vaultKVRequest{}, errors.New("VAULT_SERVER_URL environment variable not set")
}
token := strings.TrimSpace(EnvOrDefault("VAULT_SERVER_ROOT_ACCESS_TOKEN", ""))
if token == "" {
return vaultKVRequest{}, errors.New("VAULT_SERVER_ROOT_ACCESS_TOKEN environment variable not set")
}
operation := strings.ToLower(strings.TrimSpace(StringArg(arguments, "operation", "")))
if operation == "" {
return vaultKVRequest{}, errors.New("operation is required")
}
path := normalizeVaultPath(StringArg(arguments, "path", ""))
if path == "" {
return vaultKVRequest{}, errors.New("path is required")
}
data, err := vaultDataArg(arguments["data"])
if err != nil {
return vaultKVRequest{}, err
}
return vaultKVRequest{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
namespace: strings.TrimSpace(EnvOrDefault("VAULT_NAMESPACE", "")),
operation: operation,
mount: normalizeVaultMount(StringArg(arguments, "mount", "secret")),
path: path,
data: data,
cas: vaultCASArg(arguments["cas"]),
}, nil
}
func executeVaultKVRequest(request vaultKVRequest) (VaultKVResult, error) {
switch request.operation {
case "get", "read":
return vaultKVRead(request)
case "put", "write":
return vaultKVWrite(request)
case "list":
return vaultKVList(request)
case "delete":
return vaultKVDelete(request)
default:
return VaultKVResult{}, fmt.Errorf("unsupported operation: %s", request.operation)
}
}
func vaultKVRead(request vaultKVRequest) (VaultKVResult, error) {
response, err := doVaultRequest(
request,
http.MethodGet,
vaultDataURL(request.mount, request.path),
nil,
)
if err != nil {
return VaultKVResult{}, err
}
dataBlock := mapArg(response["data"])
return VaultKVResult{
Operation: "read",
Mount: request.mount,
Path: request.path,
Data: mapArg(dataBlock["data"]),
Metadata: mapArg(dataBlock["metadata"]),
}, nil
}
func vaultKVWrite(request vaultKVRequest) (VaultKVResult, error) {
if len(request.data) == 0 {
return VaultKVResult{}, errors.New("data is required for write operations")
}
payload := map[string]any{"data": request.data}
if request.cas > 0 {
payload["options"] = map[string]any{"cas": request.cas}
}
response, err := doVaultRequest(
request,
http.MethodPost,
vaultDataURL(request.mount, request.path),
payload,
)
if err != nil {
return VaultKVResult{}, err
}
return VaultKVResult{
Operation: "write",
Mount: request.mount,
Path: request.path,
Data: request.data,
Metadata: mapArg(mapArg(response["data"])["metadata"]),
}, nil
}
func vaultKVList(request vaultKVRequest) (VaultKVResult, error) {
response, err := doVaultRequest(
request,
"LIST",
vaultMetadataURL(request.mount, request.path),
nil,
)
if err != nil {
return VaultKVResult{}, err
}
dataBlock := mapArg(response["data"])
return VaultKVResult{
Operation: "list",
Mount: request.mount,
Path: request.path,
Keys: stringSliceArg(dataBlock["keys"]),
}, nil
}
func vaultKVDelete(request vaultKVRequest) (VaultKVResult, error) {
_, err := doVaultRequest(
request,
http.MethodDelete,
vaultDataURL(request.mount, request.path),
nil,
)
if err != nil {
return VaultKVResult{}, err
}
return VaultKVResult{
Operation: "delete",
Mount: request.mount,
Path: request.path,
}, nil
}
func doVaultRequest(
request vaultKVRequest,
method string,
target string,
payload map[string]any,
) (map[string]any, error) {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(encoded)
}
httpRequest, err := http.NewRequest(method, target, body)
if err != nil {
return nil, err
}
httpRequest.Header.Set("X-Vault-Token", request.token)
if request.namespace != "" {
httpRequest.Header.Set("X-Vault-Namespace", request.namespace)
}
if payload != nil {
httpRequest.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
response, err := client.Do(httpRequest)
if err != nil {
return nil, err
}
defer response.Body.Close()
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf(
"vault api error %d: %s",
response.StatusCode,
strings.TrimSpace(string(bodyBytes)),
)
}
if len(strings.TrimSpace(string(bodyBytes))) == 0 {
return map[string]any{}, nil
}
var decoded map[string]any
if err := json.Unmarshal(bodyBytes, &decoded); err != nil {
return nil, err
}
return decoded, nil
}
func vaultDataURL(mount, path string) string {
return fmt.Sprintf("%s/data/%s", vaultBasePath(mount), vaultPathSegments(path))
}
func vaultMetadataURL(mount, path string) string {
return fmt.Sprintf("%s/metadata/%s", vaultBasePath(mount), vaultPathSegments(path))
}
func vaultBasePath(mount string) string {
return fmt.Sprintf("%s/v1/%s", strings.TrimRight(strings.TrimSpace(EnvOrDefault("VAULT_SERVER_URL", "")), "/"), url.PathEscape(normalizeVaultMount(mount)))
}
func vaultPathSegments(path string) string {
segments := strings.Split(normalizeVaultPath(path), "/")
for index, segment := range segments {
segments[index] = url.PathEscape(segment)
}
return strings.Join(segments, "/")
}
func normalizeVaultMount(raw string) string {
trimmed := strings.Trim(strings.TrimSpace(raw), "/")
if trimmed == "" {
return "secret"
}
return trimmed
}
func normalizeVaultPath(raw string) string {
return strings.Trim(strings.TrimSpace(raw), "/")
}
func vaultDataArg(raw any) (map[string]any, error) {
if raw == nil {
return nil, nil
}
switch typed := raw.(type) {
case map[string]any:
return typed, nil
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return nil, nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
return nil, errors.New("data must be a JSON object")
}
return decoded, nil
default:
return nil, errors.New("data must be an object")
}
}
func vaultCASArg(raw any) int {
switch typed := raw.(type) {
case int:
return typed
case int64:
return int(typed)
case float64:
return int(typed)
case string:
return IntArg(typed, 0)
default:
return 0
}
}
func mapArg(raw any) map[string]any {
switch typed := raw.(type) {
case map[string]any:
return typed
default:
return map[string]any{}
}
}
func stringSliceArg(raw any) []string {
values, ok := raw.([]any)
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" {
continue
}
result = append(result, text)
}
return result
}