Refactor entrypoint

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-05 15:39:50 +01:00
parent fea53b589a
commit 63bf983e59
24 changed files with 1442 additions and 361 deletions

View File

@@ -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:

View File

@@ -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 && \

View 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.
// 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)
}

View File

@@ -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" <<EOF
unit:
metrics:
addr: "${METRICS_ADDR:-localhost:8081}"
tracing:
addr: "${TRACING_ADDR:-localhost:4317}"
max-batch-size: ${TRACING_MAX_BATCH_SIZE:-512}
batch-timeout: ${TRACING_BATCH_TIMEOUT:-5}
export-timeout: ${TRACING_EXPORT_TIMEOUT:-30}
max-queue-size: ${TRACING_MAX_QUEUE_SIZE:-2048}
probod:
base-url: "${PROBOD_BASE_URL:-http://localhost:8080}"
encryption-key: "${PROBOD_ENCRYPTION_KEY:?PROBOD_ENCRYPTION_KEY is required}"
chrome-dp-addr: "${CHROME_DP_ADDR:-localhost:9222}"
api:
addr: "${API_ADDR:-:8080}"
cors:
allowed-origins: [${API_CORS_ALLOWED_ORIGINS:-"http://localhost:8080"}]
extra-header-fields: {}
pg:
addr: "${PG_ADDR:-localhost:5432}"
username: "${PG_USERNAME:-postgres}"
password: "${PG_PASSWORD:-postgres}"
database: "${PG_DATABASE:-probod}"
pool-size: ${PG_POOL_SIZE:-100}
EOF
# Add PostgreSQL CA bundle if configured
if [ -n "$PG_CA_BUNDLE_FILE" ]; then
cat >> "$CONFIG_FILE" <<EOF
ca-cert-bundle: |
$(sed 's/^/ /' "$PG_CA_BUNDLE_FILE")
EOF
elif [ -n "$PG_CA_BUNDLE" ]; then
cat >> "$CONFIG_FILE" <<EOF
ca-cert-bundle: |
$(echo "$PG_CA_BUNDLE" | sed 's/^/ /')
EOF
fi
cat >> "$CONFIG_FILE" <<EOF
auth:
disable-signup: ${AUTH_DISABLE_SIGNUP:-false}
invitation-confirmation-token-validity: ${AUTH_INVITATION_TOKEN_VALIDITY:-3600}
cookie:
name: "${AUTH_COOKIE_NAME:-SSID}"
domain: "${AUTH_COOKIE_DOMAIN:-localhost}"
secret: "${AUTH_COOKIE_SECRET:?AUTH_COOKIE_SECRET is required}"
duration: ${AUTH_COOKIE_DURATION:-24}
secure: ${AUTH_COOKIE_SECURE:-true}
password:
pepper: "${AUTH_PASSWORD_PEPPER:?AUTH_PASSWORD_PEPPER is required}"
iterations: ${AUTH_PASSWORD_ITERATIONS:-1000000}
saml:
session-duration: ${SAML_SESSION_DURATION:-604800}
cleanup-interval-seconds: ${SAML_CLEANUP_INTERVAL_SECONDS:-0}
certificate: |
$(echo "${SAML_CERTIFICATE:-}" | sed 's/^/ /')
private-key: |
$(echo "${SAML_PRIVATE_KEY:-}" | sed 's/^/ /')
trust-auth:
cookie-name: "${TRUST_AUTH_COOKIE_NAME:-TCT}"
cookie-domain: "${TRUST_AUTH_COOKIE_DOMAIN:-localhost}"
cookie-duration: ${TRUST_AUTH_COOKIE_DURATION:-24}
token-duration: ${TRUST_AUTH_TOKEN_DURATION:-168}
report-url-duration: ${TRUST_AUTH_REPORT_URL_DURATION:-15}
token-secret: "${TRUST_AUTH_TOKEN_SECRET:?TRUST_AUTH_TOKEN_SECRET is required}"
scope: "${TRUST_AUTH_SCOPE:-trust_center_readonly}"
token-type: "${TRUST_AUTH_TOKEN_TYPE:-trust_center_access}"
aws:
region: "${AWS_REGION:-us-east-1}"
bucket: "${AWS_BUCKET:-probod}"
access-key-id: "${AWS_ACCESS_KEY_ID:-}"
secret-access-key: "${AWS_SECRET_ACCESS_KEY:-}"
endpoint: "${AWS_ENDPOINT:-}"
use-path-style: ${AWS_USE_PATH_STYLE:-false}
notifications:
mailer:
sender-name: "${MAILER_SENDER_NAME:-Probo}"
sender-email: "${MAILER_SENDER_EMAIL:-no-reply@notification.getprobo.com}"
smtp:
addr: "${SMTP_ADDR:-localhost:1025}"
user: "${SMTP_USER:-}"
password: "${SMTP_PASSWORD:-}"
tls-required: ${SMTP_TLS_REQUIRED:-false}
mailer-interval: ${MAILER_INTERVAL:-60}
slack:
sender-interval: ${SLACK_SENDER_INTERVAL:-60}
openai:
api-key: "${OPENAI_API_KEY:-}"
temperature: ${OPENAI_TEMPERATURE:-0.1}
model-name: "${OPENAI_MODEL_NAME:-gpt-4o}"
custom-domains:
renewal-interval: ${CUSTOM_DOMAINS_RENEWAL_INTERVAL:-3600}
provision-interval: ${CUSTOM_DOMAINS_PROVISION_INTERVAL:-30}
cname-target: "${CUSTOM_DOMAINS_CNAME_TARGET:-custom.getprobo.com}"
acme:
directory: "${ACME_DIRECTORY:-https://acme-v02.api.letsencrypt.org/directory}"
email: "${ACME_EMAIL:-admin@getprobo.com}"
key-type: "${ACME_KEY_TYPE:-EC256}"
root-ca: "${ACME_ROOT_CA:-}"
trust-center:
http-addr: "${TRUST_CENTER_HTTP_ADDR:-:80}"
https-addr: "${TRUST_CENTER_HTTPS_ADDR:-:443}"
EOF
# Add connectors if configured
if [ -n "$CONNECTOR_SLACK_CLIENT_ID" ]; then
cat >> "$CONFIG_FILE" <<EOF
connectors:
- provider: "slack"
protocol: "oauth2"
config:
client-id: "${CONNECTOR_SLACK_CLIENT_ID}"
client-secret: "${CONNECTOR_SLACK_CLIENT_SECRET:?CONNECTOR_SLACK_CLIENT_SECRET is required when CONNECTOR_SLACK_CLIENT_ID is set}"
redirect-uri: "${CONNECTOR_SLACK_REDIRECT_URI:-https://localhost:8080/api/console/v1/connectors/complete}"
auth-url: "${CONNECTOR_SLACK_AUTH_URL:-https://slack.com/oauth/v2/authorize}"
token-url: "${CONNECTOR_SLACK_TOKEN_URL:-https://slack.com/api/oauth.v2.access}"
scopes:
- "chat:write"
- "channels:join"
- "incoming-webhook"
settings:
signing-secret: "${CONNECTOR_SLACK_SIGNING_SECRET:?CONNECTOR_SLACK_SIGNING_SECRET is required when CONNECTOR_SLACK_CLIENT_ID is set}"
EOF
fi
echo "Configuration file generated at: $CONFIG_FILE"
# Generate configuration from environment variables
probod-bootstrap -output "$CONFIG_FILE"
fi
# Execute probod with the generated config

2
go.mod
View File

@@ -39,6 +39,7 @@ require (
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
google.golang.org/api v0.269.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -143,7 +144,6 @@ require (
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/gotestsum v1.13.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

331
pkg/bootstrap/builder.go Normal file
View 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
}

View 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
View 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
}

View 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
View 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
View 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)
}

View File

@@ -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"`
}

View File

@@ -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")
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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"`
}