From 63bf983e59d83b08b64d3b708deb9a1ab54c1610 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Thu, 5 Mar 2026 15:39:50 +0100 Subject: [PATCH] Refactor entrypoint Signed-off-by: Bryan Frimin --- .goreleaser.yaml | 15 + Dockerfile | 2 + cmd/probod-bootstrap/main.go | 44 +++ entrypoint.sh | 186 +---------- go.mod | 2 +- pkg/bootstrap/builder.go | 331 ++++++++++++++++++++ pkg/bootstrap/builder_test.go | 465 ++++++++++++++++++++++++++++ pkg/bootstrap/saml.go | 76 +++++ pkg/bootstrap/saml_test.go | 68 ++++ pkg/bootstrap/write.go | 44 +++ pkg/bootstrap/write_test.go | 153 +++++++++ pkg/probod/api_config.go | 33 +- pkg/probod/auth_config.go | 49 +-- pkg/probod/aws_config.go | 19 +- pkg/probod/connector_config.go | 46 +-- pkg/probod/custom_domains_config.go | 8 +- pkg/probod/mailer_config.go | 28 +- pkg/probod/notifications_config.go | 12 +- pkg/probod/openai_config.go | 3 +- pkg/probod/pg_config.go | 23 +- pkg/probod/probod.go | 173 +++++++---- pkg/probod/saml_config.go | 9 +- pkg/probod/scim_bridge_config.go | 3 +- pkg/probod/slack_config.go | 11 +- 24 files changed, 1442 insertions(+), 361 deletions(-) create mode 100644 cmd/probod-bootstrap/main.go create mode 100644 pkg/bootstrap/builder.go create mode 100644 pkg/bootstrap/builder_test.go create mode 100644 pkg/bootstrap/saml.go create mode 100644 pkg/bootstrap/saml_test.go create mode 100644 pkg/bootstrap/write.go create mode 100644 pkg/bootstrap/write_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c4f1f35c0..4f5a457ee 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -45,6 +45,20 @@ builds: goarch: - amd64 - arm64 + - id: probod-bootstrap-docker + main: ./cmd/probod-bootstrap/main.go + binary: probod-bootstrap + ldflags: + - -s -w + gcflags: + - -e + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 archives: - name_template: >- @@ -87,6 +101,7 @@ dockers_v2: dockerfile: Dockerfile ids: - probod-docker + - probod-bootstrap-docker extra_files: - entrypoint.sh labels: diff --git a/Dockerfile b/Dockerfile index f3fa356ca..9a6c8a1d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,11 @@ RUN useradd -m probo && \ ARG TARGETPLATFORM COPY $TARGETPLATFORM/probod /usr/local/bin/probod +COPY $TARGETPLATFORM/probod-bootstrap /usr/local/bin/probod-bootstrap COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/probod && \ + chmod +x /usr/local/bin/probod-bootstrap && \ chmod +x /usr/local/bin/entrypoint.sh && \ setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/probod && \ mkdir -p /etc/probod && \ diff --git a/cmd/probod-bootstrap/main.go b/cmd/probod-bootstrap/main.go new file mode 100644 index 000000000..9b63493da --- /dev/null +++ b/cmd/probod-bootstrap/main.go @@ -0,0 +1,44 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// probod-bootstrap generates a probod configuration file from environment variables. +package main + +import ( + "flag" + "fmt" + "os" + + "go.probo.inc/probo/pkg/bootstrap" +) + +func main() { + outputPath := flag.String("output", "/etc/probod/config.yml", "output path for the generated config file") + flag.Parse() + + builder := bootstrap.NewBuilder(nil) + + cfg, err := builder.Build() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if err := bootstrap.WriteConfig(cfg, *outputPath); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Configuration file generated at: %s\n", *outputPath) +} diff --git a/entrypoint.sh b/entrypoint.sh index 8c79ba8fc..6522fd7bb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,195 +4,13 @@ set -e # Configuration file path CONFIG_FILE="${CONFIG_FILE:-/etc/probod/config.yml}" -# Function to generate default SAML certificate and private key if not provided -generate_saml_defaults() { - if [ -z "$SAML_CERTIFICATE" ] || [ -z "$SAML_PRIVATE_KEY" ]; then - echo "Generating default SAML certificate and private key..." - - # Generate private key and certificate valid for 10 years - TEMP_KEY=$(mktemp) - TEMP_CERT=$(mktemp) - - openssl req -x509 -newkey rsa:2048 -keyout "$TEMP_KEY" -out "$TEMP_CERT" \ - -days 3650 -nodes -subj "/CN=probo-saml/O=Probo/C=US" 2>/dev/null - - # Read generated files and export as environment variables - export SAML_PRIVATE_KEY=$(cat "$TEMP_KEY") - export SAML_CERTIFICATE=$(cat "$TEMP_CERT") - - # Clean up temporary files - rm -f "$TEMP_KEY" "$TEMP_CERT" - - echo "Default SAML certificate and private key generated successfully" - fi -} - -# Function to validate CA bundle path -validate_pg_ca_bundle_path() { - if [ -n "$PG_CA_BUNDLE_PATH" ]; then - if [ -f "$PG_CA_BUNDLE_PATH" ]; then - echo "Loading PostgreSQL CA bundle from: $PG_CA_BUNDLE_PATH" - export PG_CA_BUNDLE_FILE="$PG_CA_BUNDLE_PATH" - else - echo "Warning: PG_CA_BUNDLE_PATH specified but file not found: $PG_CA_BUNDLE_PATH" - fi - fi -} - # Check if config file already exists (e.g., mounted from ConfigMap) if [ -f "$CONFIG_FILE" ]; then echo "Using existing configuration file at: $CONFIG_FILE" else echo "Generating configuration file from environment variables at: $CONFIG_FILE" - - # Generate default SAML credentials if not provided - generate_saml_defaults - - # Validate PostgreSQL CA bundle path if configured - validate_pg_ca_bundle_path - - # Create directory if it doesn't exist - mkdir -p "$(dirname "$CONFIG_FILE")" - - cat > "$CONFIG_FILE" <> "$CONFIG_FILE" <> "$CONFIG_FILE" <> "$CONFIG_FILE" <> "$CONFIG_FILE" <. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "fmt" + "os" + "strconv" + "strings" + + "go.probo.inc/probo/pkg/probod" +) + +// EnvGetter is a function that retrieves environment variables. +// This allows for easy testing by injecting a mock implementation. +type EnvGetter func(key string) string + +// Builder creates a Config from environment variables. +type Builder struct { + getEnv EnvGetter + samlCertificate string + samlPrivateKey string +} + +// NewBuilder creates a new Builder with the given environment getter. +// If getEnv is nil, os.Getenv is used. +func NewBuilder(getEnv EnvGetter) *Builder { + if getEnv == nil { + getEnv = os.Getenv + } + return &Builder{getEnv: getEnv} +} + +// SetSAMLCredentials sets pre-generated SAML certificate and private key. +// If not set, they will be generated automatically if not provided via environment. +func (b *Builder) SetSAMLCredentials(certificate, privateKey string) { + b.samlCertificate = certificate + b.samlPrivateKey = privateKey +} + +// Build creates a FullConfig from environment variables. +// Returns an error if required environment variables are missing. +func (b *Builder) Build() (*probod.FullConfig, error) { + if err := b.validateRequired(); err != nil { + return nil, err + } + + samlCert, samlKey, err := b.getSAMLCredentials() + if err != nil { + return nil, fmt.Errorf("failed to get SAML credentials: %w", err) + } + + pgCACertBundle := b.getPgCACertBundle() + + cfg := &probod.FullConfig{ + Unit: probod.UnitConfig{ + Metrics: probod.MetricsConfig{ + Addr: b.getEnvOrDefault("METRICS_ADDR", "localhost:8081"), + }, + Tracing: probod.TracingConfig{ + Addr: b.getEnvOrDefault("TRACING_ADDR", "localhost:4317"), + MaxBatchSize: b.getEnvIntOrDefault("TRACING_MAX_BATCH_SIZE", 512), + BatchTimeout: b.getEnvIntOrDefault("TRACING_BATCH_TIMEOUT", 5), + ExportTimeout: b.getEnvIntOrDefault("TRACING_EXPORT_TIMEOUT", 30), + MaxQueueSize: b.getEnvIntOrDefault("TRACING_MAX_QUEUE_SIZE", 2048), + }, + }, + Probod: probod.Config{ + BaseURL: b.getEnvOrDefault("PROBOD_BASE_URL", "http://localhost:8080"), + EncryptionKey: b.getEnv("PROBOD_ENCRYPTION_KEY"), + ChromeDPAddr: b.getEnvOrDefault("CHROME_DP_ADDR", "localhost:9222"), + Api: probod.APIConfig{ + Addr: b.getEnvOrDefault("API_ADDR", ":8080"), + ProxyProtocol: probod.ProxyProtocolConfig{ + TrustedProxies: b.parseOriginsList(b.getEnv("API_PROXY_PROTOCOL_TRUSTED_PROXIES")), + }, + Cors: probod.CorsConfig{ + AllowedOrigins: b.parseOriginsList(b.getEnvOrDefault("API_CORS_ALLOWED_ORIGINS", "http://localhost:8080")), + }, + ExtraHeaderFields: make(map[string]string), + }, + Pg: probod.PgConfig{ + Addr: b.getEnvOrDefault("PG_ADDR", "localhost:5432"), + Username: b.getEnvOrDefault("PG_USERNAME", "postgres"), + Password: b.getEnvOrDefault("PG_PASSWORD", "postgres"), + Database: b.getEnvOrDefault("PG_DATABASE", "probod"), + PoolSize: int32(b.getEnvIntOrDefault("PG_POOL_SIZE", 100)), + CACertBundle: pgCACertBundle, + Debug: b.getEnvBoolOrDefault("PG_DEBUG", false), + }, + Auth: probod.AuthConfig{ + DisableSignup: b.getEnvBoolOrDefault("AUTH_DISABLE_SIGNUP", false), + InvitationConfirmationTokenValidity: b.getEnvIntOrDefault("AUTH_INVITATION_TOKEN_VALIDITY", 3600), + PasswordResetTokenValidity: b.getEnvIntOrDefault("AUTH_PASSWORD_RESET_TOKEN_VALIDITY", 3600), + MagicLinkTokenValidity: b.getEnvIntOrDefault("AUTH_MAGIC_LINK_TOKEN_VALIDITY", 900), + Cookie: probod.CookieConfig{ + Name: b.getEnvOrDefault("AUTH_COOKIE_NAME", "SSID"), + Domain: b.getEnvOrDefault("AUTH_COOKIE_DOMAIN", "localhost"), + Secret: b.getEnv("AUTH_COOKIE_SECRET"), + Duration: b.getEnvIntOrDefault("AUTH_COOKIE_DURATION", 24), + Secure: b.getEnvBoolOrDefault("AUTH_COOKIE_SECURE", true), + }, + Password: probod.PasswordConfig{ + Pepper: b.getEnv("AUTH_PASSWORD_PEPPER"), + Iterations: b.getEnvIntOrDefault("AUTH_PASSWORD_ITERATIONS", 1000000), + }, + SAML: probod.SAMLConfig{ + SessionDuration: b.getEnvIntOrDefault("SAML_SESSION_DURATION", 604800), + CleanupIntervalSeconds: b.getEnvIntOrDefault("SAML_CLEANUP_INTERVAL_SECONDS", 0), + Certificate: samlCert, + PrivateKey: samlKey, + DomainVerificationIntervalSeconds: b.getEnvIntOrDefault("SAML_DOMAIN_VERIFICATION_INTERVAL_SECONDS", 60), + DomainVerificationResolverAddr: b.getEnvOrDefault("SAML_DOMAIN_VERIFICATION_RESOLVER_ADDR", "8.8.8.8:53"), + }, + }, + TrustCenter: probod.TrustCenterConfig{ + HTTPAddr: b.getEnvOrDefault("TRUST_CENTER_HTTP_ADDR", ":80"), + HTTPSAddr: b.getEnvOrDefault("TRUST_CENTER_HTTPS_ADDR", ":443"), + ProxyProtocol: probod.ProxyProtocolConfig{ + TrustedProxies: b.parseOriginsList(b.getEnv("TRUST_CENTER_PROXY_PROTOCOL_TRUSTED_PROXIES")), + }, + }, + AWS: probod.AWSConfig{ + Region: b.getEnvOrDefault("AWS_REGION", "us-east-1"), + Bucket: b.getEnvOrDefault("AWS_BUCKET", "probod"), + AccessKeyID: b.getEnv("AWS_ACCESS_KEY_ID"), + SecretAccessKey: b.getEnv("AWS_SECRET_ACCESS_KEY"), + Endpoint: b.getEnv("AWS_ENDPOINT"), + UsePathStyle: b.getEnvBoolOrDefault("AWS_USE_PATH_STYLE", false), + }, + Notifications: probod.NotificationsConfig{ + Mailer: probod.MailerConfig{ + SenderName: b.getEnvOrDefault("MAILER_SENDER_NAME", "Probo"), + SenderEmail: b.getEnvOrDefault("MAILER_SENDER_EMAIL", "no-reply@notification.getprobo.com"), + MailerInterval: b.getEnvIntOrDefault("MAILER_INTERVAL", 60), + SMTP: probod.SMTPConfig{ + Addr: b.getEnvOrDefault("SMTP_ADDR", "localhost:1025"), + User: b.getEnv("SMTP_USER"), + Password: b.getEnv("SMTP_PASSWORD"), + TLSRequired: b.getEnvBoolOrDefault("SMTP_TLS_REQUIRED", false), + }, + }, + Slack: probod.SlackConfig{ + SenderInterval: b.getEnvIntOrDefault("SLACK_SENDER_INTERVAL", 60), + SigningSecret: b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"), + }, + Webhook: probod.WebhookConfig{ + SenderInterval: b.getEnvIntOrDefault("WEBHOOK_SENDER_INTERVAL", 5), + CacheTTL: b.getEnvIntOrDefault("WEBHOOK_CACHE_TTL", 86400), + }, + }, + OpenAI: probod.OpenAIConfig{ + APIKey: b.getEnv("OPENAI_API_KEY"), + Temperature: b.getEnvFloatOrDefault("OPENAI_TEMPERATURE", 0.1), + ModelName: b.getEnvOrDefault("OPENAI_MODEL_NAME", "gpt-4o"), + }, + CustomDomains: probod.CustomDomainsConfig{ + RenewalInterval: b.getEnvIntOrDefault("CUSTOM_DOMAINS_RENEWAL_INTERVAL", 3600), + ProvisionInterval: b.getEnvIntOrDefault("CUSTOM_DOMAINS_PROVISION_INTERVAL", 30), + CnameTarget: b.getEnvOrDefault("CUSTOM_DOMAINS_CNAME_TARGET", "custom.getprobo.com"), + ResolverAddr: b.getEnvOrDefault("CUSTOM_DOMAINS_RESOLVER_ADDR", "8.8.8.8:53"), + ACME: probod.ACMEConfig{ + Directory: b.getEnvOrDefault("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory"), + Email: b.getEnvOrDefault("ACME_EMAIL", "admin@getprobo.com"), + KeyType: b.getEnvOrDefault("ACME_KEY_TYPE", "EC256"), + RootCA: b.getEnv("ACME_ROOT_CA"), + AccountKey: b.getEnv("ACME_ACCOUNT_KEY"), + }, + }, + SCIMBridge: probod.SCIMBridgeConfig{ + SyncInterval: b.getEnvIntOrDefault("SCIM_BRIDGE_SYNC_INTERVAL", 900), + PollInterval: b.getEnvIntOrDefault("SCIM_BRIDGE_POLL_INTERVAL", 30), + }, + ESign: probod.ESignConfig{ + TSAURL: b.getEnvOrDefault("ESIGN_TSA_URL", "http://timestamp.digicert.com"), + }, + }, + } + + if slackClientID := b.getEnv("CONNECTOR_SLACK_CLIENT_ID"); slackClientID != "" { + cfg.Probod.Connectors = []probod.ConnectorConfig{ + { + Provider: "SLACK", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: slackClientID, + ClientSecret: b.getEnv("CONNECTOR_SLACK_CLIENT_SECRET"), + RedirectURI: b.getEnv("CONNECTOR_SLACK_REDIRECT_URI"), + AuthURL: b.getEnvOrDefault("CONNECTOR_SLACK_AUTH_URL", "https://slack.com/oauth/v2/authorize"), + TokenURL: b.getEnvOrDefault("CONNECTOR_SLACK_TOKEN_URL", "https://slack.com/api/oauth.v2.access"), + Scopes: []string{"chat:write", "channels:join", "incoming-webhook"}, + }, + RawSettings: map[string]interface{}{ + "signing-secret": b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"), + }, + }, + } + } + + return cfg, nil +} + +func (b *Builder) validateRequired() error { + var missing []string + + required := []string{ + "PROBOD_ENCRYPTION_KEY", + "AUTH_COOKIE_SECRET", + "AUTH_PASSWORD_PEPPER", + } + + for _, key := range required { + if b.getEnv(key) == "" { + missing = append(missing, key) + } + } + + if slackClientID := b.getEnv("CONNECTOR_SLACK_CLIENT_ID"); slackClientID != "" { + slackRequired := []string{ + "CONNECTOR_SLACK_CLIENT_SECRET", + "CONNECTOR_SLACK_SIGNING_SECRET", + "CONNECTOR_SLACK_REDIRECT_URI", + } + for _, key := range slackRequired { + if b.getEnv(key) == "" { + missing = append(missing, key+" (required when CONNECTOR_SLACK_CLIENT_ID is set)") + } + } + } + + if len(missing) > 0 { + return fmt.Errorf("missing required environment variables:\n - %s", strings.Join(missing, "\n - ")) + } + + return nil +} + +func (b *Builder) getSAMLCredentials() (cert, key string, err error) { + cert = b.samlCertificate + key = b.samlPrivateKey + + if cert == "" { + cert = b.getEnv("SAML_CERTIFICATE") + } + if key == "" { + key = b.getEnv("SAML_PRIVATE_KEY") + } + + if cert == "" || key == "" { + cert, key, err = GenerateSAMLCertificate() + if err != nil { + return "", "", err + } + } + + return cert, key, nil +} + +func (b *Builder) getPgCACertBundle() string { + if path := b.getEnv("PG_CA_BUNDLE_PATH"); path != "" { + data, err := os.ReadFile(path) + if err == nil { + return string(data) + } + } + + return b.getEnv("PG_CA_BUNDLE") +} + +func (b *Builder) getEnvOrDefault(key, defaultValue string) string { + if value := b.getEnv(key); value != "" { + return value + } + return defaultValue +} + +func (b *Builder) getEnvIntOrDefault(key string, defaultValue int) int { + if value := b.getEnv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +func (b *Builder) getEnvFloatOrDefault(key string, defaultValue float64) float64 { + if value := b.getEnv(key); value != "" { + if floatValue, err := strconv.ParseFloat(value, 64); err == nil { + return floatValue + } + } + return defaultValue +} + +func (b *Builder) getEnvBoolOrDefault(key string, defaultValue bool) bool { + if value := b.getEnv(key); value != "" { + if boolValue, err := strconv.ParseBool(value); err == nil { + return boolValue + } + } + return defaultValue +} + +func (b *Builder) parseOriginsList(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + + var result []string + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + part = strings.Trim(part, "\"") + if part != "" { + result = append(result, part) + } + } + return result +} diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go new file mode 100644 index 000000000..53790df09 --- /dev/null +++ b/pkg/bootstrap/builder_test.go @@ -0,0 +1,465 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/probod" +) + +func mockEnv(env map[string]string) EnvGetter { + return func(key string) string { + return env[key] + } +} + +func requiredEnv() map[string]string { + return map[string]string{ + "PROBOD_ENCRYPTION_KEY": "test-encryption-key-32-bytes-long", + "AUTH_COOKIE_SECRET": "test-cookie-secret-32-bytes-long!", + "AUTH_PASSWORD_PEPPER": "test-password-pepper-32-bytes-lo", + } +} + +func TestBuilder_Build_MissingRequiredEnvVars(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantMissing []string + }{ + { + name: "all missing", + env: map[string]string{}, + wantMissing: []string{"PROBOD_ENCRYPTION_KEY", "AUTH_COOKIE_SECRET", "AUTH_PASSWORD_PEPPER"}, + }, + { + name: "missing encryption key", + env: map[string]string{ + "AUTH_COOKIE_SECRET": "secret", + "AUTH_PASSWORD_PEPPER": "pepper", + }, + wantMissing: []string{"PROBOD_ENCRYPTION_KEY"}, + }, + { + name: "missing cookie secret", + env: map[string]string{ + "PROBOD_ENCRYPTION_KEY": "key", + "AUTH_PASSWORD_PEPPER": "pepper", + }, + wantMissing: []string{"AUTH_COOKIE_SECRET"}, + }, + { + name: "slack connector missing required fields", + env: map[string]string{ + "PROBOD_ENCRYPTION_KEY": "key", + "AUTH_COOKIE_SECRET": "secret", + "AUTH_PASSWORD_PEPPER": "pepper", + "CONNECTOR_SLACK_CLIENT_ID": "client-id", + }, + wantMissing: []string{"CONNECTOR_SLACK_CLIENT_SECRET", "CONNECTOR_SLACK_SIGNING_SECRET", "CONNECTOR_SLACK_REDIRECT_URI"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := NewBuilder(mockEnv(tt.env)) + _, err := b.Build() + + require.Error(t, err) + for _, missing := range tt.wantMissing { + assert.Contains(t, err.Error(), missing) + } + }) + } +} + +func TestBuilder_Build_Defaults(t *testing.T) { + b := NewBuilder(mockEnv(requiredEnv())) + b.SetSAMLCredentials("test-cert", "test-key") + + cfg, err := b.Build() + require.NoError(t, err) + + // Unit config + assert.Equal(t, "localhost:8081", cfg.Unit.Metrics.Addr) + assert.Equal(t, "localhost:4317", cfg.Unit.Tracing.Addr) + assert.Equal(t, 512, cfg.Unit.Tracing.MaxBatchSize) + assert.Equal(t, 5, cfg.Unit.Tracing.BatchTimeout) + assert.Equal(t, 30, cfg.Unit.Tracing.ExportTimeout) + assert.Equal(t, 2048, cfg.Unit.Tracing.MaxQueueSize) + + // Probod base config + assert.Equal(t, "http://localhost:8080", cfg.Probod.BaseURL) + assert.Equal(t, "localhost:9222", cfg.Probod.ChromeDPAddr) + + // API config + assert.Equal(t, ":8080", cfg.Probod.Api.Addr) + assert.Nil(t, cfg.Probod.Api.ProxyProtocol.TrustedProxies) + assert.Equal(t, []string{"http://localhost:8080"}, cfg.Probod.Api.Cors.AllowedOrigins) + + // PG config + assert.Equal(t, "localhost:5432", cfg.Probod.Pg.Addr) + assert.Equal(t, "postgres", cfg.Probod.Pg.Username) + assert.Equal(t, "postgres", cfg.Probod.Pg.Password) + assert.Equal(t, "probod", cfg.Probod.Pg.Database) + assert.Equal(t, int32(100), cfg.Probod.Pg.PoolSize) + assert.False(t, cfg.Probod.Pg.Debug) + + // Auth config + assert.False(t, cfg.Probod.Auth.DisableSignup) + assert.Equal(t, 3600, cfg.Probod.Auth.InvitationConfirmationTokenValidity) + assert.Equal(t, 3600, cfg.Probod.Auth.PasswordResetTokenValidity) + assert.Equal(t, 900, cfg.Probod.Auth.MagicLinkTokenValidity) + assert.Equal(t, "SSID", cfg.Probod.Auth.Cookie.Name) + assert.Equal(t, "localhost", cfg.Probod.Auth.Cookie.Domain) + assert.Equal(t, 24, cfg.Probod.Auth.Cookie.Duration) + assert.True(t, cfg.Probod.Auth.Cookie.Secure) + assert.Equal(t, 1000000, cfg.Probod.Auth.Password.Iterations) + + // SAML config + assert.Equal(t, 604800, cfg.Probod.Auth.SAML.SessionDuration) + assert.Equal(t, 0, cfg.Probod.Auth.SAML.CleanupIntervalSeconds) + assert.Equal(t, 60, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds) + assert.Equal(t, "8.8.8.8:53", cfg.Probod.Auth.SAML.DomainVerificationResolverAddr) + + // Trust center config + assert.Equal(t, ":80", cfg.Probod.TrustCenter.HTTPAddr) + assert.Equal(t, ":443", cfg.Probod.TrustCenter.HTTPSAddr) + assert.Nil(t, cfg.Probod.TrustCenter.ProxyProtocol.TrustedProxies) + + // AWS config + assert.Equal(t, "us-east-1", cfg.Probod.AWS.Region) + assert.Equal(t, "probod", cfg.Probod.AWS.Bucket) + assert.False(t, cfg.Probod.AWS.UsePathStyle) + + // Notifications config + assert.Equal(t, "Probo", cfg.Probod.Notifications.Mailer.SenderName) + assert.Equal(t, "no-reply@notification.getprobo.com", cfg.Probod.Notifications.Mailer.SenderEmail) + assert.Equal(t, "localhost:1025", cfg.Probod.Notifications.Mailer.SMTP.Addr) + assert.False(t, cfg.Probod.Notifications.Mailer.SMTP.TLSRequired) + assert.Equal(t, 60, cfg.Probod.Notifications.Mailer.MailerInterval) + assert.Equal(t, 60, cfg.Probod.Notifications.Slack.SenderInterval) + assert.Empty(t, cfg.Probod.Notifications.Slack.SigningSecret) + assert.Equal(t, 5, cfg.Probod.Notifications.Webhook.SenderInterval) + assert.Equal(t, 86400, cfg.Probod.Notifications.Webhook.CacheTTL) + + // OpenAI config + assert.Equal(t, 0.1, cfg.Probod.OpenAI.Temperature) + assert.Equal(t, "gpt-4o", cfg.Probod.OpenAI.ModelName) + + // Custom domains config + assert.Equal(t, 3600, cfg.Probod.CustomDomains.RenewalInterval) + assert.Equal(t, 30, cfg.Probod.CustomDomains.ProvisionInterval) + assert.Equal(t, "custom.getprobo.com", cfg.Probod.CustomDomains.CnameTarget) + assert.Equal(t, "8.8.8.8:53", cfg.Probod.CustomDomains.ResolverAddr) + assert.Equal(t, "https://acme-v02.api.letsencrypt.org/directory", cfg.Probod.CustomDomains.ACME.Directory) + assert.Equal(t, "admin@getprobo.com", cfg.Probod.CustomDomains.ACME.Email) + assert.Equal(t, "EC256", cfg.Probod.CustomDomains.ACME.KeyType) + + // SCIM bridge config + assert.Equal(t, 900, cfg.Probod.SCIMBridge.SyncInterval) + assert.Equal(t, 30, cfg.Probod.SCIMBridge.PollInterval) + + // ESign config + assert.Equal(t, "http://timestamp.digicert.com", cfg.Probod.ESign.TSAURL) + + // No connectors by default + assert.Empty(t, cfg.Probod.Connectors) +} + +func TestBuilder_Build_CustomValues(t *testing.T) { + env := requiredEnv() + // Unit + env["METRICS_ADDR"] = "0.0.0.0:9090" + env["TRACING_ADDR"] = "jaeger:4317" + env["TRACING_MAX_BATCH_SIZE"] = "1024" + // Probod + env["PROBOD_BASE_URL"] = "https://app.example.com" + env["CHROME_DP_ADDR"] = "chrome:9222" + // API + env["API_ADDR"] = "0.0.0.0:8080" + env["API_CORS_ALLOWED_ORIGINS"] = "https://app.example.com,https://admin.example.com" + env["API_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.0.1,10.0.0.2" + // PG + env["PG_ADDR"] = "postgres.example.com:5432" + env["PG_USERNAME"] = "probo" + env["PG_PASSWORD"] = "secret123" + env["PG_DATABASE"] = "probo_prod" + env["PG_POOL_SIZE"] = "200" + env["PG_DEBUG"] = "true" + // Auth + env["AUTH_DISABLE_SIGNUP"] = "true" + env["AUTH_INVITATION_TOKEN_VALIDITY"] = "7200" + env["AUTH_PASSWORD_RESET_TOKEN_VALIDITY"] = "1800" + env["AUTH_MAGIC_LINK_TOKEN_VALIDITY"] = "600" + env["AUTH_COOKIE_DOMAIN"] = ".example.com" + env["AUTH_COOKIE_DURATION"] = "48" + // SAML + env["SAML_DOMAIN_VERIFICATION_INTERVAL_SECONDS"] = "120" + env["SAML_DOMAIN_VERIFICATION_RESOLVER_ADDR"] = "1.1.1.1:53" + // Trust center + env["TRUST_CENTER_HTTP_ADDR"] = ":8080" + env["TRUST_CENTER_HTTPS_ADDR"] = ":8443" + env["TRUST_CENTER_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.1.1,10.0.1.2" + // AWS + env["AWS_REGION"] = "eu-west-1" + env["AWS_BUCKET"] = "probo-files" + env["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" + env["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + env["AWS_ENDPOINT"] = "https://s3.example.com" + env["AWS_USE_PATH_STYLE"] = "true" + // Notifications + env["WEBHOOK_SENDER_INTERVAL"] = "10" + env["WEBHOOK_CACHE_TTL"] = "3600" + env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret" + // OpenAI + env["OPENAI_API_KEY"] = "sk-test-key" + env["OPENAI_TEMPERATURE"] = "0.5" + env["OPENAI_MODEL_NAME"] = "gpt-4-turbo" + // Custom domains + env["CUSTOM_DOMAINS_RESOLVER_ADDR"] = "1.1.1.1:53" + env["ACME_ACCOUNT_KEY"] = "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----" + // SCIM bridge + env["SCIM_BRIDGE_SYNC_INTERVAL"] = "1800" + env["SCIM_BRIDGE_POLL_INTERVAL"] = "60" + // ESign + env["ESIGN_TSA_URL"] = "http://custom.tsa.example.com" + + b := NewBuilder(mockEnv(env)) + b.SetSAMLCredentials("test-cert", "test-key") + + cfg, err := b.Build() + require.NoError(t, err) + + // Unit + assert.Equal(t, "0.0.0.0:9090", cfg.Unit.Metrics.Addr) + assert.Equal(t, "jaeger:4317", cfg.Unit.Tracing.Addr) + assert.Equal(t, 1024, cfg.Unit.Tracing.MaxBatchSize) + // Probod + assert.Equal(t, "https://app.example.com", cfg.Probod.BaseURL) + assert.Equal(t, "chrome:9222", cfg.Probod.ChromeDPAddr) + // API + assert.Equal(t, "0.0.0.0:8080", cfg.Probod.Api.Addr) + assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, cfg.Probod.Api.ProxyProtocol.TrustedProxies) + assert.Equal(t, []string{"https://app.example.com", "https://admin.example.com"}, cfg.Probod.Api.Cors.AllowedOrigins) + // PG + assert.Equal(t, "postgres.example.com:5432", cfg.Probod.Pg.Addr) + assert.Equal(t, "probo", cfg.Probod.Pg.Username) + assert.Equal(t, "secret123", cfg.Probod.Pg.Password) + assert.Equal(t, "probo_prod", cfg.Probod.Pg.Database) + assert.Equal(t, int32(200), cfg.Probod.Pg.PoolSize) + assert.True(t, cfg.Probod.Pg.Debug) + // Auth + assert.True(t, cfg.Probod.Auth.DisableSignup) + assert.Equal(t, 7200, cfg.Probod.Auth.InvitationConfirmationTokenValidity) + assert.Equal(t, 1800, cfg.Probod.Auth.PasswordResetTokenValidity) + assert.Equal(t, 600, cfg.Probod.Auth.MagicLinkTokenValidity) + assert.Equal(t, ".example.com", cfg.Probod.Auth.Cookie.Domain) + assert.Equal(t, 48, cfg.Probod.Auth.Cookie.Duration) + // SAML + assert.Equal(t, 120, cfg.Probod.Auth.SAML.DomainVerificationIntervalSeconds) + assert.Equal(t, "1.1.1.1:53", cfg.Probod.Auth.SAML.DomainVerificationResolverAddr) + // Trust center + assert.Equal(t, ":8080", cfg.Probod.TrustCenter.HTTPAddr) + assert.Equal(t, ":8443", cfg.Probod.TrustCenter.HTTPSAddr) + assert.Equal(t, []string{"10.0.1.1", "10.0.1.2"}, cfg.Probod.TrustCenter.ProxyProtocol.TrustedProxies) + // AWS + assert.Equal(t, "eu-west-1", cfg.Probod.AWS.Region) + assert.Equal(t, "probo-files", cfg.Probod.AWS.Bucket) + assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", cfg.Probod.AWS.AccessKeyID) + assert.Equal(t, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", cfg.Probod.AWS.SecretAccessKey) + assert.Equal(t, "https://s3.example.com", cfg.Probod.AWS.Endpoint) + assert.True(t, cfg.Probod.AWS.UsePathStyle) + // Notifications + assert.Equal(t, "slack-signing-secret", cfg.Probod.Notifications.Slack.SigningSecret) + assert.Equal(t, 10, cfg.Probod.Notifications.Webhook.SenderInterval) + assert.Equal(t, 3600, cfg.Probod.Notifications.Webhook.CacheTTL) + // OpenAI + assert.Equal(t, "sk-test-key", cfg.Probod.OpenAI.APIKey) + assert.Equal(t, 0.5, cfg.Probod.OpenAI.Temperature) + assert.Equal(t, "gpt-4-turbo", cfg.Probod.OpenAI.ModelName) + // Custom domains + assert.Equal(t, "1.1.1.1:53", cfg.Probod.CustomDomains.ResolverAddr) + assert.Equal(t, "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----", cfg.Probod.CustomDomains.ACME.AccountKey) + // SCIM bridge + assert.Equal(t, 1800, cfg.Probod.SCIMBridge.SyncInterval) + assert.Equal(t, 60, cfg.Probod.SCIMBridge.PollInterval) + // ESign + assert.Equal(t, "http://custom.tsa.example.com", cfg.Probod.ESign.TSAURL) +} + +func TestBuilder_Build_SlackConnector(t *testing.T) { + env := requiredEnv() + env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id" + env["CONNECTOR_SLACK_CLIENT_SECRET"] = "slack-client-secret" + env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret" + env["CONNECTOR_SLACK_REDIRECT_URI"] = "https://app.example.com/api/console/v1/connectors/complete" + + b := NewBuilder(mockEnv(env)) + b.SetSAMLCredentials("test-cert", "test-key") + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Connectors, 1) + connector := cfg.Probod.Connectors[0] + assert.Equal(t, "SLACK", connector.Provider) + assert.Equal(t, "oauth2", string(connector.Protocol)) + rawConfig := connector.RawConfig.(probod.ConnectorConfigOAuth2) + assert.Equal(t, "slack-client-id", rawConfig.ClientID) + assert.Equal(t, "slack-client-secret", rawConfig.ClientSecret) + assert.Equal(t, "https://app.example.com/api/console/v1/connectors/complete", rawConfig.RedirectURI) + assert.Equal(t, "https://slack.com/oauth/v2/authorize", rawConfig.AuthURL) + assert.Equal(t, "https://slack.com/api/oauth.v2.access", rawConfig.TokenURL) + assert.Equal(t, []string{"chat:write", "channels:join", "incoming-webhook"}, rawConfig.Scopes) + rawSettings := connector.RawSettings.(map[string]interface{}) + assert.Equal(t, "slack-signing-secret", rawSettings["signing-secret"]) +} + +func TestBuilder_Build_SlackConnector_CustomURLs(t *testing.T) { + env := requiredEnv() + env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id" + env["CONNECTOR_SLACK_CLIENT_SECRET"] = "slack-client-secret" + env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret" + env["CONNECTOR_SLACK_REDIRECT_URI"] = "https://app.example.com/callback" + env["CONNECTOR_SLACK_AUTH_URL"] = "https://custom.slack.com/oauth/authorize" + env["CONNECTOR_SLACK_TOKEN_URL"] = "https://custom.slack.com/oauth/token" + + b := NewBuilder(mockEnv(env)) + b.SetSAMLCredentials("test-cert", "test-key") + + cfg, err := b.Build() + require.NoError(t, err) + + connector := cfg.Probod.Connectors[0] + rawConfig := connector.RawConfig.(probod.ConnectorConfigOAuth2) + assert.Equal(t, "https://custom.slack.com/oauth/authorize", rawConfig.AuthURL) + assert.Equal(t, "https://custom.slack.com/oauth/token", rawConfig.TokenURL) +} + +func TestBuilder_Build_SAMLAutoGeneration(t *testing.T) { + b := NewBuilder(mockEnv(requiredEnv())) + + cfg, err := b.Build() + require.NoError(t, err) + + assert.Contains(t, cfg.Probod.Auth.SAML.Certificate, "-----BEGIN CERTIFICATE-----") + assert.Contains(t, cfg.Probod.Auth.SAML.Certificate, "-----END CERTIFICATE-----") + assert.Contains(t, cfg.Probod.Auth.SAML.PrivateKey, "-----BEGIN RSA PRIVATE KEY-----") + assert.Contains(t, cfg.Probod.Auth.SAML.PrivateKey, "-----END RSA PRIVATE KEY-----") +} + +func TestBuilder_Build_SAMLFromEnv(t *testing.T) { + env := requiredEnv() + env["SAML_CERTIFICATE"] = "env-cert" + env["SAML_PRIVATE_KEY"] = "env-key" + + b := NewBuilder(mockEnv(env)) + + cfg, err := b.Build() + require.NoError(t, err) + + assert.Equal(t, "env-cert", cfg.Probod.Auth.SAML.Certificate) + assert.Equal(t, "env-key", cfg.Probod.Auth.SAML.PrivateKey) +} + +func TestBuilder_Build_SAMLPreset(t *testing.T) { + b := NewBuilder(mockEnv(requiredEnv())) + b.SetSAMLCredentials("preset-cert", "preset-key") + + cfg, err := b.Build() + require.NoError(t, err) + + assert.Equal(t, "preset-cert", cfg.Probod.Auth.SAML.Certificate) + assert.Equal(t, "preset-key", cfg.Probod.Auth.SAML.PrivateKey) +} + +func TestBuilder_Build_PgCABundleFromEnv(t *testing.T) { + env := requiredEnv() + env["PG_CA_BUNDLE"] = "test-ca-bundle-content" + + b := NewBuilder(mockEnv(env)) + b.SetSAMLCredentials("test-cert", "test-key") + + cfg, err := b.Build() + require.NoError(t, err) + + assert.Equal(t, "test-ca-bundle-content", cfg.Probod.Pg.CACertBundle) +} + +func TestBuilder_Build_PgCABundleFromFile(t *testing.T) { + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "ca-bundle.pem") + err := os.WriteFile(caFile, []byte("ca-bundle-from-file"), 0644) + require.NoError(t, err) + + env := requiredEnv() + env["PG_CA_BUNDLE_PATH"] = caFile + + b := NewBuilder(mockEnv(env)) + b.SetSAMLCredentials("test-cert", "test-key") + + cfg, err := b.Build() + require.NoError(t, err) + + assert.Equal(t, "ca-bundle-from-file", cfg.Probod.Pg.CACertBundle) +} + +func TestBuilder_parseOriginsList(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "single origin", + input: "http://localhost:8080", + want: []string{"http://localhost:8080"}, + }, + { + name: "multiple origins", + input: "http://localhost:8080,https://example.com", + want: []string{"http://localhost:8080", "https://example.com"}, + }, + { + name: "quoted origins", + input: `"http://localhost:8080","https://example.com"`, + want: []string{"http://localhost:8080", "https://example.com"}, + }, + { + name: "with spaces", + input: "http://localhost:8080 , https://example.com", + want: []string{"http://localhost:8080", "https://example.com"}, + }, + { + name: "empty", + input: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := NewBuilder(nil) + got := b.parseOriginsList(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/bootstrap/saml.go b/pkg/bootstrap/saml.go new file mode 100644 index 000000000..9e858ced0 --- /dev/null +++ b/pkg/bootstrap/saml.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +const ( + samlCertValidityYears = 10 + samlKeyBits = 2048 +) + +// GenerateSAMLCertificate generates a self-signed certificate and private key +// for SAML authentication. The certificate is valid for 10 years. +func GenerateSAMLCertificate() (cert string, key string, err error) { + privateKey, err := rsa.GenerateKey(rand.Reader, samlKeyBits) + if err != nil { + return "", "", fmt.Errorf("generate RSA key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", fmt.Errorf("generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: "probo-saml", + Organization: []string{"Probo"}, + Country: []string{"US"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(samlCertValidityYears, 0, 0), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return "", "", fmt.Errorf("create certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + return string(certPEM), string(keyPEM), nil +} diff --git a/pkg/bootstrap/saml_test.go b/pkg/bootstrap/saml_test.go new file mode 100644 index 000000000..36b4dea70 --- /dev/null +++ b/pkg/bootstrap/saml_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSAMLCertificate(t *testing.T) { + cert, key, err := GenerateSAMLCertificate() + require.NoError(t, err) + + certBlock, _ := pem.Decode([]byte(cert)) + require.NotNil(t, certBlock, "certificate should be valid PEM") + assert.Equal(t, "CERTIFICATE", certBlock.Type) + + keyBlock, _ := pem.Decode([]byte(key)) + require.NotNil(t, keyBlock, "private key should be valid PEM") + assert.Equal(t, "RSA PRIVATE KEY", keyBlock.Type) + + parsedCert, err := x509.ParseCertificate(certBlock.Bytes) + require.NoError(t, err) + + assert.Equal(t, "probo-saml", parsedCert.Subject.CommonName) + assert.Equal(t, []string{"Probo"}, parsedCert.Subject.Organization) + assert.Equal(t, []string{"US"}, parsedCert.Subject.Country) + + assert.True(t, parsedCert.NotBefore.Before(time.Now().Add(time.Minute))) + assert.True(t, parsedCert.NotAfter.After(time.Now().AddDate(9, 0, 0))) + assert.True(t, parsedCert.NotAfter.Before(time.Now().AddDate(11, 0, 0))) +} + +func TestGenerateSAMLCertificate_UniqueSerials(t *testing.T) { + cert1, _, err := GenerateSAMLCertificate() + require.NoError(t, err) + + cert2, _, err := GenerateSAMLCertificate() + require.NoError(t, err) + + block1, _ := pem.Decode([]byte(cert1)) + block2, _ := pem.Decode([]byte(cert2)) + + parsed1, err := x509.ParseCertificate(block1.Bytes) + require.NoError(t, err) + + parsed2, err := x509.ParseCertificate(block2.Bytes) + require.NoError(t, err) + + assert.NotEqual(t, parsed1.SerialNumber, parsed2.SerialNumber) +} diff --git a/pkg/bootstrap/write.go b/pkg/bootstrap/write.go new file mode 100644 index 000000000..a806b60dc --- /dev/null +++ b/pkg/bootstrap/write.go @@ -0,0 +1,44 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + + "go.probo.inc/probo/pkg/probod" + "gopkg.in/yaml.v3" +) + +// WriteConfig writes the configuration to the specified path as YAML. +// It creates the parent directory if it doesn't exist. +func WriteConfig(cfg *probod.FullConfig, path string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + return nil +} diff --git a/pkg/bootstrap/write_test.go b/pkg/bootstrap/write_test.go new file mode 100644 index 000000000..fdba26236 --- /dev/null +++ b/pkg/bootstrap/write_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bootstrap + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/probod" + "gopkg.in/yaml.v3" +) + +func TestWriteConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "probod.yml") + + cfg := &probod.FullConfig{ + Unit: probod.UnitConfig{ + Metrics: probod.MetricsConfig{Addr: "localhost:9090"}, + }, + Probod: probod.Config{ + BaseURL: "http://localhost:8080", + EncryptionKey: "test-key", + }, + } + + err := WriteConfig(cfg, configPath) + require.NoError(t, err) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + + var loaded probod.FullConfig + err = yaml.Unmarshal(data, &loaded) + require.NoError(t, err) + + assert.Equal(t, cfg.Unit.Metrics.Addr, loaded.Unit.Metrics.Addr) + assert.Equal(t, cfg.Probod.BaseURL, loaded.Probod.BaseURL) + assert.Equal(t, cfg.Probod.EncryptionKey, loaded.Probod.EncryptionKey) +} + +func TestWriteConfig_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "nested", "dir", "probod.yml") + + cfg := &probod.FullConfig{ + Probod: probod.Config{BaseURL: "http://localhost:8080"}, + } + + err := WriteConfig(cfg, configPath) + require.NoError(t, err) + + _, err = os.Stat(configPath) + require.NoError(t, err) +} + +func TestWriteConfig_FilePermissions(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "probod.yml") + + cfg := &probod.FullConfig{} + + err := WriteConfig(cfg, configPath) + require.NoError(t, err) + + info, err := os.Stat(configPath) + require.NoError(t, err) + + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +func TestWriteConfig_CompleteConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "probod.yml") + + cfg := &probod.FullConfig{ + Unit: probod.UnitConfig{ + Metrics: probod.MetricsConfig{Addr: "localhost:8081"}, + Tracing: probod.TracingConfig{ + Addr: "localhost:4317", + MaxBatchSize: 512, + BatchTimeout: 5, + ExportTimeout: 30, + MaxQueueSize: 2048, + }, + }, + Probod: probod.Config{ + BaseURL: "http://localhost:8080", + EncryptionKey: "test-key", + ChromeDPAddr: "localhost:9222", + Api: probod.APIConfig{ + Addr: ":8080", + Cors: probod.CorsConfig{ + AllowedOrigins: []string{"http://localhost:8080"}, + }, + ExtraHeaderFields: map[string]string{}, + }, + Pg: probod.PgConfig{ + Addr: "localhost:5432", + Username: "postgres", + Password: "postgres", + Database: "probod", + PoolSize: 100, + }, + Connectors: []probod.ConnectorConfig{ + { + Provider: "slack", + Protocol: "oauth2", + RawConfig: probod.ConnectorConfigOAuth2{ + ClientID: "client-id", + ClientSecret: "client-secret", + Scopes: []string{"chat:write"}, + }, + RawSettings: map[string]interface{}{ + "signing-secret": "secret", + }, + }, + }, + }, + } + + err := WriteConfig(cfg, configPath) + require.NoError(t, err) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + + var loaded probod.FullConfig + err = yaml.Unmarshal(data, &loaded) + require.NoError(t, err) + + assert.Equal(t, cfg.Unit.Metrics.Addr, loaded.Unit.Metrics.Addr) + assert.Equal(t, cfg.Unit.Tracing.MaxBatchSize, loaded.Unit.Tracing.MaxBatchSize) + assert.Equal(t, cfg.Probod.Api.Cors.AllowedOrigins, loaded.Probod.Api.Cors.AllowedOrigins) + assert.Equal(t, cfg.Probod.Pg.PoolSize, loaded.Probod.Pg.PoolSize) + require.Len(t, loaded.Probod.Connectors, 1) + assert.Equal(t, "slack", loaded.Probod.Connectors[0].Provider) +} diff --git a/pkg/probod/api_config.go b/pkg/probod/api_config.go index 376b62415..a651a9b5d 100644 --- a/pkg/probod/api_config.go +++ b/pkg/probod/api_config.go @@ -14,23 +14,20 @@ package probod -import ( - "net" -) +// CorsConfig contains CORS settings. +type CorsConfig struct { + AllowedOrigins []string `json:"allowed-origins"` +} -type ( - corsConfig struct { - AllowedOrigins []string `json:"allowed-origins"` - } +// ProxyProtocolConfig contains proxy protocol settings. +type ProxyProtocolConfig struct { + TrustedProxies []string `json:"trusted-proxies"` +} - proxyProtocolConfig struct { - TrustedProxies []net.IP `json:"trusted-proxies"` - } - - apiConfig struct { - Addr string `json:"addr"` - ProxyProtocol proxyProtocolConfig `json:"proxy-protocol"` - Cors corsConfig `json:"cors"` - ExtraHeaderFields map[string]string `json:"extra-header-fields"` - } -) +// APIConfig contains HTTP API configuration. +type APIConfig struct { + Addr string `json:"addr"` + ProxyProtocol ProxyProtocolConfig `json:"proxy-protocol"` + Cors CorsConfig `json:"cors"` + ExtraHeaderFields map[string]string `json:"extra-header-fields"` +} diff --git a/pkg/probod/auth_config.go b/pkg/probod/auth_config.go index 78641f7cc..990c6cc3a 100644 --- a/pkg/probod/auth_config.go +++ b/pkg/probod/auth_config.go @@ -19,32 +19,33 @@ import ( "fmt" ) -type ( - authConfig struct { - Cookie cookieConfig `json:"cookie"` - Password passwordConfig `json:"password"` - DisableSignup bool `json:"disable-signup"` - InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"` - PasswordResetTokenValidity int `json:"password-reset-token-validity"` - MagicLinkTokenValidity int `json:"magic-link-token-validity"` - SAML samlConfig `json:"saml"` - } +// AuthConfig contains authentication configuration. +type AuthConfig struct { + Cookie CookieConfig `json:"cookie"` + Password PasswordConfig `json:"password"` + DisableSignup bool `json:"disable-signup"` + InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"` + PasswordResetTokenValidity int `json:"password-reset-token-validity"` + MagicLinkTokenValidity int `json:"magic-link-token-validity"` + SAML SAMLConfig `json:"saml"` +} - cookieConfig struct { - Domain string `json:"domain"` - Secret string `json:"secret"` - Duration int `json:"duration"` - Name string `json:"name"` - Secure bool `json:"secure"` - } +// CookieConfig contains session cookie configuration. +type CookieConfig struct { + Domain string `json:"domain"` + Secret string `json:"secret"` + Duration int `json:"duration"` + Name string `json:"name"` + Secure bool `json:"secure"` +} - passwordConfig struct { - Iterations uint32 `json:"iterations"` - Pepper string `json:"pepper"` - } -) +// PasswordConfig contains password hashing configuration. +type PasswordConfig struct { + Iterations int `json:"iterations"` + Pepper string `json:"pepper"` +} -func (c authConfig) GetPepperBytes() ([]byte, error) { +func (c AuthConfig) GetPepperBytes() ([]byte, error) { if c.Password.Pepper == "" { return nil, fmt.Errorf("pepper cannot be empty") } @@ -63,7 +64,7 @@ func (c authConfig) GetPepperBytes() ([]byte, error) { return []byte(c.Password.Pepper), nil } -func (c authConfig) GetCookieSecretBytes() ([]byte, error) { +func (c AuthConfig) GetCookieSecretBytes() ([]byte, error) { if c.Cookie.Secret == "" { return nil, fmt.Errorf("cookie secret cannot be empty") } diff --git a/pkg/probod/aws_config.go b/pkg/probod/aws_config.go index a4095fba0..aa8f09783 100644 --- a/pkg/probod/aws_config.go +++ b/pkg/probod/aws_config.go @@ -14,13 +14,12 @@ package probod -type ( - awsConfig struct { - Region string `json:"region"` - Bucket string `json:"bucket"` - AccessKeyID string `json:"access-key-id"` - SecretAccessKey string `json:"secret-access-key"` - Endpoint string `json:"endpoint"` - UsePathStyle bool `json:"use-path-style"` - } -) +// AWSConfig contains AWS S3 configuration. +type AWSConfig struct { + Region string `json:"region"` + Bucket string `json:"bucket"` + AccessKeyID string `json:"access-key-id"` + SecretAccessKey string `json:"secret-access-key"` + Endpoint string `json:"endpoint"` + UsePathStyle bool `json:"use-path-style"` +} diff --git a/pkg/probod/connector_config.go b/pkg/probod/connector_config.go index dadc4020a..133ba063b 100644 --- a/pkg/probod/connector_config.go +++ b/pkg/probod/connector_config.go @@ -23,26 +23,32 @@ import ( "go.probo.inc/probo/pkg/connector" ) -type ( - connectorConfig struct { - Provider string `json:"provider"` - Protocol connector.ProtocolType `json:"protocol"` - Config connector.Connector `json:"-"` - Settings any `json:"-"` +// ConnectorConfig contains connector configuration. +type ConnectorConfig struct { + Provider string `json:"provider"` + Protocol connector.ProtocolType `json:"protocol"` + Config connector.Connector `json:"-"` + RawConfig any `json:"config,omitempty"` + Settings any `json:"-"` + RawSettings any `json:"settings,omitempty"` +} + +// ConnectorConfigOAuth2 contains OAuth2 connector configuration. +type ConnectorConfigOAuth2 struct { + ClientID string `json:"client-id"` + ClientSecret string `json:"client-secret"` + RedirectURI string `json:"redirect-uri"` + AuthURL string `json:"auth-url"` + TokenURL string `json:"token-url"` + Scopes []string `json:"scopes"` + ExtraAuthParams map[string]string `json:"extra-auth-params,omitempty"` +} + +func (c *Config) GetSlackSigningSecret() string { + if c.Notifications.Slack.SigningSecret != "" { + return c.Notifications.Slack.SigningSecret } - connectorConfigOAuth2 struct { - ClientID string `json:"client-id"` - ClientSecret string `json:"client-secret"` - RedirectURI string `json:"redirect-uri"` - AuthURL string `json:"auth-url"` - TokenURL string `json:"token-url"` - Scopes []string `json:"scopes"` - ExtraAuthParams map[string]string `json:"extra-auth-params,omitempty"` - } -) - -func (c *config) GetSlackSigningSecret() string { for _, conn := range c.Connectors { if conn.Provider == "SLACK" { if settings, ok := conn.Settings.(map[string]any); ok { @@ -55,7 +61,7 @@ func (c *config) GetSlackSigningSecret() string { return "" } -func (c *connectorConfig) UnmarshalJSON(data []byte) error { +func (c *ConnectorConfig) UnmarshalJSON(data []byte) error { var tmp struct { Provider string `json:"provider"` Protocol string `json:"protocol"` @@ -80,7 +86,7 @@ func (c *connectorConfig) UnmarshalJSON(data []byte) error { switch c.Protocol { case connector.ProtocolOAuth2: - var config connectorConfigOAuth2 + var config ConnectorConfigOAuth2 if err := json.NewDecoder(bytes.NewReader(tmp.RawConfig)).Decode(&config); err != nil { return fmt.Errorf("cannot unmarshal oauth2 connector config: %w", err) } diff --git a/pkg/probod/custom_domains_config.go b/pkg/probod/custom_domains_config.go index 17b0e5f1b..1b962ba52 100644 --- a/pkg/probod/custom_domains_config.go +++ b/pkg/probod/custom_domains_config.go @@ -14,15 +14,17 @@ package probod -type customDomainsConfig struct { +// CustomDomainsConfig contains custom domain configuration. +type CustomDomainsConfig struct { RenewalInterval int `json:"renewal-interval"` ProvisionInterval int `json:"provision-interval"` ResolverAddr string `json:"resolver-addr"` CnameTarget string `json:"cname-target"` - ACME acmeConfig `json:"acme"` + ACME ACMEConfig `json:"acme"` } -type acmeConfig struct { +// ACMEConfig contains ACME certificate configuration. +type ACMEConfig struct { Directory string `json:"directory"` Email string `json:"email"` KeyType string `json:"key-type"` diff --git a/pkg/probod/mailer_config.go b/pkg/probod/mailer_config.go index 03c583e6e..3edd90178 100644 --- a/pkg/probod/mailer_config.go +++ b/pkg/probod/mailer_config.go @@ -14,18 +14,18 @@ package probod -type ( - mailerConfig struct { - MailerInterval int `json:"mailer-interval"` - SenderName string `json:"sender-name"` - SenderEmail string `json:"sender-email"` - SMTP smtpConfig `json:"smtp"` - } +// MailerConfig contains email mailer configuration. +type MailerConfig struct { + MailerInterval int `json:"mailer-interval"` + SenderName string `json:"sender-name"` + SenderEmail string `json:"sender-email"` + SMTP SMTPConfig `json:"smtp"` +} - smtpConfig struct { - Addr string `json:"addr"` - User string `json:"user"` - Password string `json:"password"` - TLSRequired bool `json:"tls-required"` - } -) +// SMTPConfig contains SMTP server configuration. +type SMTPConfig struct { + Addr string `json:"addr"` + User string `json:"user"` + Password string `json:"password"` + TLSRequired bool `json:"tls-required"` +} diff --git a/pkg/probod/notifications_config.go b/pkg/probod/notifications_config.go index ac7e373db..1cf2b2e2f 100644 --- a/pkg/probod/notifications_config.go +++ b/pkg/probod/notifications_config.go @@ -14,13 +14,15 @@ package probod -type notificationsConfig struct { - Mailer mailerConfig `json:"mailer"` - Slack slackConfig `json:"slack"` - Webhook webhookConfig `json:"webhook"` +// NotificationsConfig contains notification configuration. +type NotificationsConfig struct { + Mailer MailerConfig `json:"mailer"` + Slack SlackConfig `json:"slack"` + Webhook WebhookConfig `json:"webhook"` } -type webhookConfig struct { +// WebhookConfig contains webhook configuration. +type WebhookConfig struct { SenderInterval int `json:"sender-interval"` CacheTTL int `json:"cache-ttl"` } diff --git a/pkg/probod/openai_config.go b/pkg/probod/openai_config.go index 0acea872c..4af203e35 100644 --- a/pkg/probod/openai_config.go +++ b/pkg/probod/openai_config.go @@ -14,7 +14,8 @@ package probod -type openaiConfig struct { +// OpenAIConfig contains OpenAI API configuration. +type OpenAIConfig struct { APIKey string `json:"api-key"` Temperature float64 `json:"temperature"` ModelName string `json:"model-name"` diff --git a/pkg/probod/pg_config.go b/pkg/probod/pg_config.go index 5f4ea705d..757ec3e99 100644 --- a/pkg/probod/pg_config.go +++ b/pkg/probod/pg_config.go @@ -21,19 +21,18 @@ import ( "go.gearno.de/kit/pg" ) -type ( - pgConfig struct { - Addr string `json:"addr"` - Username string `json:"username"` - Password string `json:"password"` - Database string `json:"database"` - PoolSize int32 `json:"pool-size"` - CACertBundle string `json:"ca-cert-bundle"` - Debug bool `json:"debug"` - } -) +// PgConfig contains PostgreSQL database configuration. +type PgConfig struct { + Addr string `json:"addr"` + Username string `json:"username"` + Password string `json:"password"` + Database string `json:"database"` + PoolSize int32 `json:"pool-size"` + CACertBundle string `json:"ca-cert-bundle"` + Debug bool `json:"debug"` +} -func (cfg pgConfig) Options(options ...pg.Option) []pg.Option { +func (cfg PgConfig) Options(options ...pg.Option) []pg.Option { opts := []pg.Option{ pg.WithAddr(cfg.Addr), pg.WithUser(cfg.Username), diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d4a947ef5..51610bc1a 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -67,34 +67,64 @@ import ( type ( Implm struct { - cfg config + cfg Config } - esignConfig struct { + // FullConfig represents the complete configuration file structure. + // This is used by bootstrap to generate the YAML config file. + FullConfig struct { + Unit UnitConfig `json:"unit"` + Probod Config `json:"probod"` + } + + // UnitConfig contains unit framework configuration. + UnitConfig struct { + Metrics MetricsConfig `json:"metrics"` + Tracing TracingConfig `json:"tracing"` + } + + // MetricsConfig contains metrics server configuration. + MetricsConfig struct { + Addr string `json:"addr"` + } + + // TracingConfig contains tracing configuration. + TracingConfig struct { + Addr string `json:"addr"` + MaxBatchSize int `json:"max-batch-size"` + BatchTimeout int `json:"batch-timeout"` + ExportTimeout int `json:"export-timeout"` + MaxQueueSize int `json:"max-queue-size"` + } + + // ESignConfig contains electronic signature configuration. + ESignConfig struct { TSAURL string `json:"tsa-url"` } - config struct { - BaseURL *baseurl.BaseURL `json:"base-url"` - EncryptionKey cipher.EncryptionKey `json:"encryption-key"` - Pg pgConfig `json:"pg"` - Api apiConfig `json:"api"` - Auth authConfig `json:"auth"` - TrustCenter trustCenterConfig `json:"trust-center"` - AWS awsConfig `json:"aws"` - Notifications notificationsConfig `json:"notifications"` - Connectors []connectorConfig `json:"connectors"` - OpenAI openaiConfig `json:"openai"` - ChromeDPAddr string `json:"chrome-dp-addr"` - CustomDomains customDomainsConfig `json:"custom-domains"` - SCIMBridge scimBridgeConfig `json:"scim-bridge"` - ESign esignConfig `json:"esign"` + // Config represents the probod application configuration. + Config struct { + BaseURL string `json:"base-url"` + EncryptionKey string `json:"encryption-key"` + Pg PgConfig `json:"pg"` + Api APIConfig `json:"api"` + Auth AuthConfig `json:"auth"` + TrustCenter TrustCenterConfig `json:"trust-center"` + AWS AWSConfig `json:"aws"` + Notifications NotificationsConfig `json:"notifications"` + Connectors []ConnectorConfig `json:"connectors"` + OpenAI OpenAIConfig `json:"openai"` + ChromeDPAddr string `json:"chrome-dp-addr"` + CustomDomains CustomDomainsConfig `json:"custom-domains"` + SCIMBridge SCIMBridgeConfig `json:"scim-bridge"` + ESign ESignConfig `json:"esign"` } - trustCenterConfig struct { + // TrustCenterConfig contains trust center server configuration. + TrustCenterConfig struct { HTTPAddr string `json:"http-addr"` HTTPSAddr string `json:"https-addr"` - ProxyProtocol proxyProtocolConfig `json:"proxy-protocol"` + ProxyProtocol ProxyProtocolConfig `json:"proxy-protocol"` } ) @@ -105,12 +135,12 @@ var ( func New() *Implm { return &Implm{ - cfg: config{ - BaseURL: baseurl.MustParse("http://localhost:8080"), - Api: apiConfig{ + cfg: Config{ + BaseURL: "http://localhost:8080", + Api: APIConfig{ Addr: "localhost:8080", }, - Pg: pgConfig{ + Pg: PgConfig{ Addr: "localhost:5432", Username: "postgres", Password: "postgres", @@ -118,12 +148,12 @@ func New() *Implm { PoolSize: 100, }, ChromeDPAddr: "localhost:9222", - Auth: authConfig{ - Password: passwordConfig{ + Auth: AuthConfig{ + Password: PasswordConfig{ Pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes", Iterations: 1000000, }, - Cookie: cookieConfig{ + Cookie: CookieConfig{ Name: "SSID", Secret: "this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes", Duration: 24, @@ -134,53 +164,53 @@ func New() *Implm { InvitationConfirmationTokenValidity: 3600, PasswordResetTokenValidity: 3600, MagicLinkTokenValidity: 900, - SAML: samlConfig{ + SAML: SAMLConfig{ SessionDuration: 604800, CleanupIntervalSeconds: 86400, DomainVerificationIntervalSeconds: 60, DomainVerificationResolverAddr: "8.8.8.8:53", }, }, - TrustCenter: trustCenterConfig{ + TrustCenter: TrustCenterConfig{ HTTPAddr: ":80", HTTPSAddr: ":443", }, - AWS: awsConfig{ + AWS: AWSConfig{ Region: "us-east-1", Bucket: "probod", }, - Notifications: notificationsConfig{ - Mailer: mailerConfig{ + Notifications: NotificationsConfig{ + Mailer: MailerConfig{ MailerInterval: 60, SenderEmail: "no-reply@notification.getprobo.com", SenderName: "Probo", - SMTP: smtpConfig{ + SMTP: SMTPConfig{ Addr: "localhost:1025", }, }, - Slack: slackConfig{ + Slack: SlackConfig{ SenderInterval: 60, }, - Webhook: webhookConfig{ + Webhook: WebhookConfig{ SenderInterval: 5, CacheTTL: 86400, }, }, - CustomDomains: customDomainsConfig{ + CustomDomains: CustomDomainsConfig{ RenewalInterval: 3600, ProvisionInterval: 30, ResolverAddr: "8.8.8.8:53", - ACME: acmeConfig{ + ACME: ACMEConfig{ Directory: "https://acme-v02.api.letsencrypt.org/directory", Email: "admin@getprobo.com", KeyType: "EC256", }, }, - SCIMBridge: scimBridgeConfig{ + SCIMBridge: SCIMBridgeConfig{ SyncInterval: 60, // 15 minutes PollInterval: 30, // 30 seconds }, - ESign: esignConfig{ + ESign: ESignConfig{ TSAURL: "http://timestamp.digicert.com", }, }, @@ -201,6 +231,19 @@ func (impl *Implm) Run( ctx, rootSpan := tracer.Start(parentCtx, "probod.Run") defer rootSpan.End() + // Parse config values that need conversion from strings to complex types + baseURL, err := baseurl.Parse(impl.cfg.BaseURL) + if err != nil { + rootSpan.RecordError(err) + return fmt.Errorf("cannot parse base URL: %w", err) + } + + var encryptionKey cipher.EncryptionKey + if err := encryptionKey.UnmarshalText([]byte(impl.cfg.EncryptionKey)); err != nil { + rootSpan.RecordError(err) + return fmt.Errorf("cannot parse encryption key: %w", err) + } + wg := sync.WaitGroup{} ctx, cancel := context.WithCancelCause(ctx) defer cancel(context.Canceled) @@ -328,8 +371,8 @@ func (impl *Implm) Run( SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour, Bucket: impl.cfg.AWS.Bucket, TokenSecret: impl.cfg.Auth.Cookie.Secret, - BaseURL: impl.cfg.BaseURL, - EncryptionKey: impl.cfg.EncryptionKey, + BaseURL: baseURL, + EncryptionKey: encryptionKey, Certificate: samlCert, PrivateKey: samlKey, Logger: l.Named("iam"), @@ -378,8 +421,8 @@ func (impl *Implm) Run( slackService := slack.NewService( pgClient, impl.cfg.GetSlackSigningSecret(), - impl.cfg.BaseURL.String(), - impl.cfg.EncryptionKey, + baseURL.String(), + encryptionKey, impl.cfg.Auth.Cookie.Secret, l.Named("slack"), ) @@ -395,11 +438,11 @@ func (impl *Implm) Run( proboService, err := probo.NewService( ctx, - impl.cfg.EncryptionKey, + encryptionKey, pgClient, s3Client, impl.cfg.AWS.Bucket, - impl.cfg.BaseURL.String(), + baseURL.String(), impl.cfg.Auth.Cookie.Secret, agentConfig, html2pdfConverter, @@ -418,8 +461,8 @@ func (impl *Implm) Run( pgClient, s3Client, impl.cfg.AWS.Bucket, - impl.cfg.BaseURL.String(), - impl.cfg.EncryptionKey, + baseURL.String(), + encryptionKey, impl.cfg.GetSlackSigningSecret(), iamService, esignService, @@ -439,7 +482,7 @@ func (impl *Implm) Run( ESign: esignService, Slack: slackService, ConnectorRegistry: defaultConnectorRegistry, - BaseURL: impl.cfg.BaseURL, + BaseURL: baseURL, Agent: agent, CustomDomainCname: impl.cfg.CustomDomains.CnameTarget, TokenSecret: impl.cfg.Auth.Cookie.Secret, @@ -495,7 +538,7 @@ func (impl *Implm) Run( ) slackSenderCtx, stopSlackSender := context.WithCancel(context.Background()) - slackSender := slack.NewSender(pgClient, l.Named("slack-sender"), impl.cfg.EncryptionKey, slack.Config{ + slackSender := slack.NewSender(pgClient, l.Named("slack-sender"), encryptionKey, slack.Config{ Interval: time.Duration(impl.cfg.Notifications.Slack.SenderInterval) * time.Second, }) wg.Go( @@ -510,7 +553,7 @@ func (impl *Implm) Run( webhookSender := webhook.NewSender(pgClient, l.Named("webhook-sender"), webhook.Config{ Interval: time.Duration(impl.cfg.Notifications.Webhook.SenderInterval) * time.Second, CacheTTL: time.Duration(impl.cfg.Notifications.Webhook.CacheTTL) * time.Second, - EncryptionKey: impl.cfg.EncryptionKey, + EncryptionKey: encryptionKey, }) wg.Go( func() { @@ -560,6 +603,7 @@ func (impl *Implm) Run( serverHandler.TrustCenterHandler(), acmeService, proboService, + encryptionKey, ); err != nil { cancel(fmt.Errorf("trust center server crashed: %w", err)) } @@ -633,7 +677,7 @@ func (impl *Implm) runApiServer( } if len(impl.cfg.Api.ProxyProtocol.TrustedProxies) > 0 { - policy := proxyproto.TrustProxyHeaderFrom(impl.cfg.Api.ProxyProtocol.TrustedProxies...) + policy := proxyproto.TrustProxyHeaderFrom(parseIPs(impl.cfg.Api.ProxyProtocol.TrustedProxies)...) listener = &proxyproto.Listener{ Listener: listener, @@ -725,14 +769,15 @@ func (impl *Implm) runTrustCenterServer( trustRouter http.Handler, acmeService *certmanager.ACMEService, proboService *probo.Service, + encryptionKey cipher.EncryptionKey, ) error { tracer := tp.Tracer("go.probo.inc/probo/pkg/probod") ctx, span := tracer.Start(ctx, "probod.runTrustCenterServer") defer span.End() - certSelector := certmanager.NewSelector(pgClient, impl.cfg.EncryptionKey) + certSelector := certmanager.NewSelector(pgClient, encryptionKey) - warmer := certmanager.NewCacheStore(pgClient, impl.cfg.EncryptionKey, l) + warmer := certmanager.NewCacheStore(pgClient, encryptionKey, l) if err := warmer.WarmCache(ctx); err != nil { span.RecordError(err) l.ErrorCtx(ctx, "cannot warm certificate cache", log.Error(err)) @@ -743,13 +788,13 @@ func (impl *Implm) runTrustCenterServer( renewalInterval = time.Hour } - renewer := certmanager.NewRenewer(pgClient, acmeService, impl.cfg.EncryptionKey, renewalInterval, l) + renewer := certmanager.NewRenewer(pgClient, acmeService, encryptionKey, renewalInterval, l) certProvisioningInterval := time.Duration(impl.cfg.CustomDomains.ProvisionInterval) * time.Second if certProvisioningInterval == 0 { certProvisioningInterval = 30 * time.Second } - certProvisioner := certmanager.NewProvisioner(pgClient, acmeService, impl.cfg.EncryptionKey, impl.cfg.CustomDomains.CnameTarget, certProvisioningInterval, impl.cfg.CustomDomains.ResolverAddr, l) + certProvisioner := certmanager.NewProvisioner(pgClient, acmeService, encryptionKey, impl.cfg.CustomDomains.CnameTarget, certProvisioningInterval, impl.cfg.CustomDomains.ResolverAddr, l) g, ctx := errgroup.WithContext(ctx) @@ -772,7 +817,7 @@ func (impl *Implm) runTrustCenterServer( httpACMEHandler := certmanager.NewACMEChallengeHandler( pgClient, - impl.cfg.EncryptionKey, + encryptionKey, l.Named("http_acme_handler"), ) @@ -798,7 +843,7 @@ func (impl *Implm) runTrustCenterServer( defer func() { _ = listener.Close() }() if len(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies) > 0 { - policy := proxyproto.TrustProxyHeaderFrom(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies...) + policy := proxyproto.TrustProxyHeaderFrom(parseIPs(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies)...) listener = &proxyproto.Listener{ Listener: listener, @@ -818,7 +863,7 @@ func (impl *Implm) runTrustCenterServer( acmeHandler := certmanager.NewACMEChallengeHandler( pgClient, - impl.cfg.EncryptionKey, + encryptionKey, l.Named("acme_handler"), ) @@ -884,7 +929,7 @@ func (impl *Implm) runTrustCenterServer( defer func() { _ = listener.Close() }() if len(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies) > 0 { - policy := proxyproto.TrustProxyHeaderFrom(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies...) + policy := proxyproto.TrustProxyHeaderFrom(parseIPs(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies)...) listener = &proxyproto.Listener{ Listener: listener, @@ -935,3 +980,15 @@ func (impl *Implm) runTrustCenterServer( return ctx.Err() } + +// parseIPs converts a slice of string IP addresses to net.IP. +// Invalid IPs are skipped. +func parseIPs(strs []string) []net.IP { + ips := make([]net.IP, 0, len(strs)) + for _, s := range strs { + if ip := net.ParseIP(s); ip != nil { + ips = append(ips, ip) + } + } + return ips +} diff --git a/pkg/probod/saml_config.go b/pkg/probod/saml_config.go index ddd8f3d77..385c67e64 100644 --- a/pkg/probod/saml_config.go +++ b/pkg/probod/saml_config.go @@ -18,7 +18,8 @@ import ( "time" ) -type samlConfig struct { +// SAMLConfig contains SAML authentication configuration. +type SAMLConfig struct { SessionDuration int `json:"session-duration"` CleanupIntervalSeconds int `json:"cleanup-interval-seconds"` Certificate string `json:"certificate"` @@ -27,14 +28,14 @@ type samlConfig struct { DomainVerificationResolverAddr string `json:"domain-verification-resolver-addr"` } -func (c samlConfig) SessionDurationTime() time.Duration { +func (c SAMLConfig) SessionDurationTime() time.Duration { if c.SessionDuration == 0 { return 7 * 24 * time.Hour } return time.Duration(c.SessionDuration) * time.Second } -func (c samlConfig) CleanupInterval() time.Duration { +func (c SAMLConfig) CleanupInterval() time.Duration { if c.CleanupIntervalSeconds == 0 { return 0 } @@ -42,6 +43,6 @@ func (c samlConfig) CleanupInterval() time.Duration { return time.Duration(c.CleanupIntervalSeconds) * time.Second } -func (c samlConfig) DomainVerificationInterval() time.Duration { +func (c SAMLConfig) DomainVerificationInterval() time.Duration { return time.Duration(c.DomainVerificationIntervalSeconds) * time.Second } diff --git a/pkg/probod/scim_bridge_config.go b/pkg/probod/scim_bridge_config.go index 5415a90de..239189140 100644 --- a/pkg/probod/scim_bridge_config.go +++ b/pkg/probod/scim_bridge_config.go @@ -14,7 +14,8 @@ package probod -type scimBridgeConfig struct { +// SCIMBridgeConfig contains SCIM bridge configuration. +type SCIMBridgeConfig struct { // SyncInterval is the time between sync attempts for each bridge (in seconds). // Default: 900 (15 minutes) SyncInterval int `json:"sync-interval"` diff --git a/pkg/probod/slack_config.go b/pkg/probod/slack_config.go index 26fd9d527..c22978d44 100644 --- a/pkg/probod/slack_config.go +++ b/pkg/probod/slack_config.go @@ -14,9 +14,8 @@ package probod -type ( - slackConfig struct { - SenderInterval int `json:"sender-interval"` - SigningSecret string `json:"signing-secret"` - } -) +// SlackConfig contains Slack notification configuration. +type SlackConfig struct { + SenderInterval int `json:"sender-interval"` + SigningSecret string `json:"signing-secret"` +}