master
go 237 lines 8.52 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package gcp
4
5 import (
6 "context"
7 "crypto"
8 cryptorand "crypto/rand"
9 "crypto/rsa"
10 "crypto/sha256"
11 "crypto/x509"
12 "encoding/base64"
13 "encoding/json"
14 "encoding/pem"
15 "fmt"
16 "io"
17 "net/http"
18 "net/url"
19 "os"
20 "strings"
21 "time"
22
23 "github.com/netdata/netdata/go/plugins/logger"
24 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
25 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
26 )
27
28 func (s *publishedStore) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
29 return s.resolve(ctx, req)
30 }
31
32 func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
33 project, secretName, version, ok := parseOperand(req.Operand)
34 if !ok {
35 return "", fmt.Errorf("resolving secret '%s': store '%s': operand must be in format 'project/secret' or 'project/secret/version'", req.Original, req.StoreKey)
36 }
37 if !reGCPSafeProjectID.MatchString(project) {
38 return "", fmt.Errorf("resolving secret '%s': store '%s': invalid project ID '%s'", req.Original, req.StoreKey, project)
39 }
40 if !reGCPSafeName.MatchString(secretName) {
41 return "", fmt.Errorf("resolving secret '%s': store '%s': invalid secret name '%s'", req.Original, req.StoreKey, secretName)
42 }
43 if !reGCPSafeName.MatchString(version) {
44 return "", fmt.Errorf("resolving secret '%s': store '%s': invalid version '%s'", req.Original, req.StoreKey, version)
45 }
46
47 token, err := s.accessToken(ctx)
48 if err != nil {
49 return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
50 }
51
52 httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s/versions/%s:access", project, secretName, version), nil)
53 if err != nil {
54 return "", fmt.Errorf("resolving secret '%s': store '%s': creating request: %w", req.Original, req.StoreKey, err)
55 }
56 httpReq.Header.Set("Authorization", "Bearer "+token)
57 resp, err := s.runtime.apiClient.Do(httpReq)
58 if err != nil {
59 return "", fmt.Errorf("resolving secret '%s': store '%s': request failed: %w", req.Original, req.StoreKey, err)
60 }
61 defer resp.Body.Close()
62 body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
63 if err != nil {
64 return "", fmt.Errorf("resolving secret '%s': store '%s': reading response: %w", req.Original, req.StoreKey, err)
65 }
66 if resp.StatusCode != http.StatusOK {
67 return "", fmt.Errorf("resolving secret '%s': store '%s': GCP Secret Manager returned HTTP %d: %s", req.Original, req.StoreKey, resp.StatusCode, httpx.TruncateBody(body))
68 }
69 var result struct {
70 Payload struct {
71 Data string `json:"data"`
72 } `json:"payload"`
73 }
74 if err := json.Unmarshal(body, &result); err != nil {
75 return "", fmt.Errorf("resolving secret '%s': store '%s': parsing response: %w", req.Original, req.StoreKey, err)
76 }
77 decoded, err := base64.StdEncoding.DecodeString(result.Payload.Data)
78 if err != nil {
79 return "", fmt.Errorf("resolving secret '%s': store '%s': decoding secret data: %w", req.Original, req.StoreKey, err)
80 }
81 logResolvedRequest(ctx, req, project, secretName, version)
82 return string(decoded), nil
83 }
84
85 func logResolvedRequest(ctx context.Context, req secretstore.ResolveRequest, project, secretName, version string) {
86 if log, ok := logger.LoggerFromContext(ctx); ok {
87 log.Infof("resolved secret via gcp-sm secretstore '%s' project '%s' secret '%s' version '%s'", req.StoreKey, project, secretName, version)
88 }
89 }
90
91 func parseOperand(operand string) (string, string, string, bool) {
92 project, rest, ok := strings.Cut(operand, "/")
93 if !ok || project == "" || rest == "" {
94 return "", "", "", false
95 }
96 secretName, version, hasVersion := strings.Cut(rest, "/")
97 if !hasVersion || version == "" {
98 version = "latest"
99 }
100 return project, secretName, version, secretName != ""
101 }
102
103 func (s *publishedStore) accessToken(ctx context.Context) (string, error) {
104 switch s.mode {
105 case "metadata":
106 return s.metadataToken(ctx)
107 case "service_account_file":
108 path := s.serviceAccountFilePath
109 if path == "" {
110 return "", fmt.Errorf("mode_service_account_file.path is required")
111 }
112 return s.serviceAccountToken(ctx, path)
113 default:
114 return "", fmt.Errorf("mode '%s' is invalid for gcp-sm", s.mode)
115 }
116 }
117
118 func (s *publishedStore) metadataToken(ctx context.Context) (string, error) {
119 req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", nil)
120 if err != nil {
121 return "", fmt.Errorf("creating metadata token request: %w", err)
122 }
123 req.Header.Set("Metadata-Flavor", "Google")
124 resp, err := s.runtime.metadataClient.Do(req)
125 if err != nil {
126 return "", fmt.Errorf("metadata token request failed: %w", err)
127 }
128 defer resp.Body.Close()
129 body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
130 if err != nil {
131 return "", fmt.Errorf("reading metadata token response: %w", err)
132 }
133 if resp.StatusCode != http.StatusOK {
134 return "", fmt.Errorf("metadata token request returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
135 }
136 var result struct {
137 AccessToken string `json:"access_token"`
138 }
139 if err := json.Unmarshal(body, &result); err != nil {
140 return "", fmt.Errorf("parsing metadata token response: %w", err)
141 }
142 if result.AccessToken == "" {
143 return "", fmt.Errorf("metadata token response missing access_token")
144 }
145 return result.AccessToken, nil
146 }
147
148 func (s *publishedStore) serviceAccountToken(ctx context.Context, credFile string) (string, error) {
149 data, err := os.ReadFile(credFile)
150 if err != nil {
151 return "", fmt.Errorf("reading service account file '%s': %w", credFile, err)
152 }
153 var sa struct {
154 ClientEmail string `json:"client_email"`
155 PrivateKey string `json:"private_key"`
156 TokenURI string `json:"token_uri"`
157 }
158 if err := json.Unmarshal(data, &sa); err != nil {
159 return "", fmt.Errorf("parsing service account JSON: %w", err)
160 }
161 if sa.ClientEmail == "" || sa.PrivateKey == "" || sa.TokenURI == "" {
162 return "", fmt.Errorf("service account JSON missing required fields (client_email, private_key, token_uri)")
163 }
164 now := time.Now().Unix()
165 signedJWT, err := createSignedJWT(sa.ClientEmail, sa.TokenURI, sa.PrivateKey, now)
166 if err != nil {
167 return "", err
168 }
169 form := url.Values{
170 "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
171 "assertion": {signedJWT},
172 }
173 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, sa.TokenURI, strings.NewReader(form.Encode()))
174 if err != nil {
175 return "", fmt.Errorf("creating token exchange request: %w", err)
176 }
177 httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
178 resp, err := s.runtime.apiClient.Do(httpReq)
179 if err != nil {
180 return "", fmt.Errorf("token exchange request failed: %w", err)
181 }
182 defer resp.Body.Close()
183 body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
184 if err != nil {
185 return "", fmt.Errorf("reading token exchange response: %w", err)
186 }
187 if resp.StatusCode != http.StatusOK {
188 return "", fmt.Errorf("token exchange returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
189 }
190 var result struct {
191 AccessToken string `json:"access_token"`
192 }
193 if err := json.Unmarshal(body, &result); err != nil {
194 return "", fmt.Errorf("parsing token exchange response: %w", err)
195 }
196 if result.AccessToken == "" {
197 return "", fmt.Errorf("token exchange response missing access_token")
198 }
199 return result.AccessToken, nil
200 }
201
202 func createSignedJWT(clientEmail, tokenURI, privateKeyPEM string, nowUnix int64) (string, error) {
203 header := `{"alg":"RS256","typ":"JWT"}`
204 claimsMap := map[string]any{
205 "iss": clientEmail,
206 "scope": "https://www.googleapis.com/auth/cloud-platform",
207 "aud": tokenURI,
208 "iat": nowUnix,
209 "exp": nowUnix + 3600,
210 }
211 claimsJSON, err := json.Marshal(claimsMap)
212 if err != nil {
213 return "", fmt.Errorf("marshaling JWT claims: %w", err)
214 }
215 headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header))
216 claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
217 unsigned := headerB64 + "." + claimsB64
218
219 block, _ := pem.Decode([]byte(privateKeyPEM))
220 if block == nil {
221 return "", fmt.Errorf("failed to decode PEM private key")
222 }
223 key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
224 if err != nil {
225 return "", fmt.Errorf("parsing private key: %w", err)
226 }
227 rsaKey, ok := key.(*rsa.PrivateKey)
228 if !ok {
229 return "", fmt.Errorf("private key is not RSA")
230 }
231 hashed := sha256.Sum256([]byte(unsigned))
232 sig, err := rsa.SignPKCS1v15(cryptorand.Reader, rsaKey, crypto.SHA256, hashed[:])
233 if err != nil {
234 return "", fmt.Errorf("signing JWT: %w", err)
235 }
236 return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
237 }