pomerium/internal/zero/cmd/import_ui.go
2024-09-26 13:31:35 -04:00

538 lines
18 KiB
Go

package cmd
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
http_connection_managerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
"github.com/muesli/termenv"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/log"
configpb "github.com/pomerium/pomerium/pkg/grpc/config"
cluster_api "github.com/pomerium/pomerium/pkg/zero/cluster"
"github.com/pomerium/pomerium/pkg/zero/importutil"
"github.com/pomerium/protoutil/fieldmasks"
"github.com/pomerium/protoutil/paths"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protopath"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
type onCursorUpdate struct {
Field interface{ Cursor() int }
}
func (u onCursorUpdate) Hash() (uint64, error) {
return uint64(u.Field.Cursor()), nil
}
var (
yellowText = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(3))
faintText = lipgloss.NewStyle().Faint(true).UnsetForeground()
redText = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(1))
)
func errText(err error) string {
return redText.Render(fmt.Sprintf("(error: %v)", err))
}
func certInfoFromSettingsCertificate(v protoreflect.Value) string {
switch v := v.Interface().(type) {
case protoreflect.List:
buf := bytes.Buffer{}
for i, l := 0, v.Len(); i < l; i++ {
crtBytes := string(v.Get(i).Message().Interface().(*configpb.Settings_Certificate).GetCertBytes())
buf.WriteString(crtBytes)
if i < l-1 {
buf.WriteRune('\n')
}
}
return certInfoFromBytes(buf.Bytes())
case protoreflect.Message:
crtBytes := string(v.Interface().(*configpb.Settings_Certificate).GetCertBytes())
return certInfoFromBytes([]byte(crtBytes))
default:
panic(fmt.Sprintf("bug: unexpected value type %T", v))
}
}
func certInfoFromBase64(v protoreflect.Value) string {
crtBytes, err := base64.StdEncoding.DecodeString(v.String())
if err != nil {
return errText(err)
}
return certInfoFromBytes(crtBytes)
}
func certInfoFromBytes(b []byte) string {
if len(b) == 0 {
return faintText.Render("(empty)")
}
block, rest := pem.Decode(b)
if block == nil {
return errText(errors.New("no PEM data found"))
}
extraBlocks := []*pem.Block{}
for len(rest) > 0 {
block, rest = pem.Decode(rest)
if block != nil {
extraBlocks = append(extraBlocks, block)
}
}
blockType := block.Type
var info string
switch block.Type {
case "X509 CRL":
crl, err := x509.ParseRevocationList(block.Bytes)
if err != nil {
return errText(err)
}
info = fmt.Sprintf("%d entries", len(crl.RevokedCertificateEntries))
default:
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return errText(err)
}
info = *importutil.GenerateCertName(cert)
}
out := yellowText.Render(fmt.Sprintf("(%s: %s)", blockType, info))
if len(extraBlocks) > 0 {
s := ""
if len(extraBlocks) != 1 {
s = "s"
}
out += faintText.Render(fmt.Sprintf(" ...+%d block%s", len(extraBlocks), s))
}
return out
}
func secret(s protoreflect.Value) string {
length := len(s.String())
return yellowText.Render(fmt.Sprintf("(secret: %d bytes)", length))
}
var customSettingsInfoByPath = map[string]func(v protoreflect.Value) string{
"(pomerium.config.Settings).metrics_certificate": certInfoFromSettingsCertificate,
"(pomerium.config.Settings).metrics_client_ca": certInfoFromBase64,
"(pomerium.config.Settings).certificates": certInfoFromSettingsCertificate,
"(pomerium.config.Settings).certificate_authority": certInfoFromBase64,
"(pomerium.config.Settings).downstream_mtls.ca": certInfoFromBase64,
"(pomerium.config.Settings).downstream_mtls.crl": certInfoFromBase64,
"(pomerium.config.Settings).shared_secret": secret,
"(pomerium.config.Settings).cookie_secret": secret,
"(pomerium.config.Settings).google_cloud_serverless_authentication_service_account": secret,
"(pomerium.config.Settings).idp_client_secret": secret,
"(pomerium.config.Settings).databroker_storage_connection_string": secret,
}
type ImportHints struct {
// Indicates that the field is ignored during Zero import
Ignored bool
// Indicates that the field is entirely unsupported by Zero, and will likely
// break an existing configuration if imported. If any of these fields are
// selected, an error will be displayed.
Unsupported bool
// An optional note explaining why a field is ignored or unsupported, if
// additional context would be helpful. This message will be user facing.
Note string
// Indicates that the field is treated as a secret, and will be encrypted.
Secret bool
}
const (
noteSplitService = "split-service mode"
noteEnterpriseOnly = "enterprise only"
noteFeatureNotYetAvailable = "feature not yet available"
)
func noteCertificate(n int) string {
suffix := ""
if n != 1 {
suffix = "s"
}
return fmt.Sprintf("+%d certificate%s", n, suffix)
}
func notePolicy(n int) string {
suffix := "y"
if n != 1 {
suffix = "ies"
}
return fmt.Sprintf("+%d polic%s", n, suffix)
}
func computeSettingsImportHints(cfg *configpb.Config) map[string]ImportHints {
m := map[string]ImportHints{
"authenticate_callback_path": {Ignored: true},
"shared_secret": {Ignored: true},
"cookie_secret": {Ignored: true},
"signing_key": {Ignored: true},
"authenticate_internal_service_url": {Unsupported: true, Note: noteSplitService},
"authorize_internal_service_url": {Unsupported: true, Note: noteSplitService},
"databroker_internal_service_url": {Unsupported: true, Note: noteSplitService},
"derive_tls": {Unsupported: true, Note: noteSplitService},
"audit_key": {Unsupported: true, Note: noteEnterpriseOnly},
"primary_color": {Unsupported: true, Note: noteEnterpriseOnly},
"secondary_color": {Unsupported: true, Note: noteEnterpriseOnly},
"darkmode_primary_color": {Unsupported: true, Note: noteEnterpriseOnly},
"darkmode_secondary_color": {Unsupported: true, Note: noteEnterpriseOnly},
"logo_url": {Unsupported: true, Note: noteEnterpriseOnly},
"favicon_url": {Unsupported: true, Note: noteEnterpriseOnly},
"error_message_first_paragraph": {Unsupported: true, Note: noteEnterpriseOnly},
"use_proxy_protocol": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"programmatic_redirect_domain_whitelist": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"grpc_client_timeout": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"grpc_client_dns_roundrobin": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"envoy_bind_config_freebind": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"envoy_bind_config_source_address": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"google_cloud_serverless_authentication_service_account": {Secret: true},
"idp_client_secret": {Secret: true},
"databroker_storage_connection_string": {Secret: true},
"metrics_certificate": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"metrics_client_ca": {Unsupported: true, Note: noteFeatureNotYetAvailable},
// "metrics_certificate": {Note: noteCertificate(1)},
// "metrics_client_ca": {Note: noteCertificate(1)},
"certificate_authority": {Note: noteCertificate(1)},
"certificates": {Note: noteCertificate(len(cfg.GetSettings().GetCertificates()))},
"downstream_mtls.crl": {Unsupported: true, Note: noteFeatureNotYetAvailable},
"downstream_mtls.ca": {Note: noteCertificate(1)},
}
if dm := cfg.GetSettings().GetDownstreamMtls(); dm != nil {
if dm.Enforcement != nil {
switch *dm.Enforcement {
case configpb.MtlsEnforcementMode_POLICY:
case configpb.MtlsEnforcementMode_POLICY_WITH_DEFAULT_DENY:
case configpb.MtlsEnforcementMode_REJECT_CONNECTION:
// this is a special case - zero does not support this mode, but we cannot continue
// with a partial import because it fundamentally changes the behavior of all routes
// and policies in the system
log.Fatal().Msg("downstream mtls enforcement mode 'reject_connection' is not supported")
}
}
}
if cfg.GetSettings().GetServices() != "all" {
m["services"] = ImportHints{Ignored: true, Note: `only "all" is supported`}
}
if cfg.GetSettings().GetCodecType() != http_connection_managerv3.HttpConnectionManager_AUTO {
m["codec_type"] = ImportHints{Ignored: true, Note: `only "auto" is supported`}
}
return m
}
type ImportUI struct {
form *huh.Form
selectedSettings []string
selectedRoutes []string
}
func NewImportUI(cfg *configpb.Config, quotas *cluster_api.ConfigQuotas) *ImportUI {
settingsImportHints := computeSettingsImportHints(cfg)
presentSettings := fieldmasks.Leaves(
fieldmasks.Diff(
config.NewDefaultOptions().ToProto().GetSettings().ProtoReflect(),
cfg.GetSettings().ProtoReflect(),
),
cfg.Settings.ProtoReflect().Descriptor(),
)
slices.Sort(presentSettings.Paths)
settingsOptions := huh.NewOptions(presentSettings.Paths...)
ui := &ImportUI{
selectedSettings: slices.Clone(presentSettings.Paths),
}
for i, value := range presentSettings.Paths {
if hints, ok := settingsImportHints[value]; ok {
switch {
case hints.Ignored:
note := ""
if hints.Note != "" {
note = fmt.Sprintf(": %s", hints.Note)
}
settingsOptions[i].Key = fmt.Sprintf("\x1b[9m%s\x1b[29m \x1b[2m(ignored%s)\x1b[22m", settingsOptions[i].Key, note)
ui.selectedSettings[i] = ""
case hints.Unsupported:
note := ""
if hints.Note != "" {
note = fmt.Sprintf(": %s", hints.Note)
}
settingsOptions[i].Key = fmt.Sprintf("\x1b[9m%s\x1b[29m \x1b[2m(unsupported%s)\x1b[22m", settingsOptions[i].Key, note)
ui.selectedSettings[i] = ""
case hints.Secret:
settingsOptions[i].Key += " \x1b[2m(secret)\x1b[22m"
default:
if hints.Note != "" {
settingsOptions[i].Key += fmt.Sprintf(" \x1b[2m(%s)\x1b[22m", hints.Note)
}
}
}
}
ui.selectedSettings = slices.DeleteFunc(ui.selectedSettings, func(s string) bool {
return s == ""
})
settingsSelect := huh.NewMultiSelect[string]().
Filterable(false).
Title("Import Settings").
Description("Choose settings to import from your existing configuration").
Options(settingsOptions...).
Validate(func(selected []string) error {
var unsupportedCount int
for _, s := range selected {
if hints, ok := settingsImportHints[s]; ok && hints.Unsupported {
unsupportedCount++
}
}
if unsupportedCount == 1 {
return fmt.Errorf("1 selected setting is unsupported")
} else if unsupportedCount > 1 {
return fmt.Errorf("%d selected settings are unsupported", unsupportedCount)
}
return nil
}).
Value(&ui.selectedSettings)
settingsSelect.Focus()
escapeNoteText := strings.NewReplacer(
"*", "\\*",
"_", "\\_",
"`", "\\`",
)
settingsNoteDescription := func(idx int) string {
if idx < 0 || idx > len(presentSettings.Paths) {
return ""
}
path, err := paths.ParseFrom(cfg.Settings.ProtoReflect().Descriptor(), "."+presentSettings.Paths[idx])
if err != nil {
return errText(err)
}
val, err := paths.Evaluate(cfg.Settings, path)
if err != nil {
return errText(err)
}
if infoFunc, ok := customSettingsInfoByPath[path.String()]; ok {
return escapeNoteText.Replace(infoFunc(val))
}
return escapeNoteText.Replace(formatValue(path, val))
}
settingsNote := huh.NewNote().
Title(fmt.Sprintf("Value: %s", presentSettings.Paths[0])).
TitleFunc(func() string {
return fmt.Sprintf("Value: %s", presentSettings.Paths[settingsSelect.Cursor()])
}, onCursorUpdate{settingsSelect}).
Description(settingsNoteDescription(0)).
DescriptionFunc(func() string {
return settingsNoteDescription(settingsSelect.Cursor())
}, onCursorUpdate{settingsSelect}).
Height(3)
settingsNote.Focus()
routeNames := make([]string, len(cfg.Routes))
for i, name := range importutil.GenerateRouteNames(cfg.Routes) {
routeNames[i] = name
cfg.Routes[i].Name = name
}
routeOptions := huh.NewOptions(routeNames...)
for i, name := range routeNames {
if i < quotas.Routes {
ui.selectedRoutes = append(ui.selectedRoutes, name)
}
if n := includedCertificatesInRoute(cfg.Routes[i]); n > 0 {
routeOptions[i].Key += fmt.Sprintf(" \x1b[2m(%s)\x1b[22m", noteCertificate(n))
}
if n := includedPoliciesInRoute(cfg.Routes[i]); n > 0 {
routeOptions[i].Key += fmt.Sprintf(" \x1b[2m(%s)\x1b[22m", notePolicy(n))
}
}
routesSelectDescription := func() string {
return fmt.Sprintf(`
Choose routes to import from your existing configuration. Policies and
certificates associated with selected routes will also be imported.
Pomerium Zero routes require unique names. We've generated default names
from the contents of each route, but these can always be changed later on.
Selected: %d/%d`[1:], len(ui.selectedRoutes), quotas.Routes)
}
topMarginLines := 1 + len(strings.Split(routesSelectDescription(), "\n"))
routesSelect := huh.NewMultiSelect[string]().
Filterable(true).
Title("Import Routes").
Description(routesSelectDescription()).
DescriptionFunc(routesSelectDescription, &ui.selectedRoutes).
Height(min(30, len(cfg.Routes)) + topMarginLines).
Options(routeOptions...).
Validate(func(_ []string) error {
if len(ui.selectedRoutes) > quotas.Routes {
return fmt.Errorf("A maximum of %d routes can be imported", quotas.Routes) //nolint:stylecheck
}
return nil
}).
Value(&ui.selectedRoutes)
var (
labelFrom = yellowText.Render(" from: ")
labelPath = yellowText.Render(" path: ")
labelPrefix = yellowText.Render(" prefix: ")
labelRegex = yellowText.Render(" regex: ")
labelTo = yellowText.Render(" to: ")
labelRedirect = yellowText.Render("redirect: ")
labelResponse = yellowText.Render("response: ")
)
routesNoteDescription := func(idx int) string {
selected := cfg.Routes[idx]
var b strings.Builder
b.WriteString(labelFrom)
b.WriteString(selected.From)
switch {
case selected.Path != "":
b.WriteRune('\n')
b.WriteString(labelPath)
b.WriteString(selected.Path)
case selected.Prefix != "":
b.WriteRune('\n')
b.WriteString(labelPrefix)
b.WriteString(selected.Prefix)
case selected.Regex != "":
b.WriteRune('\n')
b.WriteString(labelRegex)
b.WriteString(selected.Regex)
}
switch {
case len(selected.To) > 0:
b.WriteRune('\n')
b.WriteString(labelTo)
b.WriteString(selected.To[0])
for _, t := range selected.To[1:] {
b.WriteString(", ")
b.WriteString(t)
}
case selected.Redirect != nil:
b.WriteRune('\n')
b.WriteString(labelRedirect)
b.WriteString(selected.Redirect.String())
case selected.Response != nil:
b.WriteRune('\n')
b.WriteString(labelResponse)
b.WriteString(fmt.Sprint(selected.Response.Status))
b.WriteRune(' ')
b.WriteString(strconv.Quote(selected.Response.Body))
}
return b.String()
}
routesNote := huh.NewNote().
Title("Route Info").
Description(routesNoteDescription(0)).
DescriptionFunc(func() string {
return routesNoteDescription(routesSelect.Cursor())
}, onCursorUpdate{routesSelect}).Height(3)
routesNote.Focus()
ui.form = huh.NewForm(
huh.NewGroup(settingsSelect, settingsNote),
huh.NewGroup(routesSelect, routesNote),
).WithTheme(huh.ThemeBase16())
return ui
}
func (ui *ImportUI) Run(ctx context.Context) error {
if lipgloss.ColorProfile() == termenv.Ascii &&
!termenv.EnvNoColor() && os.Getenv("TERM") != "dumb" {
lipgloss.SetColorProfile(termenv.ANSI)
}
return ui.form.RunWithContext(ctx)
}
func (ui *ImportUI) ApplySelections(cfg *configpb.Config) {
fieldmasks.ExclusiveKeep(cfg.Settings, &fieldmaskpb.FieldMask{
Paths: ui.selectedSettings,
})
cfg.Routes = slices.DeleteFunc(cfg.Routes, func(route *configpb.Route) bool {
return !slices.Contains(ui.selectedRoutes, route.Name)
})
}
func includedCertificatesInRoute(route *configpb.Route) int {
n := 0
if route.TlsClientCert != "" && route.TlsClientKey != "" {
n++
}
if route.TlsCustomCa != "" {
n++
}
if route.TlsDownstreamClientCa != "" {
n++
}
return n
}
func includedPoliciesInRoute(route *configpb.Route) int {
n := 0
for _, policy := range route.PplPolicies {
// skip over common generated policies
switch string(policy.Raw) {
case `[{"allow":{"or":[{"accept":true}]}}]`:
case `[{"allow":{"or":[{"authenticated_user":true}]}}]`:
case `[{"allow":{"or":[{"cors_preflight":true}]}}]`:
default:
n++
}
}
return n
}
func formatValue(path protopath.Path, val protoreflect.Value) string {
switch vi := val.Interface().(type) {
case protoreflect.Message:
jsonData, err := protojson.Marshal(vi.Interface())
if err != nil {
return err.Error()
}
return string(jsonData)
case protoreflect.List:
values := []string{}
for i := 0; i < vi.Len(); i++ {
values = append(values, formatValue(path, vi.Get(i)))
}
return renderStringSlice(values)
case protoreflect.Map:
values := []string{}
vi.Range(func(mk protoreflect.MapKey, v protoreflect.Value) bool {
values = append(values, mk.String()+yellowText.Render("=")+formatValue(path, v))
return true
})
slices.Sort(values)
return renderStringSlice(values)
case protoreflect.EnumNumber:
var field protoreflect.FieldDescriptor
switch step := path.Index(-1); step.Kind() {
case protopath.FieldAccessStep:
field = step.FieldDescriptor()
case protopath.ListIndexStep, protopath.MapIndexStep:
field = path.Index(-2).FieldDescriptor()
}
if field != nil {
return strings.ToLower(string(field.Enum().Values().ByNumber(vi).Name()))
}
return fmt.Sprint(vi)
default:
return val.String()
}
}
func renderStringSlice(values []string) string {
return yellowText.Render("[") + strings.Join(values, yellowText.Render(", ")) + yellowText.Render("]")
}