feat: kubernetes basic integration
This commit is contained in:
112
internal/admin/bootstrap.go
Normal file
112
internal/admin/bootstrap.go
Normal file
@ -0,0 +1,112 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock/redis"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/schema"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/setup"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func (s *Server) bootstrapProxies(ctx context.Context) error {
|
||||
if err := s.validateBootstrap(ctx); err != nil {
|
||||
return errors.Wrap(err, "could not validate bootstrapped proxies")
|
||||
}
|
||||
|
||||
proxyRepo := s.proxyRepository
|
||||
layerRepo := s.layerRepository
|
||||
|
||||
lockTimeout := time.Duration(s.bootstrapConfig.LockTimeout)
|
||||
locker := redis.NewLocker(s.redisClient)
|
||||
|
||||
err := locker.WithLock(ctx, "bouncer-admin-bootstrap", lockTimeout, func(ctx context.Context) error {
|
||||
logger.Info(ctx, "bootstrapping proxies")
|
||||
|
||||
for proxyName, proxyConfig := range s.bootstrapConfig.Proxies {
|
||||
_, err := s.proxyRepository.GetProxy(ctx, proxyName)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "ignoring existing proxy", logger.F("proxyName", proxyName))
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info(ctx, "creating proxy", logger.F("proxyName", proxyName))
|
||||
|
||||
if _, err := proxyRepo.CreateProxy(ctx, proxyName, string(proxyConfig.To), proxyConfig.From...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
_, err = proxyRepo.UpdateProxy(
|
||||
ctx, proxyName,
|
||||
store.WithProxyUpdateEnabled(bool(proxyConfig.Enabled)),
|
||||
store.WithProxyUpdateWeight(int(proxyConfig.Weight)),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
for layerName, layerConfig := range proxyConfig.Layers {
|
||||
layerType := store.LayerType(layerConfig.Type)
|
||||
layerOptions := store.LayerOptions(layerConfig.Options)
|
||||
|
||||
if _, err := layerRepo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
_, err := layerRepo.UpdateLayer(
|
||||
ctx,
|
||||
proxyName, layerName,
|
||||
store.WithLayerUpdateEnabled(bool(layerConfig.Enabled)),
|
||||
store.WithLayerUpdateOptions(layerOptions),
|
||||
store.WithLayerUpdateWeight(int(layerConfig.Weight)),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const validateErrMessage = "could not validate proxy '%s': could not validate layer '%s'"
|
||||
|
||||
func (s *Server) validateBootstrap(ctx context.Context) error {
|
||||
for proxyName, proxyConf := range s.bootstrapConfig.Proxies {
|
||||
for layerName, layerConf := range proxyConf.Layers {
|
||||
layerType := store.LayerType(layerConf.Type)
|
||||
if !setup.LayerTypeExists(layerType) {
|
||||
return errors.Errorf(validateErrMessage+": could not find layer type '%s'", proxyName, layerName, layerType)
|
||||
}
|
||||
|
||||
layerOptionsSchema, err := setup.GetLayerOptionsSchema(layerType)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, validateErrMessage, proxyName, layerName)
|
||||
}
|
||||
|
||||
rawOptions := func(opts config.InterpolatedMap) map[string]any {
|
||||
return opts
|
||||
}(layerConf.Options)
|
||||
|
||||
if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil {
|
||||
return errors.Wrapf(err, validateErrMessage, proxyName, layerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -2,15 +2,9 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/schema"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/setup"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/bsm/redislock"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func (s *Server) initRepositories(ctx context.Context) error {
|
||||
@ -58,112 +52,3 @@ func (s *Server) initProxyRepository(ctx context.Context) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const bootstrapLockKey = "bouncer-bootstrap"
|
||||
|
||||
func (s *Server) bootstrapProxies(ctx context.Context) error {
|
||||
if err := s.validateBootstrap(ctx); err != nil {
|
||||
return errors.Wrap(err, "could not validate bootstrapped proxies")
|
||||
}
|
||||
|
||||
proxyRepo := s.proxyRepository
|
||||
layerRepo := s.layerRepository
|
||||
|
||||
locker := redislock.New(s.redisClient)
|
||||
|
||||
backoff := redislock.ExponentialBackoff(time.Second, time.Duration(s.bootstrapConfig.LockTimeout)*2)
|
||||
|
||||
logger.Debug(ctx, "acquiring proxies bootstrap lock", logger.F("lockTimeout", s.bootstrapConfig.LockTimeout))
|
||||
|
||||
lock, err := locker.Obtain(ctx, bootstrapLockKey, time.Duration(s.bootstrapConfig.LockTimeout), &redislock.Options{
|
||||
RetryStrategy: backoff,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := lock.Release(ctx); err != nil {
|
||||
logger.Error(ctx, "could not release lock", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Info(ctx, "bootstrapping proxies")
|
||||
|
||||
for proxyName, proxyConfig := range s.bootstrapConfig.Proxies {
|
||||
_, err := s.proxyRepository.GetProxy(ctx, proxyName)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "ignoring existing proxy", logger.F("proxyName", proxyName))
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info(ctx, "creating proxy", logger.F("proxyName", proxyName))
|
||||
|
||||
if _, err := proxyRepo.CreateProxy(ctx, proxyName, string(proxyConfig.To), proxyConfig.From...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
_, err = proxyRepo.UpdateProxy(
|
||||
ctx, proxyName,
|
||||
store.WithProxyUpdateEnabled(bool(proxyConfig.Enabled)),
|
||||
store.WithProxyUpdateWeight(int(proxyConfig.Weight)),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
for layerName, layerConfig := range proxyConfig.Layers {
|
||||
layerType := store.LayerType(layerConfig.Type)
|
||||
layerOptions := store.LayerOptions(layerConfig.Options)
|
||||
|
||||
if _, err := layerRepo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
_, err := layerRepo.UpdateLayer(
|
||||
ctx,
|
||||
proxyName, layerName,
|
||||
store.WithLayerUpdateEnabled(bool(layerConfig.Enabled)),
|
||||
store.WithLayerUpdateOptions(layerOptions),
|
||||
store.WithLayerUpdateWeight(int(layerConfig.Weight)),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const validateErrMessage = "could not validate proxy '%s': could not validate layer '%s'"
|
||||
|
||||
func (s *Server) validateBootstrap(ctx context.Context) error {
|
||||
for proxyName, proxyConf := range s.bootstrapConfig.Proxies {
|
||||
for layerName, layerConf := range proxyConf.Layers {
|
||||
layerType := store.LayerType(layerConf.Type)
|
||||
if !setup.LayerTypeExists(layerType) {
|
||||
return errors.Errorf(validateErrMessage+": could not find layer type '%s'", proxyName, layerName, layerType)
|
||||
}
|
||||
|
||||
layerOptionsSchema, err := setup.GetLayerOptionsSchema(layerType)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, validateErrMessage, proxyName, layerName)
|
||||
}
|
||||
|
||||
rawOptions := func(opts config.InterpolatedMap) map[string]any {
|
||||
return opts
|
||||
}(layerConf.Options)
|
||||
|
||||
if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil {
|
||||
return errors.Wrapf(err, validateErrMessage, proxyName, layerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -2,12 +2,14 @@ package admin
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/integration"
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
BootstrapConfig config.BootstrapConfig
|
||||
ServerConfig config.AdminServerConfig
|
||||
RedisConfig config.RedisConfig
|
||||
Integrations []integration.Integration
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
@ -16,6 +18,7 @@ func defaultOption() *Option {
|
||||
return &Option{
|
||||
ServerConfig: config.NewDefaultAdminServerConfig(),
|
||||
RedisConfig: config.NewDefaultRedisConfig(),
|
||||
Integrations: make([]integration.Integration, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,3 +39,9 @@ func WithBootstrapConfig(conf config.BootstrapConfig) OptionFunc {
|
||||
opt.BootstrapConfig = conf
|
||||
}
|
||||
}
|
||||
|
||||
func WithIntegrations(integrations ...integration.Integration) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.Integrations = integrations
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
|
||||
bouncerChi "forge.cadoles.com/cadoles/bouncer/internal/chi"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/integration"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
@ -24,9 +25,13 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
serverConfig config.AdminServerConfig
|
||||
redisConfig config.RedisConfig
|
||||
redisClient redis.UniversalClient
|
||||
serverConfig config.AdminServerConfig
|
||||
redisConfig config.RedisConfig
|
||||
|
||||
redisClient redis.UniversalClient
|
||||
|
||||
integrations []integration.Integration
|
||||
|
||||
bootstrapConfig config.BootstrapConfig
|
||||
proxyRepository store.ProxyRepository
|
||||
layerRepository store.LayerRepository
|
||||
@ -62,6 +67,12 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
|
||||
return
|
||||
}
|
||||
|
||||
if err := integration.RunOnStartup(ctx, s.integrations); err != nil {
|
||||
errs <- errors.WithStack(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.serverConfig.HTTP.Host, s.serverConfig.HTTP.Port))
|
||||
if err != nil {
|
||||
errs <- errors.WithStack(err)
|
||||
@ -187,5 +198,6 @@ func NewServer(funcs ...OptionFunc) *Server {
|
||||
serverConfig: opt.ServerConfig,
|
||||
redisConfig: opt.RedisConfig,
|
||||
bootstrapConfig: opt.BootstrapConfig,
|
||||
integrations: opt.Integrations,
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ const keyRole = "role"
|
||||
func parseToken(ctx context.Context, keys jwk.Set, issuer string, rawToken string, acceptableSkew time.Duration) (jwt.Token, error) {
|
||||
token, err := jwt.Parse(
|
||||
[]byte(rawToken),
|
||||
jwt.WithContext(ctx),
|
||||
jwt.WithKeySet(keys, jws.WithRequireKid(false)),
|
||||
jwt.WithIssuer(issuer),
|
||||
jwt.WithValidate(true),
|
||||
@ -60,3 +61,17 @@ func GenerateToken(ctx context.Context, key jwk.Key, issuer, subject string, rol
|
||||
|
||||
return string(rawToken), nil
|
||||
}
|
||||
|
||||
func GenerateTokenWithPrivateKey(ctx context.Context, privateKeyFile string, issuer string, subject string, role Role) (string, jwk.Key, error) {
|
||||
key, err := jwk.LoadOrGenerate(privateKeyFile, jwk.DefaultKeySize)
|
||||
if err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
token, err := GenerateToken(ctx, key, issuer, subject, role)
|
||||
if err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return token, key, nil
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import (
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
@ -30,20 +29,15 @@ func CreateTokenCommand() *cli.Command {
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
return errors.Wrap(err, "could not load configuration")
|
||||
}
|
||||
|
||||
subject := ctx.String("subject")
|
||||
role := ctx.String("role")
|
||||
|
||||
key, err := jwk.LoadOrGenerate(string(conf.Admin.Auth.PrivateKey), jwk.DefaultKeySize)
|
||||
token, _, err := jwt.GenerateTokenWithPrivateKey(ctx.Context, string(conf.Admin.Auth.PrivateKey), string(conf.Admin.Auth.Issuer), subject, jwt.Role(role))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
token, err := jwt.GenerateToken(ctx.Context, key, string(conf.Admin.Auth.Issuer), subject, jwt.Role(role))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
return errors.Wrap(err, "could not generate token")
|
||||
}
|
||||
|
||||
fmt.Println(token)
|
||||
|
@ -65,10 +65,16 @@ func RunCommand() *cli.Command {
|
||||
logger.SetLevel(logger.Level(conf.Logger.Level))
|
||||
}
|
||||
|
||||
integrations, err := setup.SetupIntegrations(ctx.Context, conf)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not setup integrations")
|
||||
}
|
||||
|
||||
srv := admin.NewServer(
|
||||
admin.WithServerConfig(conf.Admin),
|
||||
admin.WithRedisConfig(conf.Redis),
|
||||
admin.WithBootstrapConfig(conf.Bootstrap),
|
||||
admin.WithIntegrations(integrations...),
|
||||
)
|
||||
|
||||
addrs, srvErrs := srv.Start(ctx.Context)
|
||||
|
@ -10,12 +10,13 @@ import (
|
||||
|
||||
// Config definition
|
||||
type Config struct {
|
||||
Admin AdminServerConfig `yaml:"admin"`
|
||||
Proxy ProxyServerConfig `yaml:"proxy"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Layers LayersConfig `yaml:"layers"`
|
||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||
Admin AdminServerConfig `yaml:"admin"`
|
||||
Proxy ProxyServerConfig `yaml:"proxy"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Layers LayersConfig `yaml:"layers"`
|
||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||
Integrations IntegrationsConfig `yaml:"integrations"`
|
||||
}
|
||||
|
||||
// NewFromFile retrieves the configuration from the given file
|
||||
@ -44,12 +45,13 @@ func NewDumpDefault() *Config {
|
||||
// NewDefault return new default configuration
|
||||
func NewDefault() *Config {
|
||||
return &Config{
|
||||
Admin: NewDefaultAdminServerConfig(),
|
||||
Proxy: NewDefaultProxyServerConfig(),
|
||||
Logger: NewDefaultLoggerConfig(),
|
||||
Redis: NewDefaultRedisConfig(),
|
||||
Layers: NewDefaultLayersConfig(),
|
||||
Bootstrap: NewDefaultBootstrapConfig(),
|
||||
Admin: NewDefaultAdminServerConfig(),
|
||||
Proxy: NewDefaultProxyServerConfig(),
|
||||
Logger: NewDefaultLoggerConfig(),
|
||||
Redis: NewDefaultRedisConfig(),
|
||||
Layers: NewDefaultLayersConfig(),
|
||||
Bootstrap: NewDefaultBootstrapConfig(),
|
||||
Integrations: NewDefaultIntegrationsConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
|
27
internal/config/integrations.go
Normal file
27
internal/config/integrations.go
Normal file
@ -0,0 +1,27 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type IntegrationsConfig struct {
|
||||
Kubernetes KubernetesConfig `yaml:"kubernetes"`
|
||||
}
|
||||
|
||||
func NewDefaultIntegrationsConfig() IntegrationsConfig {
|
||||
return IntegrationsConfig{
|
||||
Kubernetes: KubernetesConfig{
|
||||
Enabled: false,
|
||||
WriterTokenSecret: "",
|
||||
ReaderTokenSecret: "",
|
||||
LockTimeout: *NewInterpolatedDuration(30 * time.Second),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type KubernetesConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
WriterTokenSecret InterpolatedString `yaml:"writerTokenSecret"`
|
||||
WriterTokenSecretNamespace InterpolatedString `yaml:"writerTokenSecretNamespace"`
|
||||
ReaderTokenSecret InterpolatedString `yaml:"readerTokenSecret"`
|
||||
ReaderTokenSecretNamespace InterpolatedString `yaml:"readerTokenSecretNamespace"`
|
||||
LockTimeout InterpolatedDuration `yaml:"lockTimeout"`
|
||||
}
|
31
internal/integration/integration.go
Normal file
31
internal/integration/integration.go
Normal file
@ -0,0 +1,31 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Integration interface {
|
||||
Integration()
|
||||
}
|
||||
|
||||
type OnStartup interface {
|
||||
Integration
|
||||
OnStartup(ctx context.Context) error
|
||||
}
|
||||
|
||||
func RunOnStartup(ctx context.Context, integrations []Integration) error {
|
||||
for _, it := range integrations {
|
||||
onStartup, ok := it.(OnStartup)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := onStartup.OnStartup(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
194
internal/integration/kubernetes/integration.go
Normal file
194
internal/integration/kubernetes/integration.go
Normal file
@ -0,0 +1,194 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/integration"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
k8serr "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
const (
|
||||
namespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
|
||||
writerTokenSubject = "bouncer-admin-kubernetes-writer"
|
||||
readerTokenSubject = "bouncer-admin-kubernetes-reader"
|
||||
)
|
||||
|
||||
type Integration struct {
|
||||
Options *Options
|
||||
}
|
||||
|
||||
// Integration implements integration.OnStartup.
|
||||
func (i *Integration) Integration() {}
|
||||
|
||||
// OnStartup implements integration.OnStartup.
|
||||
func (i *Integration) OnStartup(ctx context.Context) error {
|
||||
locker := i.Options.Locker
|
||||
timeout := i.Options.LockTimeout
|
||||
err := locker.WithLock(ctx, "bouncer-kubernetes-onstartup", timeout, func(ctx context.Context) error {
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if i.Options.WriterTokenSecret != "" {
|
||||
if err := i.upsertTokenSecret(ctx, client, i.Options.WriterTokenSecretNamespace, i.Options.WriterTokenSecret, writerTokenSubject, jwt.RoleWriter); err != nil {
|
||||
return errors.Wrap(err, "could not upsert writer token secret")
|
||||
}
|
||||
}
|
||||
|
||||
if i.Options.ReaderTokenSecret != "" {
|
||||
if err := i.upsertTokenSecret(ctx, client, i.Options.ReaderTokenSecretNamespace, i.Options.ReaderTokenSecret, readerTokenSubject, jwt.RoleReader); err != nil {
|
||||
return errors.Wrap(err, "could not upsert reader token secret")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
annotationPublicKey = "bouncer.cadoles.com/public-key"
|
||||
)
|
||||
|
||||
func (i *Integration) upsertTokenSecret(ctx context.Context, client *kubernetes.Clientset, namespace string, name string, subject string, role jwt.Role) error {
|
||||
if namespace == "" {
|
||||
defaultNamespace, err := i.getCurrentNamespace()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
namespace = defaultNamespace
|
||||
}
|
||||
|
||||
ctx = logger.With(ctx,
|
||||
logger.F("secretNamespace", namespace),
|
||||
logger.F("secretName", name),
|
||||
logger.F("tokenRole", role),
|
||||
logger.F("tokenSubject", subject),
|
||||
)
|
||||
|
||||
logger.Debug(ctx, "generating new token")
|
||||
|
||||
key, err := jwk.LoadOrGenerate(i.Options.PrivateKey, jwk.DefaultKeySize)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
publicKey, err := key.PublicKey()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
publicKeyThumbprint, err := publicKey.Thumbprint(crypto.SHA256)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
publicKeyHash := fmt.Sprintf("%x", publicKeyThumbprint)
|
||||
|
||||
alreadyExists := true
|
||||
secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serr.IsNotFound(err) {
|
||||
alreadyExists = false
|
||||
} else {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
if !alreadyExists {
|
||||
token, err := jwt.GenerateToken(ctx, key, i.Options.Issuer, subject, role)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
secret := &v1.Secret{
|
||||
Type: v1.SecretTypeOpaque,
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Annotations: map[string]string{
|
||||
annotationPublicKey: publicKeyHash,
|
||||
},
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"token": token,
|
||||
},
|
||||
}
|
||||
|
||||
logger.Info(ctx, "creating token secret")
|
||||
|
||||
if _, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
existingPublicKeyHash, exists := secret.Annotations[annotationPublicKey]
|
||||
if !exists || publicKeyHash != existingPublicKeyHash {
|
||||
token, err := jwt.GenerateToken(ctx, key, i.Options.Issuer, subject, role)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
secret.StringData = map[string]string{
|
||||
"token": token,
|
||||
}
|
||||
|
||||
if secret.Annotations == nil {
|
||||
secret.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
secret.Annotations[annotationPublicKey] = publicKeyHash
|
||||
|
||||
logger.Info(ctx, "updating token secret")
|
||||
|
||||
if _, err := client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
logger.Info(ctx, "key did not changed, doing nothing")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Integration) getCurrentNamespace() (string, error) {
|
||||
namespace, err := os.ReadFile(namespaceFile)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "could not retrieve current namespace")
|
||||
}
|
||||
|
||||
return string(namespace), nil
|
||||
}
|
||||
|
||||
func NewIntegration(funcs ...OptionFunc) *Integration {
|
||||
opts := NewOptions(funcs...)
|
||||
|
||||
return &Integration{
|
||||
Options: opts,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ integration.OnStartup = &Integration{}
|
||||
)
|
87
internal/integration/kubernetes/options.go
Normal file
87
internal/integration/kubernetes/options.go
Normal file
@ -0,0 +1,87 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock/memory"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
WriterTokenSecret string
|
||||
WriterTokenSecretNamespace string
|
||||
ReaderTokenSecret string
|
||||
ReaderTokenSecretNamespace string
|
||||
PrivateKey string
|
||||
Issuer string
|
||||
Locker lock.Locker
|
||||
LockTimeout time.Duration
|
||||
}
|
||||
|
||||
type OptionFunc func(opts *Options)
|
||||
|
||||
func NewOptions(funcs ...OptionFunc) *Options {
|
||||
opts := &Options{
|
||||
WriterTokenSecret: "",
|
||||
WriterTokenSecretNamespace: "",
|
||||
ReaderTokenSecret: "",
|
||||
ReaderTokenSecretNamespace: "",
|
||||
PrivateKey: "",
|
||||
Issuer: "",
|
||||
Locker: memory.NewLocker(),
|
||||
LockTimeout: 30 * time.Second,
|
||||
}
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func WithWriterTokenSecret(secretName string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.WriterTokenSecret = secretName
|
||||
}
|
||||
}
|
||||
|
||||
func WithWriterTokenSecretNamespace(namespace string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.WriterTokenSecretNamespace = namespace
|
||||
}
|
||||
}
|
||||
|
||||
func WithReaderTokenSecret(secretName string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.ReaderTokenSecret = secretName
|
||||
}
|
||||
}
|
||||
|
||||
func WithReaderTokenSecretNamespace(namespace string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.ReaderTokenSecretNamespace = namespace
|
||||
}
|
||||
}
|
||||
|
||||
func WithPrivateKey(privateKeyFile string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.PrivateKey = privateKeyFile
|
||||
}
|
||||
}
|
||||
|
||||
func WithIssuer(issuer string) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.Issuer = issuer
|
||||
}
|
||||
}
|
||||
|
||||
func WithLocker(locker lock.Locker) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.Locker = locker
|
||||
}
|
||||
}
|
||||
|
||||
func WithLockTimeout(timeout time.Duration) OptionFunc {
|
||||
return func(opts *Options) {
|
||||
opts.LockTimeout = timeout
|
||||
}
|
||||
}
|
10
internal/lock/locker.go
Normal file
10
internal/lock/locker.go
Normal file
@ -0,0 +1,10 @@
|
||||
package lock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Locker interface {
|
||||
WithLock(ctx context.Context, key string, timeout time.Duration, fn func(ctx context.Context) error) error
|
||||
}
|
45
internal/lock/memory/locker.go
Normal file
45
internal/lock/memory/locker.go
Normal file
@ -0,0 +1,45 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTimeout = errors.New("timeout")
|
||||
)
|
||||
|
||||
type Locker struct {
|
||||
lock chan struct{}
|
||||
}
|
||||
|
||||
// WithLock implements lock.Locker.
|
||||
func (l *Locker) WithLock(ctx context.Context, key string, timeout time.Duration, fn func(ctx context.Context) error) error {
|
||||
select {
|
||||
case l.lock <- struct{}{}:
|
||||
defer func() {
|
||||
<-l.lock
|
||||
}()
|
||||
if err := fn(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return errors.WithStack(ctx.Err())
|
||||
|
||||
case <-time.After(timeout):
|
||||
return errors.WithStack(ErrTimeout)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewLocker() *Locker {
|
||||
return &Locker{
|
||||
lock: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
var _ lock.Locker = &Locker{}
|
59
internal/lock/redis/locker.go
Normal file
59
internal/lock/redis/locker.go
Normal file
@ -0,0 +1,59 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock"
|
||||
"github.com/bsm/redislock"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Locker struct {
|
||||
client redis.UniversalClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// WithLock implements lock.Locker.
|
||||
func (l *Locker) WithLock(ctx context.Context, key string, timeout time.Duration, fn func(ctx context.Context) error) error {
|
||||
locker := redislock.New(l.client)
|
||||
|
||||
backoff := redislock.ExponentialBackoff(time.Second, timeout*2)
|
||||
|
||||
ctx = logger.With(ctx, logger.F("lockTimeout", timeout), logger.F("lockKey", key))
|
||||
|
||||
logger.Debug(ctx, "acquiring lock")
|
||||
|
||||
lock, err := locker.Obtain(ctx, key, timeout, &redislock.Options{
|
||||
RetryStrategy: backoff,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "lock obtained")
|
||||
|
||||
defer func() {
|
||||
if err := lock.Release(ctx); err != nil {
|
||||
logger.Error(ctx, "could not release lock", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "lock released")
|
||||
}()
|
||||
|
||||
if err := fn(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewLocker(client redis.UniversalClient) *Locker {
|
||||
return &Locker{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
var _ lock.Locker = &Locker{}
|
45
internal/setup/integrations.go
Normal file
45
internal/setup/integrations.go
Normal file
@ -0,0 +1,45 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/integration"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/integration/kubernetes"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock/redis"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func SetupIntegrations(ctx context.Context, conf *config.Config) ([]integration.Integration, error) {
|
||||
integrations := make([]integration.Integration, 0)
|
||||
|
||||
if conf.Integrations.Kubernetes.Enabled {
|
||||
kubernetes, err := setupKubernetesIntegration(ctx, conf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not setup kubernetes integration")
|
||||
}
|
||||
|
||||
integrations = append(integrations, kubernetes)
|
||||
}
|
||||
|
||||
return integrations, nil
|
||||
}
|
||||
|
||||
func setupKubernetesIntegration(ctx context.Context, conf *config.Config) (*kubernetes.Integration, error) {
|
||||
client := newRedisClient(conf.Redis)
|
||||
locker := redis.NewLocker(client)
|
||||
|
||||
integration := kubernetes.NewIntegration(
|
||||
kubernetes.WithReaderTokenSecret(string(conf.Integrations.Kubernetes.ReaderTokenSecret)),
|
||||
kubernetes.WithReaderTokenSecretNamespace(string(conf.Integrations.Kubernetes.ReaderTokenSecretNamespace)),
|
||||
kubernetes.WithWriterTokenSecret(string(conf.Integrations.Kubernetes.WriterTokenSecret)),
|
||||
kubernetes.WithWriterTokenSecretNamespace(string(conf.Integrations.Kubernetes.WriterTokenSecretNamespace)),
|
||||
kubernetes.WithIssuer(string(conf.Admin.Auth.Issuer)),
|
||||
kubernetes.WithPrivateKey(string(conf.Admin.Auth.PrivateKey)),
|
||||
kubernetes.WithLocker(locker),
|
||||
kubernetes.WithLockTimeout(time.Duration(conf.Integrations.Kubernetes.LockTimeout)),
|
||||
)
|
||||
|
||||
return integration, nil
|
||||
}
|
15
internal/setup/lock.go
Normal file
15
internal/setup/lock.go
Normal file
@ -0,0 +1,15 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/config"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock"
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/lock/redis"
|
||||
)
|
||||
|
||||
func SetupLocker(ctx context.Context, conf *config.Config) (lock.Locker, error) {
|
||||
client := newRedisClient(conf.Redis)
|
||||
locker := redis.NewLocker(client)
|
||||
return locker, nil
|
||||
}
|
Reference in New Issue
Block a user