Files
probo/pkg/bootstrap/builder.go
2026-03-13 18:24:02 +01:00

319 lines
11 KiB
Go

// 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"
)
type EnvGetter func(key string) string
type Builder struct {
getEnv EnvGetter
samlCertificate string
samlPrivateKey string
}
func NewBuilder(getEnv EnvGetter) *Builder {
if getEnv == nil {
getEnv = os.Getenv
}
return &Builder{getEnv: getEnv}
}
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"),
MaxTokens: b.getEnvIntOrDefault("OPENAI_MAX_TOKENS", 4096),
},
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]any{
"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.ParseInt(value, 10, 32); err == nil {
return int(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.SplitSeq(s, ",") {
part = strings.TrimSpace(part)
part = strings.Trim(part, "\"")
if part != "" {
result = append(result, part)
}
}
return result
}