commit 00f5b4a2460ffd27cdc31d7fe4db1d5dba04c4f7 Author: Ben Vincent Date: Tue Mar 24 19:38:24 2026 +1100 initial commit: certmanager migrate from python to golang diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..a57e4f7 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,42 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: certmanager + main: ./cmd/certmanager + binary: certmanager + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - freebsd + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w + +archives: + - id: certmanager + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format: tar.gz + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3f99cbe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-mod-tidy diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..25cbaed --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BINARY := certmanager +CMD := ./cmd/certmanager +GOFLAGS := -trimpath +LDFLAGS := -s -w + +.PHONY: all build test lint clean install + +all: build + +build: + go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY) $(CMD) + +test: + go test ./... + +test-verbose: + go test -v ./... + +lint: + golangci-lint run ./... + +clean: + rm -f $(BINARY) + +install: + go install $(GOFLAGS) -ldflags "$(LDFLAGS)" $(CMD) + +tidy: + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbe92e1 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# certmanager + +Vault PKI certificate issuance and SSH host key signing tool for Puppet-managed infrastructure. + +Replaces the Python `certmanager` and `sshsignhost` scripts deployed by Puppet, compiled to a single static binary with no runtime dependencies. + +## Installation + +```sh +make build # produces ./certmanager +make install # installs to $GOPATH/bin +``` + +## Configuration + +Config file is read from `$XDG_CONFIG_HOME/certmanager/config.yaml` (defaults to `~/.config/certmanager/config.yaml`). Override with `--config`. + +On Puppet-managed nodes the config is written to `/opt/certmanager/config.yaml` by `profiles::helpers::certmanager`. + +### Auth methods + +#### AppRole (current — Puppet nodes) + +```yaml +vault: + addr: https://vault.service.consul:8200 + auth_method: approle + approle_path: approle # optional, defaults to "approle" + role_id: + secret_id: # optional + mount_point: pki_int + role_name: servers_default + output_path: /tmp/certmanager +``` + +#### LDAP (testing / interactive use) + +```yaml +vault: + addr: https://vault.service.consul:8200 + auth_method: ldap + ldap_path: ldap # optional, defaults to "ldap" + ldap_username: alice + ldap_password: secret + mount_point: pki_int + role_name: servers_default + output_path: /tmp/certmanager +``` + +#### Kubernetes (Puppet running in-cluster) + +```yaml +vault: + addr: https://vault.service.consul:8200 + auth_method: kubernetes + kubernetes_path: kubernetes # optional, defaults to "kubernetes" + kubernetes_role: puppet + kubernetes_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token # optional + mount_point: pki_int + role_name: servers_default + output_path: /tmp/certmanager +``` + +#### Token (bootstrap / testing) + +```yaml +vault: + addr: https://vault.service.consul:8200 + auth_method: token + token: hvs.XXXX + mount_point: pki_int + role_name: servers_default + output_path: /tmp/certmanager +``` + +> **Note:** `auth_method` defaults to `approle` when omitted, preserving backwards compatibility with the existing Python script config format. + +## Usage + +### PKI certificate + +```sh +# Write files to output_path// +certmanager pki host.example.com \ + --alt-names host,host.example.com \ + --ip-sans 127.0.0.1,10.0.0.1 \ + --expiry-days 90 + +# JSON output (for use with Puppet generate()) +certmanager pki host.example.com \ + --alt-names host,host.example.com \ + --ip-sans 127.0.0.1,10.0.0.1 \ + --expiry-days 90 \ + --json +``` + +Output files (written to `//`): + +| File | Contents | +|---|---| +| `certificate.crt` | Signed certificate | +| `private.key` | Private key (mode 0600) | +| `ca_certificate.crt` | Issuing CA certificate | +| `full_chain.crt` | CA + certificate | +| `certificate.pem` | Certificate + key bundle (mode 0600) | + +JSON keys: `certificate`, `private_key`, `ca_certificate`, `full_chain`. + +### SSH host certificate signing + +```sh +# Plain output (signed cert to stdout) +certmanager ssh \ + --public-key "ssh-rsa AAAA..." \ + --valid-principals host.example.com,host,10.0.0.1 + +# JSON output (for use with Puppet generate()) +certmanager ssh \ + --public-key "$(cat /etc/ssh/ssh_host_rsa_key.pub)" \ + --valid-principals host.example.com,host \ + --ttl 87600h \ + --json +``` + +JSON key: `signed_key`. + +### Global flags + +| Flag | Default | Description | +|---|---|---| +| `--config` | `~/.config/certmanager/config.yaml` | Path to config file | + +## Development + +```sh +make build # compile +make test # run tests +make test-verbose # run tests with -v +make tidy # go mod tidy +make lint # golangci-lint (requires golangci-lint installed) +``` + +## Release + +Uses [goreleaser](https://goreleaser.com/). Tag a version and run: + +```sh +goreleaser release --clean +``` diff --git a/cmd/certmanager/main.go b/cmd/certmanager/main.go new file mode 100644 index 0000000..85ce657 --- /dev/null +++ b/cmd/certmanager/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "git.unkin.net/unkin/certmanager/internal/config" + "git.unkin.net/unkin/certmanager/internal/pki" + "git.unkin.net/unkin/certmanager/internal/ssh" + "git.unkin.net/unkin/certmanager/internal/vault" +) + +var cfgFile string + +func main() { + if err := rootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func rootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "certmanager", + Short: "Request PKI certificates and sign SSH host keys via HashiCorp Vault", + } + root.PersistentFlags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml") + + root.AddCommand(pkiCmd()) + root.AddCommand(sshCmd()) + return root +} + +// --------------------------------------------------------------------------- +// pki subcommand +// --------------------------------------------------------------------------- + +func pkiCmd() *cobra.Command { + var ( + altNames []string + ipSans []string + expiryDays int + jsonOut bool + ) + + cmd := &cobra.Command{ + Use: "pki ", + Short: "Issue a PKI certificate from Vault", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + commonName := args[0] + + cfg, err := config.Load(cfgFile) + if err != nil { + return err + } + + client, err := vault.New(cfg.Vault) + if err != nil { + return fmt.Errorf("vault auth: %w", err) + } + + cert, err := pki.IssueCert(client, cfg.Vault.MountPoint, cfg.Vault.RoleName, commonName, altNames, ipSans, expiryDays) + if err != nil { + return fmt.Errorf("issue cert: %w", err) + } + + if jsonOut { + return json.NewEncoder(os.Stdout).Encode(cert) + } + + outDir := cfg.Vault.OutputPath + if outDir == "" { + outDir = "." + } + return writePKIFiles(outDir, commonName, cert) + }, + } + + cmd.Flags().StringSliceVar(&altNames, "alt-names", nil, "additional DNS SANs (comma-separated or repeated flag)") + cmd.Flags().StringSliceVar(&ipSans, "ip-sans", nil, "additional IP SANs (comma-separated or repeated flag)") + cmd.Flags().IntVar(&expiryDays, "expiry-days", 90, "certificate TTL in days") + cmd.Flags().BoolVar(&jsonOut, "json", false, "print certificate data as JSON instead of writing files") + + return cmd +} + +func writePKIFiles(outDir, commonName string, cert *pki.CertificateResponse) error { + dir := fmt.Sprintf("%s/%s", outDir, commonName) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + files := map[string]string{ + "certificate.crt": cert.Certificate, + "private.key": cert.PrivateKey, + "full_chain.crt": cert.FullChain, + "ca_certificate.crt": cert.CACertificate, + "certificate.pem": cert.Certificate + "\n" + cert.PrivateKey, + } + + for name, content := range files { + mode := os.FileMode(0o644) + if name == "private.key" || name == "certificate.pem" { + mode = 0o600 + } + path := fmt.Sprintf("%s/%s", dir, name) + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// ssh subcommand +// --------------------------------------------------------------------------- + +func sshCmd() *cobra.Command { + var ( + publicKey string + principals []string + ttl string + jsonOut bool + ) + + cmd := &cobra.Command{ + Use: "ssh", + Short: "Sign an SSH host public key via Vault", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(cfgFile) + if err != nil { + return err + } + + client, err := vault.New(cfg.Vault) + if err != nil { + return fmt.Errorf("vault auth: %w", err) + } + + // Flatten any comma-separated values passed as a single string + var flatPrincipals []string + for _, p := range principals { + for _, part := range strings.Split(p, ",") { + if s := strings.TrimSpace(part); s != "" { + flatPrincipals = append(flatPrincipals, s) + } + } + } + + signed, err := ssh.SignHostCert(client, cfg.Vault.MountPoint, cfg.Vault.RoleName, publicKey, flatPrincipals, ttl) + if err != nil { + return fmt.Errorf("sign ssh cert: %w", err) + } + + if jsonOut { + return json.NewEncoder(os.Stdout).Encode(signed) + } + + fmt.Print(signed.SignedKey) + return nil + }, + } + + cmd.Flags().StringVar(&publicKey, "public-key", "", "SSH public key string (e.g. \"ssh-rsa AAAA...\")") + cmd.Flags().StringSliceVar(&principals, "valid-principals", nil, "principals to embed in the certificate (comma-separated or repeated flag)") + cmd.Flags().StringVar(&ttl, "ttl", "87600h", "certificate TTL (e.g. 87600h)") + cmd.Flags().BoolVar(&jsonOut, "json", false, "print signed key as JSON instead of plain text") + _ = cmd.MarkFlagRequired("public-key") + _ = cmd.MarkFlagRequired("valid-principals") + + return cmd +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7bcb130 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.unkin.net/unkin/certmanager + +go 1.25.7 + +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47edb24 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0f00610 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,89 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// AuthMethod selects how certmanager authenticates to Vault. +type AuthMethod string + +const ( + AuthMethodAppRole AuthMethod = "approle" + AuthMethodLDAP AuthMethod = "ldap" + AuthMethodKubernetes AuthMethod = "kubernetes" + AuthMethodToken AuthMethod = "token" +) + +// Config is the top-level configuration structure. +type Config struct { + Vault VaultConfig `yaml:"vault"` +} + +// VaultConfig holds Vault connection and auth parameters. +// Only the fields relevant to the chosen AuthMethod need to be populated. +type VaultConfig struct { + Addr string `yaml:"addr"` + AuthMethod AuthMethod `yaml:"auth_method"` // approle | ldap | kubernetes | token + MountPoint string `yaml:"mount_point"` + RoleName string `yaml:"role_name"` + OutputPath string `yaml:"output_path"` + + // approle + ApprolePath string `yaml:"approle_path"` + RoleID string `yaml:"role_id"` + SecretID string `yaml:"secret_id"` + + // ldap + LDAPPath string `yaml:"ldap_path"` + LDAPUsername string `yaml:"ldap_username"` + LDAPPassword string `yaml:"ldap_password"` + + // kubernetes + KubernetesPath string `yaml:"kubernetes_path"` + KubernetesRole string `yaml:"kubernetes_role"` + KubernetesTokenFile string `yaml:"kubernetes_token_file"` + + // token (static; useful for testing or bootstrap) + Token string `yaml:"token"` +} + +// DefaultPath returns the XDG-compliant default config file path: +// $XDG_CONFIG_HOME/certmanager/config.yaml, falling back to +// $HOME/.config/certmanager/config.yaml. +func DefaultPath() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "config.yaml" + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "certmanager", "config.yaml") +} + +// Load reads and parses the config file at the given path. +func Load(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open config %q: %w", path, err) + } + defer f.Close() + + var cfg Config + if err := yaml.NewDecoder(f).Decode(&cfg); err != nil { + return nil, fmt.Errorf("parse config %q: %w", path, err) + } + + // Default to approle for backwards-compatibility with the existing + // Python certmanager/sshsignhost config format. + if cfg.Vault.AuthMethod == "" { + cfg.Vault.AuthMethod = AuthMethodAppRole + } + + return &cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..01ea296 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultPath_XDGSet(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg") + got := DefaultPath() + want := "/tmp/xdg/certmanager/config.yaml" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestDefaultPath_XDGUnset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home, _ := os.UserHomeDir() + got := DefaultPath() + want := filepath.Join(home, ".config", "certmanager", "config.yaml") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestLoad_AppRole(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.yaml") + content := ` +vault: + addr: https://vault.example.com:8200 + auth_method: approle + approle_path: approle + role_id: my-role-id + mount_point: pki_int + role_name: servers_default + output_path: /tmp/certs +` + os.WriteFile(path, []byte(content), 0o644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Vault.AuthMethod != AuthMethodAppRole { + t.Errorf("auth_method = %q", cfg.Vault.AuthMethod) + } + if cfg.Vault.RoleID != "my-role-id" { + t.Errorf("role_id = %q", cfg.Vault.RoleID) + } +} + +func TestLoad_DefaultAuthMethod(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.yaml") + // Omit auth_method — should default to approle for backwards compat. + content := ` +vault: + addr: https://vault.example.com:8200 + role_id: my-role-id + approle_path: approle + mount_point: pki_int + role_name: servers_default +` + os.WriteFile(path, []byte(content), 0o644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Vault.AuthMethod != AuthMethodAppRole { + t.Errorf("expected default approle, got %q", cfg.Vault.AuthMethod) + } +} + +func TestLoad_LDAP(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.yaml") + content := ` +vault: + addr: https://vault.example.com:8200 + auth_method: ldap + ldap_path: ldap + ldap_username: alice + ldap_password: secret + mount_point: pki_int + role_name: servers_default +` + os.WriteFile(path, []byte(content), 0o644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Vault.AuthMethod != AuthMethodLDAP { + t.Errorf("auth_method = %q", cfg.Vault.AuthMethod) + } + if cfg.Vault.LDAPUsername != "alice" { + t.Errorf("ldap_username = %q", cfg.Vault.LDAPUsername) + } +} + +func TestLoad_Kubernetes(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.yaml") + content := ` +vault: + addr: https://vault.example.com:8200 + auth_method: kubernetes + kubernetes_path: kubernetes + kubernetes_role: puppet + kubernetes_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token + mount_point: pki_int + role_name: servers_default +` + os.WriteFile(path, []byte(content), 0o644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Vault.AuthMethod != AuthMethodKubernetes { + t.Errorf("auth_method = %q", cfg.Vault.AuthMethod) + } + if cfg.Vault.KubernetesRole != "puppet" { + t.Errorf("kubernetes_role = %q", cfg.Vault.KubernetesRole) + } +} + +func TestLoad_Token(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config.yaml") + content := ` +vault: + addr: https://vault.example.com:8200 + auth_method: token + token: hvs.statictoken + mount_point: pki_int + role_name: servers_default +` + os.WriteFile(path, []byte(content), 0o644) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Vault.AuthMethod != AuthMethodToken { + t.Errorf("auth_method = %q", cfg.Vault.AuthMethod) + } + if cfg.Vault.Token != "hvs.statictoken" { + t.Errorf("token = %q", cfg.Vault.Token) + } +} + +func TestLoad_MissingFile(t *testing.T) { + _, err := Load("/nonexistent/config.yaml") + if err == nil { + t.Error("expected error for missing file, got nil") + } +} diff --git a/internal/pki/pki.go b/internal/pki/pki.go new file mode 100644 index 0000000..a1fa6f3 --- /dev/null +++ b/internal/pki/pki.go @@ -0,0 +1,60 @@ +package pki + +import ( + "fmt" + "strings" + + "git.unkin.net/unkin/certmanager/internal/vault" +) + +// CertificateResponse is the JSON-serialisable output returned to callers. +type CertificateResponse struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` + FullChain string `json:"full_chain"` + CACertificate string `json:"ca_certificate"` +} + +type issueRequest struct { + CommonName string `json:"common_name"` + AltNames string `json:"alt_names,omitempty"` + IPSans string `json:"ip_sans,omitempty"` + TTL string `json:"ttl"` +} + +type issueResponse struct { + Data struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` + IssuingCA string `json:"issuing_ca"` + } `json:"data"` +} + +// IssueCert requests a PKI certificate from Vault. +func IssueCert(client *vault.Client, mountPoint, roleName, commonName string, altNames, ipSans []string, expiryDays int) (*CertificateResponse, error) { + req := issueRequest{ + CommonName: commonName, + TTL: fmt.Sprintf("%dd", expiryDays), + } + if len(altNames) > 0 { + req.AltNames = strings.Join(altNames, ",") + } + if len(ipSans) > 0 { + req.IPSans = strings.Join(ipSans, ",") + } + + var resp issueResponse + path := fmt.Sprintf("%s/issue/%s", mountPoint, roleName) + if err := client.Post(path, req, &resp); err != nil { + return nil, err + } + + fullChain := resp.Data.IssuingCA + "\n" + resp.Data.Certificate + + return &CertificateResponse{ + Certificate: resp.Data.Certificate, + PrivateKey: resp.Data.PrivateKey, + FullChain: fullChain, + CACertificate: resp.Data.IssuingCA, + }, nil +} diff --git a/internal/pki/pki_test.go b/internal/pki/pki_test.go new file mode 100644 index 0000000..80cb538 --- /dev/null +++ b/internal/pki/pki_test.go @@ -0,0 +1,84 @@ +package pki_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.unkin.net/unkin/certmanager/internal/config" + "git.unkin.net/unkin/certmanager/internal/pki" + "git.unkin.net/unkin/certmanager/internal/vault" +) + +func newVaultClient(t *testing.T, mux *http.ServeMux) *vault.Client { + t.Helper() + const token = "test-token" + mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "auth": map[string]any{"client_token": token}, + }) + }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + client, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + RoleID: "role", + ApprolePath: "approle", + }) + if err != nil { + t.Fatalf("vault.New: %v", err) + } + return client +} + +func TestIssueCert(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/pki_int/issue/servers_default", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "certificate": "CERT", + "private_key": "KEY", + "issuing_ca": "CA", + }, + }) + }) + + client := newVaultClient(t, mux) + cert, err := pki.IssueCert(client, "pki_int", "servers_default", "host.example.com", + []string{"host", "host.example.com"}, []string{"127.0.0.1"}, 90) + if err != nil { + t.Fatalf("IssueCert: %v", err) + } + + if cert.Certificate != "CERT" { + t.Errorf("certificate = %q", cert.Certificate) + } + if cert.PrivateKey != "KEY" { + t.Errorf("private_key = %q", cert.PrivateKey) + } + if cert.CACertificate != "CA" { + t.Errorf("ca_certificate = %q", cert.CACertificate) + } + if !strings.Contains(cert.FullChain, "CA") || !strings.Contains(cert.FullChain, "CERT") { + t.Errorf("full_chain = %q", cert.FullChain) + } +} + +func TestIssueCert_VaultError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/pki_int/issue/servers_default", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + }) + + client := newVaultClient(t, mux) + _, err := pki.IssueCert(client, "pki_int", "servers_default", "host.example.com", nil, nil, 90) + if err == nil { + t.Error("expected error, got nil") + } +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..54e2e2f --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,52 @@ +package ssh + +import ( + "fmt" + "strings" + + "git.unkin.net/unkin/certmanager/internal/vault" +) + +// SignedCertResponse is the JSON-serialisable output returned to callers. +type SignedCertResponse struct { + SignedKey string `json:"signed_key"` +} + +type signRequest struct { + CertType string `json:"cert_type"` + PublicKey string `json:"public_key"` + ValidPrincipals string `json:"valid_principals"` + TTL string `json:"ttl"` +} + +type signResponse struct { + Data struct { + SignedKey string `json:"signed_key"` + } `json:"data"` +} + +// SignHostCert signs an SSH host public key via the Vault SSH secrets engine. +func SignHostCert(client *vault.Client, mountPoint, roleName, publicKey string, principals []string, ttl string) (*SignedCertResponse, error) { + if ttl == "" { + ttl = "87600h" + } + + req := signRequest{ + CertType: "host", + PublicKey: publicKey, + ValidPrincipals: strings.Join(principals, ","), + TTL: ttl, + } + + var resp signResponse + path := fmt.Sprintf("%s/sign/%s", mountPoint, roleName) + if err := client.Post(path, req, &resp); err != nil { + return nil, err + } + + if resp.Data.SignedKey == "" { + return nil, fmt.Errorf("vault returned empty signed_key") + } + + return &SignedCertResponse{SignedKey: resp.Data.SignedKey}, nil +} diff --git a/internal/ssh/ssh_test.go b/internal/ssh/ssh_test.go new file mode 100644 index 0000000..23fedd2 --- /dev/null +++ b/internal/ssh/ssh_test.go @@ -0,0 +1,96 @@ +package ssh_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.unkin.net/unkin/certmanager/internal/config" + "git.unkin.net/unkin/certmanager/internal/ssh" + "git.unkin.net/unkin/certmanager/internal/vault" +) + +func newVaultClient(t *testing.T, mux *http.ServeMux) *vault.Client { + t.Helper() + const token = "test-token" + mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "auth": map[string]any{"client_token": token}, + }) + }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + client, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + RoleID: "role", + ApprolePath: "approle", + }) + if err != nil { + t.Fatalf("vault.New: %v", err) + } + return client +} + +func TestSignHostCert(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/ssh-host-signer/sign/hostrole", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "signed_key": "ssh-rsa-cert-v01@openssh.com AAAA...", + }, + }) + }) + + client := newVaultClient(t, mux) + resp, err := ssh.SignHostCert(client, "ssh-host-signer", "hostrole", + "ssh-rsa AAAA...", []string{"host.example.com", "host"}, "87600h") + if err != nil { + t.Fatalf("SignHostCert: %v", err) + } + + if resp.SignedKey != "ssh-rsa-cert-v01@openssh.com AAAA..." { + t.Errorf("signed_key = %q", resp.SignedKey) + } +} + +func TestSignHostCert_DefaultTTL(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/ssh-host-signer/sign/hostrole", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["ttl"] != "87600h" { + t.Errorf("expected default ttl 87600h, got %q", body["ttl"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"signed_key": "CERT"}, + }) + }) + + client := newVaultClient(t, mux) + _, err := ssh.SignHostCert(client, "ssh-host-signer", "hostrole", "ssh-rsa AAAA...", []string{"host"}, "") + if err != nil { + t.Fatalf("SignHostCert: %v", err) + } +} + +func TestSignHostCert_EmptySignedKey(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/ssh-host-signer/sign/hostrole", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"signed_key": ""}, + }) + }) + + client := newVaultClient(t, mux) + _, err := ssh.SignHostCert(client, "ssh-host-signer", "hostrole", "ssh-rsa AAAA...", []string{"host"}, "87600h") + if err == nil { + t.Error("expected error for empty signed_key, got nil") + } +} diff --git a/internal/vault/client.go b/internal/vault/client.go new file mode 100644 index 0000000..3ccd760 --- /dev/null +++ b/internal/vault/client.go @@ -0,0 +1,186 @@ +package vault + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "os" + + "git.unkin.net/unkin/certmanager/internal/config" +) + +const defaultKubernetesTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +// Client is an authenticated Vault HTTP client. +type Client struct { + addr string + token string + http *http.Client +} + +// New authenticates to Vault using the method specified in cfg and returns a +// ready Client. +func New(cfg config.VaultConfig) (*Client, error) { + // TLS verification is intentionally skipped to match the existing Python + // scripts; the TODO comment in those scripts notes this should be removed + // once certs are generated everywhere. + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + } + httpClient := &http.Client{Transport: transport} + + c := &Client{addr: cfg.Addr, http: httpClient} + + var err error + switch cfg.AuthMethod { + case config.AuthMethodAppRole, "": + err = c.loginAppRole(cfg) + case config.AuthMethodLDAP: + err = c.loginLDAP(cfg) + case config.AuthMethodKubernetes: + err = c.loginKubernetes(cfg) + case config.AuthMethodToken: + err = c.loginToken(cfg) + default: + return nil, fmt.Errorf("unknown auth_method %q (valid: approle, ldap, kubernetes, token)", cfg.AuthMethod) + } + if err != nil { + return nil, err + } + + return c, nil +} + +// --------------------------------------------------------------------------- +// auth method implementations +// --------------------------------------------------------------------------- + +func (c *Client) loginAppRole(cfg config.VaultConfig) error { + path := cfg.ApprolePath + if path == "" { + path = "approle" + } + payload := map[string]string{"role_id": cfg.RoleID} + if cfg.SecretID != "" { + payload["secret_id"] = cfg.SecretID + } + token, err := c.fetchToken(fmt.Sprintf("auth/%s/login", path), payload) + if err != nil { + return fmt.Errorf("approle login: %w", err) + } + c.token = token + return nil +} + +func (c *Client) loginLDAP(cfg config.VaultConfig) error { + path := cfg.LDAPPath + if path == "" { + path = "ldap" + } + payload := map[string]string{"password": cfg.LDAPPassword} + token, err := c.fetchToken(fmt.Sprintf("auth/%s/login/%s", path, cfg.LDAPUsername), payload) + if err != nil { + return fmt.Errorf("ldap login: %w", err) + } + c.token = token + return nil +} + +func (c *Client) loginKubernetes(cfg config.VaultConfig) error { + path := cfg.KubernetesPath + if path == "" { + path = "kubernetes" + } + tokenFile := cfg.KubernetesTokenFile + if tokenFile == "" { + tokenFile = defaultKubernetesTokenFile + } + jwt, err := os.ReadFile(tokenFile) + if err != nil { + return fmt.Errorf("kubernetes login: read service account token %q: %w", tokenFile, err) + } + payload := map[string]string{ + "role": cfg.KubernetesRole, + "jwt": string(jwt), + } + token, err := c.fetchToken(fmt.Sprintf("auth/%s/login", path), payload) + if err != nil { + return fmt.Errorf("kubernetes login: %w", err) + } + c.token = token + return nil +} + +func (c *Client) loginToken(cfg config.VaultConfig) error { + if cfg.Token == "" { + return fmt.Errorf("token auth: vault.token must not be empty") + } + c.token = cfg.Token + return nil +} + +// fetchToken posts to a Vault auth endpoint and extracts the client token. +func (c *Client) fetchToken(authPath string, payload any) (string, error) { + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + url := fmt.Sprintf("%s/v1/%s", c.addr, authPath) + resp, err := c.http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Auth struct { + ClientToken string `json:"client_token"` + } `json:"auth"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + if result.Auth.ClientToken == "" { + return "", fmt.Errorf("empty client token in response") + } + return result.Auth.ClientToken, nil +} + +// --------------------------------------------------------------------------- +// request helpers +// --------------------------------------------------------------------------- + +// Post sends an authenticated POST to a Vault path and decodes the response. +func (c *Client) Post(path string, payload, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + url := fmt.Sprintf("%s/v1/%s", c.addr, path) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("X-Vault-Token", c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("vault POST %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("vault POST %s: unexpected status %d", path, resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/internal/vault/client_test.go b/internal/vault/client_test.go new file mode 100644 index 0000000..89df15b --- /dev/null +++ b/internal/vault/client_test.go @@ -0,0 +1,283 @@ +package vault_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.unkin.net/unkin/certmanager/internal/config" + "git.unkin.net/unkin/certmanager/internal/vault" +) + +// tokenHandler returns a handler that serves a fixed Vault token on POST. +func tokenHandler(token string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "auth": map[string]any{"client_token": token}, + }) + } +} + +func newTLSServer(t *testing.T, mux *http.ServeMux) *httptest.Server { + t.Helper() + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// --------------------------------------------------------------------------- +// AppRole +// --------------------------------------------------------------------------- + +func TestNew_AppRole(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/auth/approle/login", tokenHandler("tok-approle")) + srv := newTLSServer(t, mux) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + ApprolePath: "approle", + RoleID: "role-id", + }) + if err != nil { + t.Fatalf("AppRole auth failed: %v", err) + } +} + +func TestNew_AppRole_WithSecretID(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["secret_id"] != "my-secret" { + http.Error(w, "missing secret_id", http.StatusBadRequest) + return + } + tokenHandler("tok-approle-secret")(w, r) + }) + srv := newTLSServer(t, mux) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + ApprolePath: "approle", + RoleID: "role-id", + SecretID: "my-secret", + }) + if err != nil { + t.Fatalf("AppRole+SecretID auth failed: %v", err) + } +} + +func TestNew_AppRole_DefaultPath(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/auth/approle/login", tokenHandler("tok")) + srv := newTLSServer(t, mux) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + RoleID: "role-id", + // ApprolePath intentionally omitted — should default to "approle" + }) + if err != nil { + t.Fatalf("AppRole default path failed: %v", err) + } +} + +// --------------------------------------------------------------------------- +// LDAP +// --------------------------------------------------------------------------- + +func TestNew_LDAP(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/auth/ldap/login/testuser", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["password"] != "secret" { + http.Error(w, "bad password", http.StatusForbidden) + return + } + tokenHandler("tok-ldap")(w, r) + }) + srv := newTLSServer(t, mux) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodLDAP, + LDAPPath: "ldap", + LDAPUsername: "testuser", + LDAPPassword: "secret", + }) + if err != nil { + t.Fatalf("LDAP auth failed: %v", err) + } +} + +func TestNew_LDAP_DefaultPath(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/auth/ldap/login/u", tokenHandler("tok")) + srv := newTLSServer(t, mux) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodLDAP, + LDAPUsername: "u", + LDAPPassword: "p", + }) + if err != nil { + t.Fatalf("LDAP default path failed: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Kubernetes +// --------------------------------------------------------------------------- + +func TestNew_Kubernetes(t *testing.T) { + tmp := t.TempDir() + tokenFile := filepath.Join(tmp, "token") + os.WriteFile(tokenFile, []byte("k8s-jwt"), 0o600) + + mux := http.NewServeMux() + mux.HandleFunc("/v1/auth/kubernetes/login", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["jwt"] != "k8s-jwt" || body["role"] != "puppet" { + http.Error(w, "bad jwt/role", http.StatusForbidden) + return + } + tokenHandler("tok-k8s")(w, r) + }) + srv := newTLSServer(t, mux) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodKubernetes, + KubernetesPath: "kubernetes", + KubernetesRole: "puppet", + KubernetesTokenFile: tokenFile, + }) + if err != nil { + t.Fatalf("Kubernetes auth failed: %v", err) + } +} + +func TestNew_Kubernetes_MissingTokenFile(t *testing.T) { + _, err := vault.New(config.VaultConfig{ + Addr: "https://vault.example.com", + AuthMethod: config.AuthMethodKubernetes, + KubernetesRole: "puppet", + KubernetesTokenFile: "/nonexistent/token", + }) + if err == nil || !strings.Contains(err.Error(), "read service account token") { + t.Errorf("expected token file error, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Token +// --------------------------------------------------------------------------- + +func TestNew_Token(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/pki_int/issue/role", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Vault-Token") != "static-token" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "certificate": "C", "private_key": "K", "issuing_ca": "CA", + }, + }) + }) + srv := newTLSServer(t, mux) + + client, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodToken, + Token: "static-token", + }) + if err != nil { + t.Fatalf("Token auth failed: %v", err) + } + + var out map[string]any + if err := client.Post("pki_int/issue/role", map[string]string{"common_name": "x"}, &out); err != nil { + t.Fatalf("Post with token auth failed: %v", err) + } +} + +func TestNew_Token_Empty(t *testing.T) { + _, err := vault.New(config.VaultConfig{ + Addr: "https://vault.example.com", + AuthMethod: config.AuthMethodToken, + Token: "", + }) + if err == nil { + t.Error("expected error for empty token") + } +} + +// --------------------------------------------------------------------------- +// Unknown auth method +// --------------------------------------------------------------------------- + +func TestNew_UnknownMethod(t *testing.T) { + _, err := vault.New(config.VaultConfig{ + Addr: "https://vault.example.com", + AuthMethod: "magic", + }) + if err == nil || !strings.Contains(err.Error(), "unknown auth_method") { + t.Errorf("expected unknown method error, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Auth failure +// --------------------------------------------------------------------------- + +func TestNew_AuthFailure(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + ApprolePath: "approle", + RoleID: "role-id", + }) + if err == nil { + t.Error("expected auth error, got nil") + } +} + +func TestNew_EmptyToken(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "auth": map[string]any{"client_token": ""}, + }) + })) + t.Cleanup(srv.Close) + + _, err := vault.New(config.VaultConfig{ + Addr: srv.URL, + AuthMethod: config.AuthMethodAppRole, + ApprolePath: "approle", + RoleID: "role-id", + }) + if err == nil || !strings.Contains(err.Error(), "empty client token") { + t.Errorf("expected empty token error, got %v", err) + } +}