pomerium/internal/zero/bootstrap/writers/k8s/secret.go
Joe Kralicky de603f87de
Add new configurable bootstrap writers (#2405) (#5114)
* Add new configurable bootstrap writers (#2405)

This PR adds the ability to configure different backends to use for
storing modifications to the zero bootstrap config. The two currently
implemented backends allow writing changes to a file or to a Kubernetes
secret. Backend selection is determined by the scheme in a URI passed to
the flag '--config-writeback-uri'.

In a Kubernetes environment, where the bootstrap config is mounted into
the pod from a secret, this option allows Pomerium to write changes back
to the secret, as writes to the mounted secret file on disk are not
persisted.

* Use env vars for bootstrap config filepath/writeback uri

* linter pass and code cleanup

* Add new config writer options mechanism

This moves the encryption cipher parameter out of the WriteConfig()
method in the ConfigWriter interface and into a new ConfigWriterOptions
struct. Options (e.g. cipher) can be applied to an existing ConfigWriter
to allow customizing implementation-specific behavior.

* Code cleanup/lint fixes

* Move vendored k8s code into separate package, and add license header and package comment
2024-05-31 12:26:17 -04:00

161 lines
4 KiB
Go

package k8s
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"gopkg.in/yaml.v3"
"github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/zero/bootstrap/writers"
"github.com/pomerium/pomerium/internal/zero/bootstrap/writers/k8s/rest"
"github.com/pomerium/pomerium/pkg/cryptutil"
cluster_api "github.com/pomerium/pomerium/pkg/zero/cluster"
)
func init() {
writers.RegisterBuilder("secret", newInClusterSecretWriter)
}
type secretWriter struct {
opts writers.ConfigWriterOptions
client *http.Client
apiserverURL *url.URL
namespace string
name string
key string
}
// WithOptions implements writers.ConfigWriter.
func (w *secretWriter) WithOptions(opts writers.ConfigWriterOptions) writers.ConfigWriter {
clone := *w
clone.opts = opts
return &clone
}
func newSecretWriterForConfig(uri *url.URL, config *rest.Config) (writers.ConfigWriter, error) {
parts := strings.SplitN(path.Join(uri.Host, uri.Path), "/", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return nil, fmt.Errorf("invalid secret uri %q, expecting format \"secret://namespace/name/key\"", uri.String())
} else if parts[0] == "" {
return nil, fmt.Errorf(`invalid secret uri %q (did you mean "secret:/%s"?)`, uri.String(), uri.Path)
}
u, err := url.Parse(config.Host)
if err != nil {
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = config.TLSClientConfig
client := &http.Client{
Transport: &roundTripper{
bearerToken: config.BearerToken,
base: transport,
},
}
return &secretWriter{
client: client,
apiserverURL: u,
namespace: parts[0],
name: parts[1],
key: parts[2],
}, nil
}
func newInClusterSecretWriter(uri *url.URL) (writers.ConfigWriter, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
return newSecretWriterForConfig(uri, config)
}
// WriteConfig implements ConfigWriter.
func (w *secretWriter) WriteConfig(ctx context.Context, src *cluster_api.BootstrapConfig) error {
u := w.apiserverURL.ResolveReference(&url.URL{
Path: path.Join("/api/v1/namespaces", w.namespace, "secrets", w.name),
RawQuery: url.Values{
"fieldManager": {"pomerium-zero"},
"force": {"true"},
}.Encode(),
})
data, err := json.Marshal(src)
if err != nil {
return err
}
if w.opts.Cipher != nil {
data = cryptutil.Encrypt(w.opts.Cipher, data, nil)
}
encodedData := base64.StdEncoding.EncodeToString(data)
patch, _ := yaml.Marshal(map[string]any{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]any{
"name": w.name,
"namespace": w.namespace,
},
"data": map[string]string{
w.key: encodedData,
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, u.String(), bytes.NewReader(patch))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/apply-patch+yaml")
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
return nil
case http.StatusForbidden:
if resp.Header.Get("Content-Type") == "application/json" {
// log the detailed status message if available
status, err := io.ReadAll(resp.Body)
if err != nil {
break
}
var buf bytes.Buffer
err = json.Compact(&buf, status)
if err != nil {
break
}
log.Ctx(ctx).Error().
RawJSON("response", buf.Bytes()).
Msgf("%s %s: %s", req.Method, req.URL, resp.Status)
}
}
return fmt.Errorf("unexpected status: %s", resp.Status)
}
var _ writers.ConfigWriter = (*secretWriter)(nil)
type roundTripper struct {
base http.RoundTripper
bearerToken string
}
func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("Authorization") == "" {
req = req.Clone(req.Context())
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", rt.bearerToken))
}
return rt.base.RoundTrip(req)
}