Ctrl K
ComunidadGuías…

clusterflags.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"net"
	"net/url"
	"os"
	"strings"
	"time"

	"github.com/sophotechlabs/spinoza/internal/auth"
	"github.com/sophotechlabs/spinoza/internal/filetx"
)

const (
	clusterAddr  = "0.0.0.0:8080"
	callbackPath = "/auth/callback"
	//nolint:gosec // the name of an environment variable, not a secret
	secretEnv = "SPINOZA_SESSION_SECRET"
	//nolint:gosec // the name of an environment variable, not a secret
	clientSecretEnv = "SPINOZA_AUTH_OIDC_CLIENT_SECRET"
	proxyAuthEnv    = "SPINOZA_AUTH_PROXY_SECRET"
	maxSecretBytes  = 1 << 20
)

type serving struct {
	on          bool
	publicURL   string
	unsafeHTTP  bool
	impersonate bool
	auth        auth.Config
}

type clusterFlags struct {
	on            *bool
	publicURL     *string
	unsafeHTTP    *bool
	impersonate   *bool
	mode          *string
	allowAnon     *bool
	secretFile    *string
	ttl           *time.Duration
	maxAge        *time.Duration
	defaultRole   *string
	adminGroups   *string
	editorGroups  *string
	viewerGroups  *string
	userHeader    *string
	groupsHeader  *string
	proxyHeader   *string
	proxyFile     *string
	proxyLogout   *string
	proxyWSMaxAge *time.Duration
	issuer        *string
	inner         *string
	clientID      *string
	clientSecret  *string
	secretPath    *string
	redirect      *string
	scopes        *string
	groupsClaim   *string
	usernameClaim *string
	userPrefix    *string
	groupPrefix   *string
	postLogout    *string
	caCert        *string
	skipVerify    *bool
	issuerHTTP    *bool
	backchannel   *bool
}

