package webserver import ( "context" "crypto/tls" "crypto/x509" "fmt" "log/slog" "os" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" ) // VaultClient wraps the gRPC stubs for communicating with the vault. type VaultClient struct { conn *grpc.ClientConn system pb.SystemServiceClient auth pb.AuthServiceClient engine pb.EngineServiceClient pki pb.PKIServiceClient ca pb.CAServiceClient } // NewVaultClient dials the vault gRPC server and returns a client. func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, error) { logger.Debug("connecting to vault", "addr", addr, "ca_cert", caCertPath) tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} if caCertPath != "" { logger.Debug("loading vault CA certificate", "path", caCertPath) pemData, err := os.ReadFile(caCertPath) //nolint:gosec if err != nil { return nil, fmt.Errorf("webserver: read CA cert: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pemData) { return nil, fmt.Errorf("webserver: parse CA cert") } tlsCfg.RootCAs = pool logger.Debug("vault CA certificate loaded successfully") } else { logger.Debug("no CA cert configured, using system roots") } logger.Debug("dialing vault gRPC", "addr", addr) conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) if err != nil { return nil, fmt.Errorf("webserver: dial vault: %w", err) } logger.Debug("vault gRPC connection established", "addr", addr) return &VaultClient{ conn: conn, system: pb.NewSystemServiceClient(conn), auth: pb.NewAuthServiceClient(conn), engine: pb.NewEngineServiceClient(conn), pki: pb.NewPKIServiceClient(conn), ca: pb.NewCAServiceClient(conn), }, nil } // Close closes the underlying connection. func (c *VaultClient) Close() error { return c.conn.Close() } // withToken returns a context with the Bearer token in outgoing metadata. func withToken(ctx context.Context, token string) context.Context { return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token) } // Status returns the current vault state string (e.g. "unsealed"). func (c *VaultClient) Status(ctx context.Context) (string, error) { resp, err := c.system.Status(ctx, &pb.StatusRequest{}) if err != nil { return "", err } return resp.State, nil } // Init initializes the vault with the given password. func (c *VaultClient) Init(ctx context.Context, password string) error { _, err := c.system.Init(ctx, &pb.InitRequest{Password: password}) return err } // Unseal unseals the vault with the given password. func (c *VaultClient) Unseal(ctx context.Context, password string) error { _, err := c.system.Unseal(ctx, &pb.UnsealRequest{Password: password}) return err } // TokenInfo holds validated token details returned by the vault. type TokenInfo struct { Username string Roles []string IsAdmin bool } // Login authenticates against the vault and returns the session token. func (c *VaultClient) Login(ctx context.Context, username, password, totpCode string) (string, error) { resp, err := c.auth.Login(ctx, &pb.LoginRequest{ Username: username, Password: password, TotpCode: totpCode, }) if err != nil { return "", err } return resp.Token, nil } // ValidateToken validates a token against the vault and returns the token info. func (c *VaultClient) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) { resp, err := c.auth.TokenInfo(withToken(ctx, token), &pb.TokenInfoRequest{}) if err != nil { return nil, err } return &TokenInfo{ Username: resp.Username, Roles: resp.Roles, IsAdmin: resp.IsAdmin, }, nil } // MountInfo holds metadata about an engine mount. type MountInfo struct { Name string Type string MountPath string } // ListMounts returns all engine mounts from the vault. func (c *VaultClient) ListMounts(ctx context.Context, token string) ([]MountInfo, error) { resp, err := c.engine.ListMounts(withToken(ctx, token), &pb.ListMountsRequest{}) if err != nil { return nil, err } mounts := make([]MountInfo, 0, len(resp.Mounts)) for _, m := range resp.Mounts { mounts = append(mounts, MountInfo{ Name: m.Name, Type: m.Type, MountPath: m.MountPath, }) } return mounts, nil } // Mount creates a new engine mount on the vault. func (c *VaultClient) Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error { req := &pb.MountRequest{ Name: name, Type: engineType, } if len(config) > 0 { cfg := make(map[string]string, len(config)) for k, v := range config { cfg[k] = fmt.Sprintf("%v", v) } req.Config = cfg } _, err := c.engine.Mount(withToken(ctx, token), req) return err } // GetRootCert returns the root CA certificate PEM for the given mount. func (c *VaultClient) GetRootCert(ctx context.Context, mount string) ([]byte, error) { resp, err := c.pki.GetRootCert(ctx, &pb.GetRootCertRequest{Mount: mount}) if err != nil { return nil, err } return resp.CertPem, nil } // GetIssuerCert returns a named issuer certificate PEM for the given mount. func (c *VaultClient) GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) { resp, err := c.pki.GetIssuerCert(ctx, &pb.GetIssuerCertRequest{Mount: mount, Issuer: issuer}) if err != nil { return nil, err } return resp.CertPem, nil } // ImportRoot imports an existing root CA certificate and key into the given mount. func (c *VaultClient) ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error { _, err := c.ca.ImportRoot(withToken(ctx, token), &pb.ImportRootRequest{ Mount: mount, CertPem: []byte(certPEM), KeyPem: []byte(keyPEM), }) return err } // CreateIssuerRequest holds parameters for creating an intermediate CA issuer. type CreateIssuerRequest struct { Mount string Name string KeyAlgorithm string KeySize int32 Expiry string MaxTTL string } // CreateIssuer creates a new intermediate CA issuer on the given mount. func (c *VaultClient) CreateIssuer(ctx context.Context, token string, req CreateIssuerRequest) error { _, err := c.ca.CreateIssuer(withToken(ctx, token), &pb.CreateIssuerRequest{ Mount: req.Mount, Name: req.Name, KeyAlgorithm: req.KeyAlgorithm, KeySize: req.KeySize, Expiry: req.Expiry, MaxTtl: req.MaxTTL, }) return err } // ListIssuers returns the names of all issuers for the given mount. func (c *VaultClient) ListIssuers(ctx context.Context, token, mount string) ([]string, error) { resp, err := c.ca.ListIssuers(withToken(ctx, token), &pb.ListIssuersRequest{Mount: mount}) if err != nil { return nil, err } return resp.Issuers, nil } // IssueCertRequest holds parameters for issuing a leaf certificate. type IssueCertRequest struct { Mount string Issuer string Profile string CommonName string DNSNames []string IPAddresses []string TTL string KeyUsages []string ExtKeyUsages []string } // IssuedCert holds the result of a certificate issuance. type IssuedCert struct { Serial string CertPEM string KeyPEM string ChainPEM string ExpiresAt string } // IssueCert issues a new leaf certificate from the named issuer. func (c *VaultClient) IssueCert(ctx context.Context, token string, req IssueCertRequest) (*IssuedCert, error) { resp, err := c.ca.IssueCert(withToken(ctx, token), &pb.IssueCertRequest{ Mount: req.Mount, Issuer: req.Issuer, Profile: req.Profile, CommonName: req.CommonName, DnsNames: req.DNSNames, IpAddresses: req.IPAddresses, Ttl: req.TTL, KeyUsages: req.KeyUsages, ExtKeyUsages: req.ExtKeyUsages, }) if err != nil { return nil, err } issued := &IssuedCert{ Serial: resp.Serial, CertPEM: string(resp.CertPem), KeyPEM: string(resp.KeyPem), ChainPEM: string(resp.ChainPem), } if resp.ExpiresAt != nil { issued.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z") } return issued, nil } // SignCSRRequest holds parameters for signing an external CSR. type SignCSRRequest struct { Mount string Issuer string CSRPEM string Profile string TTL string } // SignedCert holds the result of signing a CSR. type SignedCert struct { Serial string CertPEM string ChainPEM string ExpiresAt string } // SignCSR signs an externally generated CSR with the named issuer. func (c *VaultClient) SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) { resp, err := c.ca.SignCSR(withToken(ctx, token), &pb.SignCSRRequest{ Mount: req.Mount, Issuer: req.Issuer, CsrPem: []byte(req.CSRPEM), Profile: req.Profile, Ttl: req.TTL, }) if err != nil { return nil, err } sc := &SignedCert{ Serial: resp.Serial, CertPEM: string(resp.CertPem), ChainPEM: string(resp.ChainPem), } if resp.ExpiresAt != nil { sc.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z") } return sc, nil } // CertDetail holds the full certificate record for the detail view. type CertDetail struct { Serial string Issuer string CommonName string SANs []string Profile string IssuedBy string IssuedAt string ExpiresAt string CertPEM string Revoked bool RevokedAt string RevokedBy string } // GetCert retrieves a full certificate record by serial number. func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) { resp, err := c.ca.GetCert(withToken(ctx, token), &pb.GetCertRequest{Mount: mount, Serial: serial}) if err != nil { return nil, err } rec := resp.GetCert() if rec == nil { return nil, fmt.Errorf("cert not found") } cd := &CertDetail{ Serial: rec.Serial, Issuer: rec.Issuer, CommonName: rec.CommonName, SANs: rec.Sans, Profile: rec.Profile, IssuedBy: rec.IssuedBy, CertPEM: string(rec.CertPem), Revoked: rec.Revoked, RevokedBy: rec.RevokedBy, } if rec.IssuedAt != nil { cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z") } if rec.ExpiresAt != nil { cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z") } if rec.RevokedAt != nil { cd.RevokedAt = rec.RevokedAt.AsTime().Format("2006-01-02T15:04:05Z") } return cd, nil } // RevokeCert marks a certificate as revoked. func (c *VaultClient) RevokeCert(ctx context.Context, token, mount, serial string) error { _, err := c.ca.RevokeCert(withToken(ctx, token), &pb.RevokeCertRequest{Mount: mount, Serial: serial}) return err } // DeleteCert permanently removes a certificate record. func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial string) error { _, err := c.ca.DeleteCert(withToken(ctx, token), &pb.DeleteCertRequest{Mount: mount, Serial: serial}) return err } // CertSummary holds lightweight certificate metadata for list views. type CertSummary struct { Serial string Issuer string CommonName string Profile string IssuedBy string IssuedAt string ExpiresAt string } // ListCerts returns all certificate summaries for the given mount. func (c *VaultClient) ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) { resp, err := c.ca.ListCerts(withToken(ctx, token), &pb.ListCertsRequest{Mount: mount}) if err != nil { return nil, err } certs := make([]CertSummary, 0, len(resp.Certs)) for _, s := range resp.Certs { cs := CertSummary{ Serial: s.Serial, Issuer: s.Issuer, CommonName: s.CommonName, Profile: s.Profile, IssuedBy: s.IssuedBy, } if s.IssuedAt != nil { cs.IssuedAt = s.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z") } if s.ExpiresAt != nil { cs.ExpiresAt = s.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z") } certs = append(certs, cs) } return certs, nil }