- when setting an OIDC issuer URL the defaulter will fetch and set authorization and token endpoints - basic auth allows to use either inline hashed credentials or plaintext credentials from a secret that are automatically hashed - finish TLS support for API & dashboard listeners
434 lines
13 KiB
Go
434 lines
13 KiB
Go
/*
|
|
Copyright 2025 Peter Kurfer.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package controlplane
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
|
|
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
|
|
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
|
|
endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
|
|
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
|
|
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
|
|
basic_authv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3"
|
|
oauth2v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/oauth2/v3"
|
|
routerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3"
|
|
hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
|
|
tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
|
|
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
|
|
"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
|
|
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
|
|
)
|
|
|
|
type StudioCluster struct {
|
|
client.Client
|
|
ServiceCluster
|
|
}
|
|
|
|
func (c *StudioCluster) Cluster(instance string, gateway *supabasev1alpha1.APIGateway) ([]*clusterv3.Cluster, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
serviceCfg := supabase.ServiceConfig.Studio
|
|
|
|
clusters := []*clusterv3.Cluster{
|
|
c.ServiceCluster.Cluster(fmt.Sprintf("%s@%s", serviceCfg.Name, instance), uint32(serviceCfg.Defaults.APIPort)),
|
|
}
|
|
|
|
if gateway.Spec.DashboardEndpoint.AuthType() == supabasev1alpha1.DashboardAuthTypeOAuth2 {
|
|
if tokenEndpointCluster, err := c.oauth2TokenEndpointCluster(instance, gateway); err != nil {
|
|
return nil, err
|
|
} else {
|
|
clusters = append(clusters, tokenEndpointCluster)
|
|
}
|
|
}
|
|
|
|
return clusters, nil
|
|
}
|
|
|
|
func (s *StudioCluster) Listener(ctx context.Context, instance string, gateway *supabasev1alpha1.APIGateway) (*listenerv3.Listener, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var httpFilters []*hcm.HttpFilter
|
|
|
|
switch gateway.Spec.DashboardEndpoint.AuthType() {
|
|
case supabasev1alpha1.DashboardAuthTypeOAuth2:
|
|
httpFilters = append(httpFilters, s.oauth2HttpFilter(instance, gateway))
|
|
case supabasev1alpha1.DashboardAuthTypeBasic:
|
|
if filter, err := s.basicAuthHttpFilter(ctx, gateway); err != nil {
|
|
return nil, err
|
|
} else if filter != nil {
|
|
httpFilters = append(httpFilters, filter)
|
|
}
|
|
}
|
|
|
|
studioConnetionManager := &hcm.HttpConnectionManager{
|
|
CodecType: hcm.HttpConnectionManager_AUTO,
|
|
StatPrefix: "supbase_studio",
|
|
AccessLog: []*accesslogv3.AccessLog{AccessLog("supbase_studio_access_log")},
|
|
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
|
|
Rds: &hcm.Rds{
|
|
ConfigSource: &corev3.ConfigSource{
|
|
ResourceApiVersion: resource.DefaultAPIVersion,
|
|
ConfigSourceSpecifier: &corev3.ConfigSource_ApiConfigSource{
|
|
ApiConfigSource: &corev3.ApiConfigSource{
|
|
TransportApiVersion: resource.DefaultAPIVersion,
|
|
ApiType: corev3.ApiConfigSource_GRPC,
|
|
SetNodeOnFirstMessageOnly: true,
|
|
GrpcServices: []*corev3.GrpcService{{
|
|
TargetSpecifier: &corev3.GrpcService_EnvoyGrpc_{
|
|
EnvoyGrpc: &corev3.GrpcService_EnvoyGrpc{ClusterName: "supabase-control-plane"},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
RouteConfigName: studioRouteName,
|
|
},
|
|
},
|
|
HttpFilters: append(httpFilters, &hcm.HttpFilter{
|
|
Name: FilterNameHttpRouter,
|
|
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(routerv3.Router))},
|
|
}),
|
|
}
|
|
|
|
socket, err := s.dashboardTransportSocket(ctx, gateway)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to setup dashboard TLS listener: %w", err)
|
|
}
|
|
|
|
return &listenerv3.Listener{
|
|
Name: "studio",
|
|
Address: &corev3.Address{
|
|
Address: &corev3.Address_SocketAddress{
|
|
SocketAddress: &corev3.SocketAddress{
|
|
Protocol: corev3.SocketAddress_TCP,
|
|
Address: "0.0.0.0",
|
|
PortSpecifier: &corev3.SocketAddress_PortValue{
|
|
PortValue: 3000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
FilterChains: []*listenerv3.FilterChain{
|
|
{
|
|
Filters: []*listenerv3.Filter{
|
|
{
|
|
Name: FilterNameHttpConnectionManager,
|
|
ConfigType: &listenerv3.Filter_TypedConfig{
|
|
TypedConfig: MustAny(studioConnetionManager),
|
|
},
|
|
},
|
|
},
|
|
TransportSocket: socket,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *StudioCluster) RouteConfiguration(instance string) *routev3.RouteConfiguration {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
|
|
return &routev3.RouteConfiguration{
|
|
Name: studioRouteName,
|
|
VirtualHosts: []*routev3.VirtualHost{{
|
|
Name: "supabase-studio",
|
|
Domains: []string{"*"},
|
|
Routes: []*routev3.Route{{
|
|
Name: "Studio: /* -> http://studio:3000/*",
|
|
Match: &routev3.RouteMatch{
|
|
PathSpecifier: &routev3.RouteMatch_Prefix{
|
|
Prefix: "/",
|
|
},
|
|
},
|
|
Action: &routev3.Route_Route{
|
|
Route: &routev3.RouteAction{
|
|
ClusterSpecifier: &routev3.RouteAction_Cluster{
|
|
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Studio.Name, instance),
|
|
},
|
|
PrefixRewrite: "/",
|
|
},
|
|
},
|
|
}},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func (s *StudioCluster) oauth2TokenEndpointCluster(
|
|
instance string,
|
|
gateway *supabasev1alpha1.APIGateway,
|
|
) (*clusterv3.Cluster, error) {
|
|
parsedTokenEndpoint, err := url.Parse(gateway.Spec.DashboardEndpoint.OAuth2().TokenEndpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse token endpoint: %w", err)
|
|
}
|
|
|
|
var (
|
|
endpointPort uint32
|
|
tls bool
|
|
)
|
|
switch parsedTokenEndpoint.Scheme {
|
|
case "http":
|
|
endpointPort = 80
|
|
case "https":
|
|
endpointPort = 443
|
|
tls = true
|
|
default:
|
|
return nil, fmt.Errorf("unsupported token endpoint scheme: %s", parsedTokenEndpoint.Scheme)
|
|
}
|
|
|
|
if tokenEndpointPort := parsedTokenEndpoint.Port(); tokenEndpointPort != "" {
|
|
if parsedPort, err := strconv.ParseUint(tokenEndpointPort, 10, 32); err != nil {
|
|
return nil, fmt.Errorf("failed to parse token endpoint port: %w", err)
|
|
} else {
|
|
endpointPort = uint32(parsedPort)
|
|
}
|
|
}
|
|
|
|
cluster := &clusterv3.Cluster{
|
|
Name: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance),
|
|
ConnectTimeout: durationpb.New(3 * time.Second),
|
|
ClusterDiscoveryType: &clusterv3.Cluster_Type{
|
|
Type: clusterv3.Cluster_LOGICAL_DNS,
|
|
},
|
|
LbPolicy: clusterv3.Cluster_ROUND_ROBIN,
|
|
LoadAssignment: &endpointv3.ClusterLoadAssignment{
|
|
ClusterName: dashboardOAuth2ClusterName,
|
|
Endpoints: []*endpointv3.LocalityLbEndpoints{{
|
|
LbEndpoints: []*endpointv3.LbEndpoint{{
|
|
HostIdentifier: &endpointv3.LbEndpoint_Endpoint{
|
|
Endpoint: &endpointv3.Endpoint{
|
|
Address: &corev3.Address{
|
|
Address: &corev3.Address_SocketAddress{
|
|
SocketAddress: &corev3.SocketAddress{
|
|
Address: parsedTokenEndpoint.Hostname(),
|
|
PortSpecifier: &corev3.SocketAddress_PortValue{
|
|
PortValue: endpointPort,
|
|
},
|
|
Protocol: corev3.SocketAddress_TCP,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
if gateway.Spec.Envoy != nil && gateway.Spec.Envoy.DisableIPv6 {
|
|
cluster.DnsLookupFamily = clusterv3.Cluster_V4_ONLY
|
|
}
|
|
|
|
if tls {
|
|
cluster.TransportSocket = &corev3.TransportSocket{
|
|
Name: "envoy.transport_sockets.tls",
|
|
ConfigType: &corev3.TransportSocket_TypedConfig{
|
|
TypedConfig: MustAny(&tlsv3.UpstreamTlsContext{
|
|
CommonTlsContext: new(tlsv3.CommonTlsContext),
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
return cluster, nil
|
|
}
|
|
|
|
func (s *StudioCluster) dashboardTransportSocket(
|
|
ctx context.Context,
|
|
gateway *supabasev1alpha1.APIGateway,
|
|
) (*corev3.TransportSocket, error) {
|
|
tlsSpec := gateway.Spec.DashboardEndpoint.TLSSpec()
|
|
if tlsSpec == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
dashboardTlsSecret := corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: tlsSpec.Cert.SecretName,
|
|
Namespace: gateway.Namespace,
|
|
},
|
|
}
|
|
|
|
if err := s.Get(ctx, client.ObjectKeyFromObject(&dashboardTlsSecret), &dashboardTlsSecret); err != nil {
|
|
if client.IgnoreNotFound(err) == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &corev3.TransportSocket{
|
|
Name: SocketNameTLS,
|
|
ConfigType: &corev3.TransportSocket_TypedConfig{
|
|
TypedConfig: MustAny(&tlsv3.DownstreamTlsContext{
|
|
CommonTlsContext: &tlsv3.CommonTlsContext{
|
|
TlsCertificates: []*tlsv3.TlsCertificate{{
|
|
CertificateChain: &corev3.DataSource{
|
|
Specifier: &corev3.DataSource_InlineBytes{
|
|
InlineBytes: dashboardTlsSecret.Data[corev1.TLSCertKey],
|
|
},
|
|
},
|
|
PrivateKey: &corev3.DataSource{
|
|
Specifier: &corev3.DataSource_InlineBytes{
|
|
InlineBytes: dashboardTlsSecret.Data[corev1.TLSPrivateKeyKey],
|
|
},
|
|
},
|
|
}},
|
|
ValidationContextType: &tlsv3.CommonTlsContext_ValidationContext{
|
|
ValidationContext: &tlsv3.CertificateValidationContext{
|
|
TrustedCa: &corev3.DataSource{
|
|
Specifier: &corev3.DataSource_InlineBytes{
|
|
InlineBytes: dashboardTlsSecret.Data["ca.crt"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *StudioCluster) basicAuthHttpFilter(ctx context.Context, gateway *supabasev1alpha1.APIGateway) (*hcm.HttpFilter, error) {
|
|
users := gateway.Spec.DashboardEndpoint.Auth.Basic.UsersInline
|
|
|
|
usersSecret := corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: gateway.Spec.DashboardEndpoint.Auth.Basic.PlaintextUsersSecretRef,
|
|
Namespace: gateway.Namespace,
|
|
},
|
|
}
|
|
|
|
if err := s.Client.Get(ctx, client.ObjectKeyFromObject(&usersSecret), &usersSecret); err != nil {
|
|
if client.IgnoreNotFound(err) != nil {
|
|
return nil, fmt.Errorf("failed to fetch credentials secret: %w", err)
|
|
}
|
|
}
|
|
|
|
hash := sha1.New()
|
|
for username, passwd := range usersSecret.Data {
|
|
_, _ = hash.Write(passwd)
|
|
pwHash := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
|
users = append(users, fmt.Sprintf("%s:{SHA}%s", username, pwHash))
|
|
hash.Reset()
|
|
}
|
|
|
|
if len(users) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
slices.Sort(users)
|
|
|
|
return &hcm.HttpFilter{
|
|
Name: FilterNameBasicAuth,
|
|
ConfigType: &hcm.HttpFilter_TypedConfig{
|
|
TypedConfig: MustAny(&basic_authv3.BasicAuth{
|
|
Users: &corev3.DataSource{
|
|
Specifier: &corev3.DataSource_InlineString{
|
|
InlineString: strings.Join(users, "\n"),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *StudioCluster) oauth2HttpFilter(instance string, gateway *supabasev1alpha1.APIGateway) *hcm.HttpFilter {
|
|
var (
|
|
serviceCfg = supabase.ServiceConfig.Envoy
|
|
oauth2Spec = gateway.Spec.DashboardEndpoint.OAuth2()
|
|
)
|
|
|
|
return &hcm.HttpFilter{
|
|
Name: FilterNameOAuth2,
|
|
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(&oauth2v3.OAuth2{
|
|
Config: &oauth2v3.OAuth2Config{
|
|
TokenEndpoint: &corev3.HttpUri{
|
|
HttpUpstreamType: &corev3.HttpUri_Cluster{
|
|
Cluster: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance),
|
|
},
|
|
Uri: gateway.Spec.DashboardEndpoint.Auth.OAuth2.TokenEndpoint,
|
|
Timeout: durationpb.New(3 * time.Second),
|
|
},
|
|
AuthorizationEndpoint: gateway.Spec.DashboardEndpoint.Auth.OAuth2.AuthorizationEndpoint,
|
|
RedirectUri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/callback",
|
|
RedirectPathMatcher: &matcherv3.PathMatcher{
|
|
Rule: &matcherv3.PathMatcher_Path{
|
|
Path: &matcherv3.StringMatcher{
|
|
MatchPattern: &matcherv3.StringMatcher_Exact{
|
|
Exact: "/callback",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
SignoutPath: &matcherv3.PathMatcher{
|
|
Rule: &matcherv3.PathMatcher_Path{
|
|
Path: &matcherv3.StringMatcher{
|
|
MatchPattern: &matcherv3.StringMatcher_Exact{
|
|
Exact: "/signout",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Credentials: &oauth2v3.OAuth2Credentials{
|
|
ClientId: oauth2Spec.ClientID,
|
|
TokenSecret: &tlsv3.SdsSecretConfig{
|
|
Name: serviceCfg.Defaults.OAuth2ClientSecretKey,
|
|
SdsConfig: &corev3.ConfigSource{
|
|
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
|
|
Ads: new(corev3.AggregatedConfigSource),
|
|
},
|
|
},
|
|
},
|
|
TokenFormation: &oauth2v3.OAuth2Credentials_HmacSecret{
|
|
HmacSecret: &tlsv3.SdsSecretConfig{
|
|
Name: serviceCfg.Defaults.HmacSecretKey,
|
|
SdsConfig: &corev3.ConfigSource{
|
|
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
|
|
Ads: new(corev3.AggregatedConfigSource),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
AuthScopes: oauth2Spec.Scopes,
|
|
Resources: oauth2Spec.Resources,
|
|
},
|
|
})},
|
|
}
|
|
}
|