easyai-ai-gateway/apps/api/internal/securityevents/kubernetes_secret_store.go
chengcheng 9efeb16fd1 feat(ssf): 实现安全事件一键连接与凭据托管
新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。

增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
2026-07-15 17:25:00 +08:00

187 lines
6.3 KiB
Go

package securityevents
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
const (
defaultKubernetesAPIServer = "https://kubernetes.default.svc"
defaultServiceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token"
defaultServiceAccountCA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
var kubernetesDNSNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`)
type KubernetesSecretStoreConfig struct {
Namespace string
SecretName string
APIServer string
TokenFile string
CAFile string
HTTPClient *http.Client
}
// KubernetesSecretStore keeps all generated Push Bearers as keys in one
// pre-provisioned Secret. It intentionally has no permission to create or
// delete Kubernetes Secret resources.
type KubernetesSecretStore struct {
endpoint string
tokenFile string
client *http.Client
}
func NewKubernetesSecretStore(config KubernetesSecretStoreConfig) (*KubernetesSecretStore, error) {
if !validKubernetesDNSName(config.Namespace) || !validKubernetesDNSName(config.SecretName) {
return nil, errors.New("Kubernetes security event Secret namespace or name is invalid")
}
if config.APIServer == "" {
config.APIServer = defaultKubernetesAPIServer
}
if config.TokenFile == "" {
config.TokenFile = defaultServiceAccountToken
}
parsed, err := url.Parse(strings.TrimRight(config.APIServer, "/"))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Scheme != "https" && config.HTTPClient == nil) {
return nil, errors.New("Kubernetes API server URL is invalid")
}
client := config.HTTPClient
if client == nil {
if config.CAFile == "" {
config.CAFile = defaultServiceAccountCA
}
certificate, readErr := os.ReadFile(config.CAFile)
if readErr != nil {
return nil, fmt.Errorf("read Kubernetes service account CA: %w", readErr)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(certificate) {
return nil, errors.New("Kubernetes service account CA is invalid")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DisableCompression = true
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
client = &http.Client{Timeout: 10 * time.Second, Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
endpoint := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", strings.TrimRight(config.APIServer, "/"), config.Namespace, config.SecretName)
return &KubernetesSecretStore{endpoint: endpoint, tokenFile: config.TokenFile, client: client}, nil
}
func (s *KubernetesSecretStore) Put(ctx context.Context, reference string, value []byte) error {
if !secretReferencePattern.MatchString(reference) {
return errors.New("security event secret reference is invalid")
}
if len(value) < 32 || len(value) > 4096 {
return errors.New("security event secret length is invalid")
}
return s.patch(ctx, map[string]any{reference: base64.StdEncoding.EncodeToString(value)})
}
func (s *KubernetesSecretStore) Get(ctx context.Context, reference string) ([]byte, error) {
if !secretReferencePattern.MatchString(reference) {
return nil, errors.New("security event secret reference is invalid")
}
request, err := s.request(ctx, http.MethodGet, nil)
if err != nil {
return nil, err
}
response, err := s.client.Do(request)
if err != nil {
return nil, fmt.Errorf("read Kubernetes security event Secret: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil, ErrSecretNotFound
}
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
return nil, fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode)
}
var payload struct {
Data map[string]string `json:"data"`
}
if err := decodeLimitedJSON(response.Body, &payload); err != nil {
return nil, err
}
encoded, ok := payload.Data[reference]
if !ok {
return nil, ErrSecretNotFound
}
value, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(value) < 32 || len(value) > 4096 {
clear(value)
return nil, errors.New("Kubernetes security event Secret value is invalid")
}
return value, nil
}
func (s *KubernetesSecretStore) Delete(ctx context.Context, reference string) error {
if !secretReferencePattern.MatchString(reference) {
return errors.New("security event secret reference is invalid")
}
return s.patch(ctx, map[string]any{reference: nil})
}
func (s *KubernetesSecretStore) patch(ctx context.Context, data map[string]any) error {
payload, err := json.Marshal(map[string]any{"data": data})
if err != nil {
return err
}
request, err := s.request(ctx, http.MethodPatch, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/merge-patch+json")
response, err := s.client.Do(request)
if err != nil {
return fmt.Errorf("update Kubernetes security event Secret: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
if response.StatusCode == http.StatusNotFound {
return errors.New("Kubernetes security event Secret is not provisioned")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode)
}
return nil
}
func (s *KubernetesSecretStore) request(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
token, err := os.ReadFile(s.tokenFile)
if err != nil || strings.TrimSpace(string(token)) == "" {
clear(token)
return nil, errors.New("Kubernetes service account token is unavailable")
}
request, err := http.NewRequestWithContext(ctx, method, s.endpoint, body)
if err != nil {
clear(token)
return nil, err
}
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
request.Header.Set("Accept", "application/json")
clear(token)
return request, nil
}
func validKubernetesDNSName(value string) bool {
return len(value) > 0 && len(value) <= 253 && kubernetesDNSNamePattern.MatchString(value)
}