c05ccfcb5d
logarchiver replaces the plain Vector archiver leg of the centralized logging stack (argocd-apps #296) with a Go service that archives raw logs from NATS JetStream to S3 as zstd-compressed, OpenPGP-encrypted, indexed objects, plus an operator CLI to search the index and retrieve/decrypt archived logs. It adds the things that outgrew Vector: zstd compression, encryption keyed from Ben's Vault GPG secrets engine, a searchable ClickHouse index, and sink-conditional acks (a batch is acknowledged to JetStream only after the object is durably in S3 AND indexed). Service (`logarchiver run`): - Durable JetStream pull consumer (stream LOGS, durable archiver, subject filter default logs.k8s.vault.>), explicit acks, independent offsets. - Batch per subject by size/count/time -> NDJSON -> zstd -> encrypt -> S3 PUT -> ClickHouse index row -> ack. On any failure the batch is Nak'd and redelivered, so nothing is lost on a sink outage. - Encryption is a wrapped-DEK envelope (container LARC1): the bulk is AES-256-GCM framed under a random data key, and only that 32-byte key is OpenPGP-encrypted to the engine's public key. This is because the Vault GPG engine does whole-payload decrypt only; retrieval round-trips just the tiny wrapped key regardless of object size. Public key fetched from the engine or a mounted file (configurable); key fingerprint recorded per object; periodic pubkey refresh for rotation. - Prometheus metrics, structured slog, graceful drain on shutdown. CLI: - `search` queries the index (subject/host/time) and lists matching objects. - `fetch` downloads, decrypts via the Vault GPG engine, unzstds and emits NDJSON (optionally re-filtered by host/time). - `init-schema` creates/prints the ClickHouse archive_index DDL. - cobra `completion` subcommands. Config via file+env (k8s-friendly, secrets from env), boundaries (NATS/S3/ ClickHouse/Vault) behind interfaces with unit tests (config, batching, host/subject extraction, crypto roundtrip with a test key, ack-after-persist with fakes, search query building). go build/vet/test -race clean; golangci-lint v2 clean. Woodpecker CI: build/test/pre-commit on PR; on v* tag a container image plus a Gitea binary release + rpm-internal RPM. Docs per subcommand + architecture + retrieval runbook + deployment drop-in. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
196 lines
5.6 KiB
Go
196 lines
5.6 KiB
Go
// Package vaultgpg is a thin client for Ben's Vault GPG secrets engine
|
|
// (vault-plugin-secrets-gpg), used two ways:
|
|
//
|
|
// - the service reads the ARMORED PUBLIC key (GET <mount>/keys/<name>) to
|
|
// encrypt objects locally; and
|
|
// - the CLI decrypts a wrapped DEK (POST <mount>/decrypt/<name>) to retrieve
|
|
// objects. The engine does whole-payload decrypt only, but logarchiver only
|
|
// ever sends it the tiny wrapped-DEK blob, so that is a non-issue.
|
|
//
|
|
// Auth mirrors passv: ambient VAULT_* env (token or ~/.vault-token) for humans,
|
|
// or the kubernetes auth method for the in-cluster service.
|
|
package vaultgpg
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
vault "github.com/hashicorp/vault/api"
|
|
)
|
|
|
|
// Config configures the Vault client.
|
|
type Config struct {
|
|
Address string
|
|
Mount string // e.g. "gpg"
|
|
AuthMethod string // "token" | "kubernetes"
|
|
K8sRole string
|
|
K8sMount string // e.g. "k8s/au/syd1"
|
|
K8sJWTPath string
|
|
CAFile string
|
|
}
|
|
|
|
// Client wraps the Vault API client for GPG-engine operations.
|
|
type Client struct {
|
|
api *vault.Client
|
|
mount string
|
|
}
|
|
|
|
// New builds a Client and authenticates per cfg.AuthMethod.
|
|
func New(ctx context.Context, cfg Config) (*Client, error) {
|
|
vc := vault.DefaultConfig()
|
|
if err := vc.ReadEnvironment(); err != nil {
|
|
return nil, fmt.Errorf("read vault env: %w", err)
|
|
}
|
|
if cfg.Address != "" {
|
|
vc.Address = cfg.Address
|
|
}
|
|
if cfg.CAFile != "" {
|
|
if err := vc.ConfigureTLS(&vault.TLSConfig{CACert: cfg.CAFile}); err != nil {
|
|
return nil, fmt.Errorf("configure vault tls: %w", err)
|
|
}
|
|
}
|
|
api, err := vault.NewClient(vc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new vault client: %w", err)
|
|
}
|
|
mount := cfg.Mount
|
|
if mount == "" {
|
|
mount = "gpg"
|
|
}
|
|
c := &Client{api: api, mount: strings.Trim(mount, "/")}
|
|
|
|
switch cfg.AuthMethod {
|
|
case "", "token":
|
|
if api.Token() == "" {
|
|
tok, err := resolveToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
api.SetToken(tok)
|
|
}
|
|
case "kubernetes":
|
|
if err := c.k8sLogin(ctx, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown vault auth_method %q", cfg.AuthMethod)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Client) k8sLogin(ctx context.Context, cfg Config) error {
|
|
jwtPath := cfg.K8sJWTPath
|
|
if jwtPath == "" {
|
|
jwtPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|
}
|
|
jwt, err := os.ReadFile(jwtPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read service account token: %w", err)
|
|
}
|
|
mount := cfg.K8sMount
|
|
if mount == "" {
|
|
mount = "kubernetes"
|
|
}
|
|
path := fmt.Sprintf("auth/%s/login", strings.Trim(mount, "/"))
|
|
secret, err := c.api.Logical().WriteWithContext(ctx, path, map[string]any{
|
|
"role": cfg.K8sRole,
|
|
"jwt": strings.TrimSpace(string(jwt)),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("kubernetes login: %w", err)
|
|
}
|
|
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" {
|
|
return fmt.Errorf("kubernetes login returned no token")
|
|
}
|
|
c.api.SetToken(secret.Auth.ClientToken)
|
|
return nil
|
|
}
|
|
|
|
// resolveToken mirrors passv: VAULT_TOKEN, else ~/.vault-token.
|
|
func resolveToken() (string, error) {
|
|
if t := os.Getenv("VAULT_TOKEN"); t != "" {
|
|
return t, nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("no VAULT_TOKEN and cannot find home dir: %w", err)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(home, ".vault-token"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("no VAULT_TOKEN and no ~/.vault-token: %w", err)
|
|
}
|
|
tok := strings.TrimSpace(string(data))
|
|
if tok == "" {
|
|
return "", fmt.Errorf("~/.vault-token is empty")
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
// PublicKey is the result of reading a GPG engine key.
|
|
type PublicKey struct {
|
|
Armored string
|
|
Fingerprint string
|
|
}
|
|
|
|
// FetchPublicKey reads <mount>/keys/<name> and returns the latest armored public
|
|
// key and its fingerprint.
|
|
func (c *Client) FetchPublicKey(ctx context.Context, name string) (PublicKey, error) {
|
|
path := fmt.Sprintf("%s/keys/%s", c.mount, name)
|
|
secret, err := c.api.Logical().ReadWithContext(ctx, path)
|
|
if err != nil {
|
|
return PublicKey{}, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
if secret == nil || secret.Data == nil {
|
|
return PublicKey{}, fmt.Errorf("key %q not found at %s", name, path)
|
|
}
|
|
pub, _ := secret.Data["public_key"].(string)
|
|
if pub == "" {
|
|
return PublicKey{}, fmt.Errorf("key %q has no public_key field", name)
|
|
}
|
|
fpr, _ := secret.Data["fingerprint"].(string)
|
|
return PublicKey{Armored: pub, Fingerprint: fpr}, nil
|
|
}
|
|
|
|
// Decrypt sends ciphertext (raw binary OpenPGP) to <mount>/decrypt/<name> and
|
|
// returns the plaintext. The engine auto-detects binary vs armored; we send
|
|
// base64 of the raw bytes, as passv does.
|
|
func (c *Client) Decrypt(ctx context.Context, name string, ciphertext []byte) ([]byte, error) {
|
|
path := fmt.Sprintf("%s/decrypt/%s", c.mount, name)
|
|
secret, err := c.api.Logical().WriteWithContext(ctx, path, map[string]any{
|
|
"ciphertext": base64.StdEncoding.EncodeToString(ciphertext),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt via %s: %w", path, err)
|
|
}
|
|
if secret == nil || secret.Data == nil {
|
|
return nil, fmt.Errorf("decrypt returned no data")
|
|
}
|
|
b64, _ := secret.Data["plaintext"].(string)
|
|
if b64 == "" {
|
|
return nil, fmt.Errorf("decrypt returned no plaintext")
|
|
}
|
|
plain, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode plaintext: %w", err)
|
|
}
|
|
return plain, nil
|
|
}
|
|
|
|
// TokenTTL returns the remaining lease TTL of the current token, for diagnostics.
|
|
func (c *Client) TokenTTL(ctx context.Context) (time.Duration, error) {
|
|
secret, err := c.api.Auth().Token().LookupSelfWithContext(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
ttl, err := secret.TokenTTL()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return ttl, nil
|
|
}
|