func registerCluster(flags *flag.FlagSet) *clusterFlags {
	return &clusterFlags{
		on:            flags.Bool("cluster-mode", envBool("SPINOZA_CLUSTER_MODE"), "serve a cluster to a team instead of running as your own local window"),
		publicURL:     flags.String("public-url", envOr("SPINOZA_PUBLIC_URL", ""), "the http(s) origin browsers reach spinoza at, such as https://spinoza.example.com"),
		unsafeHTTP:    flags.Bool("unsafe-allow-http", envBool("SPINOZA_UNSAFE_ALLOW_HTTP"), "allow plaintext non-loopback browser sessions; exposes credentials and cluster data to the network"),
		impersonate:   flags.Bool("impersonate", envUnless("SPINOZA_IMPERSONATE"), "act on the cluster as the signed-in user, so kubernetes rbac decides what they may do"),
		mode:          flags.String("auth-mode", envOr("SPINOZA_AUTH_MODE", auth.ModeNone), "how people sign in: none, proxy or oidc"),
		allowAnon:     flags.Bool("allow-anonymous-admin", envBool("SPINOZA_ALLOW_ANONYMOUS_ADMIN"), "allow unauthenticated cluster-mode requests to act as administrators"),
		secretFile:    flags.String("session-secret-file", envOr("SPINOZA_SESSION_SECRET_FILE", ""), "file holding the key that signs session cookies; without one every sign-in ends when spinoza restarts"),
		ttl:           flags.Duration("session-ttl", envDuration("SPINOZA_SESSION_TTL", auth.DefaultSessionTTL), "how long a sign-in lasts before it is renewed or ends"),
		maxAge:        flags.Duration("session-max-age", envDuration("SPINOZA_SESSION_MAX_AGE", auth.DefaultSessionMax), "how long a sign-in may be renewed for before the provider has to decide again"),
		defaultRole:   flags.String("auth-default-role", envOr("SPINOZA_AUTH_DEFAULT_ROLE", auth.RoleViewer), "role for anyone whose groups match none of the lists: viewer, editor or admin"),
		adminGroups:   flags.String("auth-admin-groups", envOr("SPINOZA_AUTH_ADMIN_GROUPS", ""), "comma separated groups whose members are admins here"),
		editorGroups:  flags.String("auth-editor-groups", envOr("SPINOZA_AUTH_EDITOR_GROUPS", ""), "comma separated groups whose members may change objects"),
		viewerGroups:  flags.String("auth-viewer-groups", envOr("SPINOZA_AUTH_VIEWER_GROUPS", ""), "comma separated groups whose members may only look"),
		userHeader:    flags.String("auth-user-header", envOr("SPINOZA_AUTH_USER_HEADER", auth.DefaultUserHeader), "header your auth proxy puts the username in"),
		groupsHeader:  flags.String("auth-groups-header", envOr("SPINOZA_AUTH_GROUPS_HEADER", auth.DefaultGroupsHeader), "header your auth proxy puts the groups in"),
		proxyHeader:   flags.String("auth-proxy-secret-header", envOr("SPINOZA_AUTH_PROXY_SECRET_HEADER", auth.DefaultProxyAuthHeader), "header your auth proxy puts its shared secret in"),
		proxyFile:     flags.String("auth-proxy-secret-file", envOr("SPINOZA_AUTH_PROXY_SECRET_FILE", ""), "file holding the secret that authenticates your proxy"),
		proxyLogout:   flags.String("auth-proxy-logout-url", envOr("SPINOZA_AUTH_PROXY_LOGOUT_URL", ""), "where signing out sends the browser when a proxy holds the session"),
		proxyWSMaxAge: flags.Duration("auth-proxy-websocket-max-age", envDuration("SPINOZA_AUTH_PROXY_WEBSOCKET_MAX_AGE", auth.DefaultProxyWSMaxAge), "maximum lifetime of a proxy-authenticated websocket before fresh proxy headers are required"),
		issuer:        flags.String("auth-oidc-issuer", envOr("SPINOZA_AUTH_OIDC_ISSUER", ""), "your identity provider, such as https://keycloak.example.com/realms/main"),
		inner:         flags.String("auth-oidc-internal-issuer", envOr("SPINOZA_AUTH_OIDC_INTERNAL_ISSUER", ""), "the same provider on the address this pod can reach, when it differs from the browser's"),
		clientID:      flags.String("auth-oidc-client-id", envOr("SPINOZA_AUTH_OIDC_CLIENT_ID", ""), "the client spinoza is registered as"),
		clientSecret:  flags.String("auth-oidc-client-secret", envOr(clientSecretEnv, ""), "the client secret; prefer the file flag or the environment variable"),
		secretPath:    flags.String("auth-oidc-client-secret-file", envOr("SPINOZA_AUTH_OIDC_CLIENT_SECRET_FILE", ""), "file holding the client secret"),
		redirect:      flags.String("auth-oidc-redirect-url", envOr("SPINOZA_AUTH_OIDC_REDIRECT_URL", ""), "where the provider sends the browser back; the public url plus /auth/callback when empty"),
		scopes:        flags.String("auth-oidc-scopes", envOr("SPINOZA_AUTH_OIDC_SCOPES", strings.Join(auth.DefaultScopes, ",")), "comma separated scopes to ask for"),
		groupsClaim:   flags.String("auth-oidc-groups-claim", envOr("SPINOZA_AUTH_OIDC_GROUPS_CLAIM", auth.DefaultGroupsClaim), "claim in the id token that carries group membership"),
		usernameClaim: flags.String("auth-oidc-username-claims", envOr("SPINOZA_AUTH_OIDC_USERNAME_CLAIMS", auth.DefaultUsernameKeys), "comma separated claims to read a username from, first one present wins"),
		userPrefix:    flags.String("auth-oidc-username-prefix", envOr("SPINOZA_AUTH_OIDC_USERNAME_PREFIX", ""), "prefix put in front of the username, to match what your apiserver binds"),
		groupPrefix:   flags.String("auth-oidc-groups-prefix", envOr("SPINOZA_AUTH_OIDC_GROUPS_PREFIX", ""), "prefix put in front of every group, to match what your apiserver binds"),
		postLogout:    flags.String("auth-oidc-post-logout-url", envOr("SPINOZA_AUTH_OIDC_POST_LOGOUT_URL", ""), "where the provider sends the browser after signing out; register it there first"),
		caCert:        flags.String("auth-oidc-ca-cert", envOr("SPINOZA_AUTH_OIDC_CA_CERT", ""), "certificate authority to trust when talking to the provider"),
		skipVerify:    flags.Bool("auth-oidc-insecure-skip-verify", envBool("SPINOZA_AUTH_OIDC_INSECURE_SKIP_VERIFY"), "do not verify the provider's certificate; for a lab, never for real use"),
		issuerHTTP:    flags.Bool("auth-oidc-unsafe-allow-http", envBool("SPINOZA_AUTH_OIDC_UNSAFE_ALLOW_HTTP"), "allow plaintext non-loopback oidc endpoints; permits network authentication compromise"),
		backchannel:   flags.Bool("auth-oidc-backchannel-logout", envBool("SPINOZA_AUTH_OIDC_BACKCHANNEL_LOGOUT"), "accept back-channel logout, so disabling somebody at the provider ends their session here"),
	}
}

