clusterflags.go
Rama main · réplica de lectura del repositorio original.
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)
}