From 4d7e96cec430de698b4bc464bc33f33a1b64d97c Mon Sep 17 00:00:00 2001 From: Ilmar Kerm Date: Mon, 6 Oct 2025 16:22:10 +0000 Subject: [PATCH 1/5] Signed-off-by: Ilmar Kerm First edition of HashiCorp Vault support --- collector/config.go | 51 ++++++++ go.mod | 16 +++ go.sum | 33 ++++++ hashivault/hashivault.go | 130 +++++++++++++++++++++ hashivault/hashivault_test.go | 53 +++++++++ site/docs/configuration/hashicorp-vault.md | 58 +++++++++ 6 files changed, 341 insertions(+) create mode 100644 hashivault/hashivault.go create mode 100644 hashivault/hashivault_test.go create mode 100644 site/docs/configuration/hashicorp-vault.md diff --git a/collector/config.go b/collector/config.go index 8a66539..516399b 100644 --- a/collector/config.go +++ b/collector/config.go @@ -8,6 +8,7 @@ import ( "github.com/godror/godror/dsn" "github.com/oracle/oracle-db-appdev-monitoring/azvault" "github.com/oracle/oracle-db-appdev-monitoring/ocivault" + "github.com/oracle/oracle-db-appdev-monitoring/hashivault" "github.com/prometheus/exporter-toolkit/web" "gopkg.in/yaml.v2" "log/slog" @@ -58,6 +59,8 @@ type VaultConfig struct { OCI *OCIVault `yaml:"oci"` // Azure if present, Azure vault will be used to load username and/or password. Azure *AZVault `yaml:"azure"` + // HashiCorp Vault if present. HashiCorp Vault will be used to fetch database credentials. + HashiCorp *HashiCorpVault `yaml:"hashicorp"` } type OCIVault struct { @@ -72,6 +75,17 @@ type AZVault struct { PasswordSecret string `yaml:"passwordSecret"` } +type HashiCorpVault struct { + Socket string `yaml:"proxySocket"` + MountType string `yaml:"mountType"` + MountName string `yaml:"mountName"` + SecretPath string `yaml:"secretPath"` + UsernameAttr string `yaml:"usernameAttribute"` + PasswordAttr string `yaml:"passwordAttribute"` + // Private to avoid making multiple calls + fetchedSecert map[string]string +} + type MetricsFilesConfig struct { Default string Custom []string @@ -146,6 +160,31 @@ func (c ConnectConfig) GetQueryTimeout() int { return *c.QueryTimeout } +func (h HashiCorpVault)GetUsernameAttr() string { + if h.UsernameAttr == "" { + return "username" + } + return h.UsernameAttr +} + +func (h HashiCorpVault)GetPasswordAttr() string { + if h.PasswordAttr == "" { + return "password" + } + return h.PasswordAttr +} + +func (d DatabaseConfig) fetchHashiCorpVaultSecret() { + if len(d.Vault.HashiCorp.fetchedSecert) > 0 { + // Secret is already fetched, do nothing + return + } + vc := hashivault.CreateVaultClient(d.Vault.HashiCorp.Socket) + // Set default username and password attribute values + requiredKeys := []string{d.Vault.HashiCorp.GetUsernameAttr(), d.Vault.HashiCorp.GetPasswordAttr()} + d.Vault.HashiCorp.fetchedSecert = vc.GetVaultSecret(d.Vault.HashiCorp.MountType, d.Vault.HashiCorp.MountName, d.Vault.HashiCorp.SecretPath, requiredKeys) +} + func (d DatabaseConfig) GetUsername() string { if d.isOCIVault() && d.Vault.OCI.UsernameSecret != "" { return ocivault.GetVaultSecret(d.Vault.OCI.ID, d.Vault.OCI.UsernameSecret) @@ -153,6 +192,10 @@ func (d DatabaseConfig) GetUsername() string { if d.isAzureVault() && d.Vault.Azure.UsernameSecret != "" { return azvault.GetVaultSecret(d.Vault.Azure.ID, d.Vault.Azure.UsernameSecret) } + if d.isHashiCorpVault() && d.Vault.HashiCorp.MountType != "" && d.Vault.HashiCorp.MountName != "" && d.Vault.HashiCorp.SecretPath != "" { + d.fetchHashiCorpVaultSecret() + return d.Vault.HashiCorp.fetchedSecert[d.Vault.HashiCorp.GetUsernameAttr()] + } return d.Username } @@ -171,6 +214,10 @@ func (d DatabaseConfig) GetPassword() string { if d.isAzureVault() && d.Vault.Azure.PasswordSecret != "" { return azvault.GetVaultSecret(d.Vault.Azure.ID, d.Vault.Azure.PasswordSecret) } + if d.isHashiCorpVault() && d.Vault.HashiCorp.MountType != "" && d.Vault.HashiCorp.MountName != "" && d.Vault.HashiCorp.SecretPath != "" { + d.fetchHashiCorpVaultSecret() + return d.Vault.HashiCorp.fetchedSecert[d.Vault.HashiCorp.GetPasswordAttr()] + } return d.Password } @@ -182,6 +229,10 @@ func (d DatabaseConfig) isAzureVault() bool { return d.Vault != nil && d.Vault.Azure != nil } +func (d DatabaseConfig) isHashiCorpVault() bool { + return d.Vault != nil && d.Vault.HashiCorp != nil +} + func LoadMetricsConfiguration(logger *slog.Logger, cfg *Config, path string, flags *web.FlagConfig) (*MetricsConfiguration, error) { m := &MetricsConfiguration{} if len(cfg.ConfigFile) > 0 { diff --git a/go.mod b/go.mod index 58984c2..6b618e7 100644 --- a/go.mod +++ b/go.mod @@ -23,22 +23,37 @@ require ( github.com/VictoriaMetrics/easyproto v0.1.4 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/godror/knownpb v0.3.0 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/mdlayher/vsock v1.2.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sony/gobreaker v0.5.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect @@ -50,5 +65,6 @@ require ( golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index b46fff5..b55e7a8 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAu github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -63,6 +65,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= @@ -97,6 +101,27 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= @@ -116,6 +141,10 @@ github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= @@ -182,6 +211,8 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0 github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -315,6 +346,8 @@ golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/hashivault/hashivault.go b/hashivault/hashivault.go new file mode 100644 index 0000000..aaee2d8 --- /dev/null +++ b/hashivault/hashivault.go @@ -0,0 +1,130 @@ +package hashivault + +import ( + "context" + "os" + "strings" + "errors" + "net" + "net/http" + "time" + + "github.com/prometheus/common/promslog" + vault "github.com/hashicorp/vault/api" +) + +//var FailedConnect = errors.New("Failed to connect to HashiCorp Vault") +var UnsupportedMountType = errors.New("Unsupported HashiCorp Vault mount type") +var RequiredKeyMissing = errors.New("Required key missing from HashiCorp Vault secret") + +type HashicorpVaultClient struct { + client *vault.Client +} + +func newUnixSocketVaultClient(socketPath string) (*vault.Client, error) { + // Create a custom HTTP client using the Unix socket + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + Timeout: 10 * time.Second, + } + + // Configure the Vault client + config := &vault.Config{ + Address: "http://unix", + HttpClient: httpClient, + Timeout: 10 * time.Second, + MinRetryWait: time.Millisecond * 1000, + MaxRetryWait: time.Millisecond * 1500, + MaxRetries: 2, + //Backoff: retryablehttp.LinearJitterBackoff, + } + + client, err := vault.NewClient(config) + if err != nil { + return nil, err + } + return client, nil +} + +func createVaultClient(socketPath string) (HashicorpVaultClient,error) { + /* + Connects client to Vault backend, need to handle different connection methods in the future + */ + promLogConfig := &promslog.Config{} + logger := promslog.New(promLogConfig) + + var vaultClient HashicorpVaultClient + var err error + + if socketPath != "" { + // Create Vault client that uses Unix Socket + vaultClient.client, err = newUnixSocketVaultClient(socketPath) + } + if err != nil { + logger.Error("Failed to connect to HashiCorp Vault", "err", err) + } + return vaultClient,err +} + +func CreateVaultClient(socketPath string) HashicorpVaultClient { + // Public callable function that does not return an error, just exits instead. Like other vault code in this project. + c,err := createVaultClient(socketPath) + if err != nil { + os.Exit(1) + } + return c +} + +func (c HashicorpVaultClient)getVaultSecret(mountType string, mount string, path string, requiredKeys []string) (map[string]string,error) { + // Proper code that returns and error and is testable + // Currently only supports key-value stores, but it should support more in the future + promLogConfig := &promslog.Config{} + logger := promslog.New(promLogConfig) + + result := map[string]string{} + var err error + if mountType == "kvv2" || mountType == "kvv1" { + // Handle simple key-value secrets + var secret *vault.KVSecret + logger.Info("Making call to HashiCorp Vault", "mountType", mountType, "mountName", mount, "secretPath", path, "expectedKeys", requiredKeys) + if mountType == "kvv2" { + secret, err = c.client.KVv2(mount).Get(context.TODO(), path) + } else { + secret, err = c.client.KVv1(mount).Get(context.TODO(), path) + } + if err != nil { + logger.Error("Failed to fetch secret from HashiCorp Vault", "err", err) + return result, err + } + // Expect simple one-level JSON, remap interface{} straight to string + for key,val := range secret.Data { + result[key] = strings.TrimRight(val.(string), "\r\n") // make sure a \r and/or \n didn't make it into the secret + } + } else { + logger.Error(UnsupportedMountType.Error()) + return result, UnsupportedMountType + } + // Check that we have all required keys present + for _, key := range requiredKeys { + val, keyExists := result[key] + if !keyExists || val == "" { + logger.Error(RequiredKeyMissing.Error(), "key", key) + return result, RequiredKeyMissing + } + } + // + return result, nil +} + +func (c HashicorpVaultClient)GetVaultSecret(mountType string, mount string, path string, requiredKeys []string) map[string]string { + // Public callable function that does not return an error, just exits instead. Like other vault code in this project. + res,err := c.getVaultSecret(mountType, mount, path, requiredKeys) + if err != nil { + os.Exit(1) + } + return res +} diff --git a/hashivault/hashivault_test.go b/hashivault/hashivault_test.go new file mode 100644 index 0000000..08715f5 --- /dev/null +++ b/hashivault/hashivault_test.go @@ -0,0 +1,53 @@ +package hashivault + +import ( + "testing" +) + +/* + Performs some integration tests against a running Vault proxy +*/ + +const ( + // TODO: Mock the entire Vault response and do not depend on external Vault + socketPath = "/var/run/vault/vault.sock" + testMount = "dev.mt1" + testPath = "oracle/devdbs01/monitoring" +) + +func TestHashiCorpVaultKVV2Secret(t *testing.T) { + c,err := createVaultClient(socketPath) + if err != nil { + t.Error(err) + return + } + _, err = c.getVaultSecret("kvv2", testMount, testPath, []string{"password","username"}) + if err != nil { + t.Error(err) + } +} + +func TestHashiCorpVaultMissingKey(t *testing.T) { + c,err := createVaultClient(socketPath) + if err != nil { + t.Error(err) + return + } + _, err = c.getVaultSecret("kvv2", testMount, testPath, []string{"password","username","keythatdoesnotexist"}) + if err == nil || err != RequiredKeyMissing { + t.Error("Wrong error code, expected RequiredKeyMissing") + } +} + +func TestHashiCorpVaultUnsupportedSecret(t *testing.T) { + c,err := createVaultClient(socketPath) + if err != nil { + t.Error(err) + return + } + _, err = c.getVaultSecret("doesnotexist", testMount, testPath, []string{"password","username"}) + if err == nil || err != UnsupportedMountType { + t.Error("Wrong error code, expected UnsupportedMountType") + } +} + diff --git a/site/docs/configuration/hashicorp-vault.md b/site/docs/configuration/hashicorp-vault.md new file mode 100644 index 0000000..4939927 --- /dev/null +++ b/site/docs/configuration/hashicorp-vault.md @@ -0,0 +1,58 @@ +--- +title: HashiCorp Vault +sidebar_position: 8 +--- + +# HashiCorp Vault + +Securely load database credentials from HashiCorp Vault. + +Each database in the config file may be configured to use HashiCorp Vault. To load the database username and/or password from HashiCorp Vault, set the `vault.hashicorp` property to contain the following information: + +```yaml +databases: + mydb: + vault: + hashicorp: + proxySocket: /var/run/vault/vault.sock + mountType: secret engine type, currently either "kvv1" or "kvv2" + mountName: secret engine mount path + secretPath: path of the secret + usernameAttribute: name of the JSON attribute, where to read the database username, if ommitted defaults to "username" + passwordAttribute: name of the JSON attribute, where to read the database password, if ommitted defaults to "password" +``` + +Example + +```yaml +databases: + mydb: + vault: + hashicorp: + proxySocket: /var/run/vault/vault.sock + mountType: kvv2 + mountName: dev + secretPath: oracle/mydb/monitoring +``` + +### Authentication + +In this first version it currently only supports queries via HashiCorp Vault Proxy configured to run on the local host and listening on a Unix socket. Currently also required use_auto_auth_token option to be set. +Will expand the support for other methods in the future. + +Example Vault Proxy configuration snippet: + +``` +listener "unix" { + address = "/var/run/vault/vault.sock" + socket_mode = "0660" + socket_user = "vault" + socket_group = "vaultaccess" + tls_disable = true +} + +api_proxy { + # This always uses the auto_auth token when communicating with Vault server, even if client does not send a token + use_auto_auth_token = true +} +``` From 0c92afbe33f97301596881812fd76651452489e9 Mon Sep 17 00:00:00 2001 From: Ilmar Kerm Date: Sat, 11 Oct 2025 14:08:47 +0200 Subject: [PATCH 2/5] Signed-off-by: Ilmar Kerm Some style fixes based on PR comments --- collector/config.go | 4 ++-- hashivault/hashivault.go | 38 ++++++++++++++------------------------ 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/collector/config.go b/collector/config.go index 516399b..d7bd820 100644 --- a/collector/config.go +++ b/collector/config.go @@ -160,14 +160,14 @@ func (c ConnectConfig) GetQueryTimeout() int { return *c.QueryTimeout } -func (h HashiCorpVault)GetUsernameAttr() string { +func (h HashiCorpVault) GetUsernameAttr() string { if h.UsernameAttr == "" { return "username" } return h.UsernameAttr } -func (h HashiCorpVault)GetPasswordAttr() string { +func (h HashiCorpVault) GetPasswordAttr() string { if h.PasswordAttr == "" { return "password" } diff --git a/hashivault/hashivault.go b/hashivault/hashivault.go index aaee2d8..ed31516 100644 --- a/hashivault/hashivault.go +++ b/hashivault/hashivault.go @@ -1,19 +1,21 @@ +// Copyright (c) 2025, Oracle and/or its affiliates. +// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. + package hashivault import ( "context" - "os" "strings" "errors" "net" "net/http" "time" + "github.com/oracle/oci-go-sdk/v65/example/helpers" "github.com/prometheus/common/promslog" vault "github.com/hashicorp/vault/api" ) -//var FailedConnect = errors.New("Failed to connect to HashiCorp Vault") var UnsupportedMountType = errors.New("Unsupported HashiCorp Vault mount type") var RequiredKeyMissing = errors.New("Required key missing from HashiCorp Vault secret") @@ -21,8 +23,8 @@ type HashicorpVaultClient struct { client *vault.Client } +// newUnixSocketVaultClient creates a custom HTTP client using a Unix socket func newUnixSocketVaultClient(socketPath string) (*vault.Client, error) { - // Create a custom HTTP client using the Unix socket httpClient := &http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { @@ -40,20 +42,13 @@ func newUnixSocketVaultClient(socketPath string) (*vault.Client, error) { MinRetryWait: time.Millisecond * 1000, MaxRetryWait: time.Millisecond * 1500, MaxRetries: 2, - //Backoff: retryablehttp.LinearJitterBackoff, } - client, err := vault.NewClient(config) - if err != nil { - return nil, err - } - return client, nil + return vault.NewClient(config) } +// createVaultClient connects to a vault client, using connection method specified with the parameters. Returns error if fails. func createVaultClient(socketPath string) (HashicorpVaultClient,error) { - /* - Connects client to Vault backend, need to handle different connection methods in the future - */ promLogConfig := &promslog.Config{} logger := promslog.New(promLogConfig) @@ -70,18 +65,15 @@ func createVaultClient(socketPath string) (HashicorpVaultClient,error) { return vaultClient,err } +// CreateVaultClient connects to a vault client, using connection method specified with the parameters. Fatal if fails. func CreateVaultClient(socketPath string) HashicorpVaultClient { - // Public callable function that does not return an error, just exits instead. Like other vault code in this project. c,err := createVaultClient(socketPath) - if err != nil { - os.Exit(1) - } + helpers.FatalIfError(err) return c } -func (c HashicorpVaultClient)getVaultSecret(mountType string, mount string, path string, requiredKeys []string) (map[string]string,error) { - // Proper code that returns and error and is testable - // Currently only supports key-value stores, but it should support more in the future +// getVaultSecret fetches secret from vault using specified mount type. Returns error on failure. +func (c HashicorpVaultClient) getVaultSecret(mountType string, mount string, path string, requiredKeys []string) (map[string]string,error) { promLogConfig := &promslog.Config{} logger := promslog.New(promLogConfig) @@ -116,15 +108,13 @@ func (c HashicorpVaultClient)getVaultSecret(mountType string, mount string, path return result, RequiredKeyMissing } } - // return result, nil } -func (c HashicorpVaultClient)GetVaultSecret(mountType string, mount string, path string, requiredKeys []string) map[string]string { +// GetVaultSecret fetches secret from vault using specified mount type. Fatal on failure. +func (c HashicorpVaultClient) GetVaultSecret(mountType string, mount string, path string, requiredKeys []string) map[string]string { // Public callable function that does not return an error, just exits instead. Like other vault code in this project. res,err := c.getVaultSecret(mountType, mount, path, requiredKeys) - if err != nil { - os.Exit(1) - } + helpers.FatalIfError(err) return res } From 083333934fe11a63db9a397e7a754494dcc17a72 Mon Sep 17 00:00:00 2001 From: Ilmar Kerm Date: Fri, 17 Oct 2025 11:57:57 +0200 Subject: [PATCH 3/5] Moved into HashiCorp recommended Docker based testing --- hashivault/hashivault_test.go | 58 +++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/hashivault/hashivault_test.go b/hashivault/hashivault_test.go index 08715f5..2a4a3b1 100644 --- a/hashivault/hashivault_test.go +++ b/hashivault/hashivault_test.go @@ -2,26 +2,61 @@ package hashivault import ( "testing" + "log/slog" + vault "github.com/hashicorp/vault/api" + // requires go1.25 + "github.com/hashicorp/vault/sdk/helper/testcluster/docker" ) /* - Performs some integration tests against a running Vault proxy + Removing the tests until project is upgraded to go1.25 + https://github.com/hashicorp/vault?tab=readme-ov-file#docker-based-tests */ const ( - // TODO: Mock the entire Vault response and do not depend on external Vault - socketPath = "/var/run/vault/vault.sock" - testMount = "dev.mt1" - testPath = "oracle/devdbs01/monitoring" + dockerImageRepo = "hashicorp/vault" + dockerImageTag = "latest" + kvTestMount = "secret" + kvTestPath = "foo1" + kvTestSecret = map[string]string{ + "username": "c##monitoring", + "password": "ep82^RxU>iqE%ZMWr!}UmtM50?~C@P", + "user": "monitoring", + "pass": "kz)7E9nJm9BDpYM0=T5Me#YGwQv?pW", + } ) -func TestHashiCorpVaultKVV2Secret(t *testing.T) { - c,err := createVaultClient(socketPath) - if err != nil { - t.Error(err) - return +// createVaultServer Starts local Vault server for testing purposes +func createVaultServerAndClient(t *testing.T) (HashicorpVaultClient, *docker.DockerCluster) { + t.Helper() + + opts := &docker.DockerClusterOptions{ + ImageRepo: dockerImageRepo, + ImageTag: dockerImageTag, + } + cluster := docker.NewTestDockerCluster(t, opts) + client := cluster.Nodes()[0].APIClient() + hvc := HashicorpVaultClient{ + client: client, + logger: slog.Default(), } - _, err = c.getVaultSecret("kvv2", testMount, testPath, []string{"password","username"}) + + return hvc, cluster +} + +func writeTestKVSecret(t *testing.T, c HashicorpVaultClient) { + t.Helper() + _,err := c.client.KVv2(kvTestMount).Put(context.TODO(), kvTestPath, kvTestSecret) + if err != nil { + t.Fatal(err) + } +} + +func TestHashiCorpVaultKVV2Secret(t *testing.T) { + c,cluster := createVaultServerAndClient(t) + defer cluster.Cleanup() + writeTestKVSecret(t, c) + _, err := c.getVaultSecret("kvv2", testMount, testPath, []string{"password","username"}) if err != nil { t.Error(err) } @@ -50,4 +85,3 @@ func TestHashiCorpVaultUnsupportedSecret(t *testing.T) { t.Error("Wrong error code, expected UnsupportedMountType") } } - From fda8398826b9700d77d0df785c556363b0f6af2e Mon Sep 17 00:00:00 2001 From: Ilmar Kerm Date: Fri, 17 Oct 2025 12:05:03 +0200 Subject: [PATCH 4/5] removed tests for now --- hashivault/hashivault_test.go | 87 ----------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 hashivault/hashivault_test.go diff --git a/hashivault/hashivault_test.go b/hashivault/hashivault_test.go deleted file mode 100644 index 2a4a3b1..0000000 --- a/hashivault/hashivault_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package hashivault - -import ( - "testing" - "log/slog" - vault "github.com/hashicorp/vault/api" - // requires go1.25 - "github.com/hashicorp/vault/sdk/helper/testcluster/docker" -) - -/* - Removing the tests until project is upgraded to go1.25 - https://github.com/hashicorp/vault?tab=readme-ov-file#docker-based-tests -*/ - -const ( - dockerImageRepo = "hashicorp/vault" - dockerImageTag = "latest" - kvTestMount = "secret" - kvTestPath = "foo1" - kvTestSecret = map[string]string{ - "username": "c##monitoring", - "password": "ep82^RxU>iqE%ZMWr!}UmtM50?~C@P", - "user": "monitoring", - "pass": "kz)7E9nJm9BDpYM0=T5Me#YGwQv?pW", - } -) - -// createVaultServer Starts local Vault server for testing purposes -func createVaultServerAndClient(t *testing.T) (HashicorpVaultClient, *docker.DockerCluster) { - t.Helper() - - opts := &docker.DockerClusterOptions{ - ImageRepo: dockerImageRepo, - ImageTag: dockerImageTag, - } - cluster := docker.NewTestDockerCluster(t, opts) - client := cluster.Nodes()[0].APIClient() - hvc := HashicorpVaultClient{ - client: client, - logger: slog.Default(), - } - - return hvc, cluster -} - -func writeTestKVSecret(t *testing.T, c HashicorpVaultClient) { - t.Helper() - _,err := c.client.KVv2(kvTestMount).Put(context.TODO(), kvTestPath, kvTestSecret) - if err != nil { - t.Fatal(err) - } -} - -func TestHashiCorpVaultKVV2Secret(t *testing.T) { - c,cluster := createVaultServerAndClient(t) - defer cluster.Cleanup() - writeTestKVSecret(t, c) - _, err := c.getVaultSecret("kvv2", testMount, testPath, []string{"password","username"}) - if err != nil { - t.Error(err) - } -} - -func TestHashiCorpVaultMissingKey(t *testing.T) { - c,err := createVaultClient(socketPath) - if err != nil { - t.Error(err) - return - } - _, err = c.getVaultSecret("kvv2", testMount, testPath, []string{"password","username","keythatdoesnotexist"}) - if err == nil || err != RequiredKeyMissing { - t.Error("Wrong error code, expected RequiredKeyMissing") - } -} - -func TestHashiCorpVaultUnsupportedSecret(t *testing.T) { - c,err := createVaultClient(socketPath) - if err != nil { - t.Error(err) - return - } - _, err = c.getVaultSecret("doesnotexist", testMount, testPath, []string{"password","username"}) - if err == nil || err != UnsupportedMountType { - t.Error("Wrong error code, expected UnsupportedMountType") - } -} From 13205c4215ee17512a65df0419d0981701d99a0d Mon Sep 17 00:00:00 2001 From: Ilmar Kerm Date: Fri, 17 Oct 2025 12:24:14 +0200 Subject: [PATCH 5/5] replaced promslog with slog --- collector/config.go | 2 +- hashivault/hashivault.go | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/collector/config.go b/collector/config.go index d7bd820..c7756a0 100644 --- a/collector/config.go +++ b/collector/config.go @@ -179,7 +179,7 @@ func (d DatabaseConfig) fetchHashiCorpVaultSecret() { // Secret is already fetched, do nothing return } - vc := hashivault.CreateVaultClient(d.Vault.HashiCorp.Socket) + vc := hashivault.CreateVaultClient(slog.Default(), d.Vault.HashiCorp.Socket) // Set default username and password attribute values requiredKeys := []string{d.Vault.HashiCorp.GetUsernameAttr(), d.Vault.HashiCorp.GetPasswordAttr()} d.Vault.HashiCorp.fetchedSecert = vc.GetVaultSecret(d.Vault.HashiCorp.MountType, d.Vault.HashiCorp.MountName, d.Vault.HashiCorp.SecretPath, requiredKeys) diff --git a/hashivault/hashivault.go b/hashivault/hashivault.go index ed31516..e7dbf8c 100644 --- a/hashivault/hashivault.go +++ b/hashivault/hashivault.go @@ -12,7 +12,7 @@ import ( "time" "github.com/oracle/oci-go-sdk/v65/example/helpers" - "github.com/prometheus/common/promslog" + "log/slog" vault "github.com/hashicorp/vault/api" ) @@ -21,6 +21,7 @@ var RequiredKeyMissing = errors.New("Required key missing from HashiCorp Vault s type HashicorpVaultClient struct { client *vault.Client + logger *slog.Logger } // newUnixSocketVaultClient creates a custom HTTP client using a Unix socket @@ -48,10 +49,7 @@ func newUnixSocketVaultClient(socketPath string) (*vault.Client, error) { } // createVaultClient connects to a vault client, using connection method specified with the parameters. Returns error if fails. -func createVaultClient(socketPath string) (HashicorpVaultClient,error) { - promLogConfig := &promslog.Config{} - logger := promslog.New(promLogConfig) - +func createVaultClient(logger *slog.Logger, socketPath string) (HashicorpVaultClient,error) { var vaultClient HashicorpVaultClient var err error @@ -62,34 +60,32 @@ func createVaultClient(socketPath string) (HashicorpVaultClient,error) { if err != nil { logger.Error("Failed to connect to HashiCorp Vault", "err", err) } + vaultClient.logger = logger return vaultClient,err } // CreateVaultClient connects to a vault client, using connection method specified with the parameters. Fatal if fails. -func CreateVaultClient(socketPath string) HashicorpVaultClient { - c,err := createVaultClient(socketPath) +func CreateVaultClient(logger *slog.Logger, socketPath string) HashicorpVaultClient { + c,err := createVaultClient(logger, socketPath) helpers.FatalIfError(err) return c } // getVaultSecret fetches secret from vault using specified mount type. Returns error on failure. func (c HashicorpVaultClient) getVaultSecret(mountType string, mount string, path string, requiredKeys []string) (map[string]string,error) { - promLogConfig := &promslog.Config{} - logger := promslog.New(promLogConfig) - result := map[string]string{} var err error if mountType == "kvv2" || mountType == "kvv1" { // Handle simple key-value secrets var secret *vault.KVSecret - logger.Info("Making call to HashiCorp Vault", "mountType", mountType, "mountName", mount, "secretPath", path, "expectedKeys", requiredKeys) + c.logger.Info("Making call to HashiCorp Vault", "mountType", mountType, "mountName", mount, "secretPath", path, "expectedKeys", requiredKeys) if mountType == "kvv2" { secret, err = c.client.KVv2(mount).Get(context.TODO(), path) } else { secret, err = c.client.KVv1(mount).Get(context.TODO(), path) } if err != nil { - logger.Error("Failed to fetch secret from HashiCorp Vault", "err", err) + c.logger.Error("Failed to fetch secret from HashiCorp Vault", "err", err) return result, err } // Expect simple one-level JSON, remap interface{} straight to string @@ -97,14 +93,14 @@ func (c HashicorpVaultClient) getVaultSecret(mountType string, mount string, pat result[key] = strings.TrimRight(val.(string), "\r\n") // make sure a \r and/or \n didn't make it into the secret } } else { - logger.Error(UnsupportedMountType.Error()) + c.logger.Error(UnsupportedMountType.Error()) return result, UnsupportedMountType } // Check that we have all required keys present for _, key := range requiredKeys { val, keyExists := result[key] if !keyExists || val == "" { - logger.Error(RequiredKeyMissing.Error(), "key", key) + c.logger.Error(RequiredKeyMissing.Error(), "key", key) return result, RequiredKeyMissing } }