func (cf *clusterFlags) settings() (serving, error) {
	out := serving{
		on:          *cf.on,
		publicURL:   *cf.publicURL,
		unsafeHTTP:  *cf.unsafeHTTP,
		impersonate: *cf.impersonate,
	}
	if !out.on {
		return out, nil
	}
	secret, secretErr := readSecret(*cf.secretFile, secretEnv)
	if secretErr != nil {
		return serving{}, secretErr
	}
	clientSecret, clientErr := readSecret(*cf.secretPath, "")
	if clientErr != nil {
		return serving{}, clientErr
	}
	if len(clientSecret) == 0 {
		clientSecret = []byte(*cf.clientSecret)
	}
	proxySecret, proxySecretErr := readSecret(*cf.proxyFile, proxyAuthEnv)
	if proxySecretErr != nil {
		return serving{}, proxySecretErr
	}
	out.auth = auth.Config{
		Mode:           *cf.mode,
		PublicURL:      out.publicURL,
		AllowAnonymous: *cf.allowAnon,
		SessionSecret:  secret,
		SessionTTL:     *cf.ttl,
		SessionMaxAge:  *cf.maxAge,
		DefaultRole:    *cf.defaultRole,
		AdminGroups:    auth.ParseList(*cf.adminGroups),
		EditorGroups:   auth.ParseList(*cf.editorGroups),
		ViewerGroups:   auth.ParseList(*cf.viewerGroups),
		Proxy: auth.ProxyConfig{
			UserHeader:      *cf.userHeader,
			GroupsHeader:    *cf.groupsHeader,
			SecretHeader:    *cf.proxyHeader,
			SharedSecret:    proxySecret,
			LogoutURL:       *cf.proxyLogout,
			WebSocketMaxAge: *cf.proxyWSMaxAge,
		},
		OIDC: auth.OIDCConfig{
			IssuerURL:          *cf.issuer,
			InternalIssuerURL:  *cf.inner,
			ClientID:           *cf.clientID,
			ClientSecret:       string(clientSecret),
			RedirectURL:        underPublic(*cf.redirect, out.publicURL, callbackPath),
			Scopes:             auth.ParseList(*cf.scopes),
			GroupsClaim:        *cf.groupsClaim,
			UsernameClaims:     auth.ParseList(*cf.usernameClaim),
			UsernamePrefix:     *cf.userPrefix,
			GroupsPrefix:       *cf.groupPrefix,
			PostLogoutURL:      underPublic(*cf.postLogout, out.publicURL, "/"),
			CACertFile:         *cf.caCert,
			InsecureSkipVerify: *cf.skipVerify,
			UnsafeAllowHTTP:    *cf.issuerHTTP,
			BackchannelLogout:  *cf.backchannel,
		},
	}
	return out, nil
}

func (sv serving) check() error {
	if !sv.on {
		return nil
	}
	parsed, err := sv.parsePublicURL()
	if err != nil {
		return err
	}
	if err := sv.checkPublicTransport(parsed); err != nil {
		return err
	}
	if err := sv.checkPublicShape(parsed); err != nil {
		return err
	}
	if sv.unsafeHTTP {
		slog.Warn("plaintext public http is enabled; network interception can steal sessions and cluster data")
	}
	return nil
}

func (sv serving) parsePublicURL() (*url.URL, error) {
	if sv.publicURL == "" {
		return nil, errors.New("cluster mode needs --public-url, the address browsers reach spinoza at")
	}
	parsed, err := url.Parse(sv.publicURL)
	if err != nil {
		return nil, fmt.Errorf("public url %q: %w", sv.publicURL, err)
	}
	return parsed, nil
}

func (sv serving) checkPublicTransport(parsed *url.URL) error {
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return fmt.Errorf("public url %q needs to start with http:// or https://", sv.publicURL)
	}
	if parsed.Scheme == "http" && !loopbackEndpoint(parsed.Hostname()) && !sv.unsafeHTTP {
		return fmt.Errorf("public url %q must use https; plaintext http requires --unsafe-allow-http", sv.publicURL)
	}
	return nil
}

func (sv serving) checkPublicShape(parsed *url.URL) error {
	if parsed.Host == "" {
		return fmt.Errorf("public url %q names no host", sv.publicURL)
	}
	if parsed.User != nil {
		return fmt.Errorf("public url %q must not include credentials", sv.publicURL)
	}
	if escaped := parsed.EscapedPath(); escaped != "" && escaped != "/" {
		return fmt.Errorf("public url %q must not include a path", sv.publicURL)
	}
	if parsed.RawQuery != "" || parsed.ForceQuery {
		return fmt.Errorf("public url %q must not include a query", sv.publicURL)
	}
	if strings.Contains(sv.publicURL, "#") {
		return fmt.Errorf("public url %q must not include a fragment", sv.publicURL)
	}
	return nil
}

func underPublic(given, public, path string) string {
	if given != "" {
		return given
	}
	if public == "" {
		return ""
	}
	return strings.TrimSuffix(public, "/") + path
}

func loopbackEndpoint(host string) bool {
	if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
		return true
	}
	ip := net.ParseIP(host)
	if ip == nil {
		return false
	}
	return ip.IsLoopback()
}

func readSecret(path, fallbackEnv string) ([]byte, error) {
	if path == "" {
		if fallbackEnv == "" {
			return nil, nil
		}
		return []byte(os.Getenv(fallbackEnv)), nil
	}
	body, err := filetx.Read(path, maxSecretBytes)
	if err != nil {
		return nil, fmt.Errorf("reading %s: %w", path, err)
	}
	return []byte(strings.TrimSpace(string(body))), nil
}

func envUnless(name string) bool {
	value, present := os.LookupEnv(name)
	if !present {
		return true
	}
	if value == "" {
		return true
	}
	return envBool(name)
}