331
pkg/bootstrap/builder.go
Normal file
331
pkg/bootstrap/builder.go
Normal file
@@ -0,0 +1,331 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
465
pkg/bootstrap/builder_test.go
Normal file
465
pkg/bootstrap/builder_test.go
Normal file
@@ -0,0 +1,465 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
76
pkg/bootstrap/saml.go
Normal file
76
pkg/bootstrap/saml.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
68
pkg/bootstrap/saml_test.go
Normal file
68
pkg/bootstrap/saml_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
44
pkg/bootstrap/write.go
Normal file
44
pkg/bootstrap/write.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
153
pkg/bootstrap/write_test.go
Normal file
153
pkg/bootstrap/write_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user