@@ -45,6 +45,20 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- 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:
|
archives:
|
||||||
- name_template: >-
|
- name_template: >-
|
||||||
@@ -87,6 +101,7 @@ dockers_v2:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ids:
|
ids:
|
||||||
- probod-docker
|
- probod-docker
|
||||||
|
- probod-bootstrap-docker
|
||||||
extra_files:
|
extra_files:
|
||||||
- entrypoint.sh
|
- entrypoint.sh
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ RUN useradd -m probo && \
|
|||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
COPY $TARGETPLATFORM/probod /usr/local/bin/probod
|
COPY $TARGETPLATFORM/probod /usr/local/bin/probod
|
||||||
|
COPY $TARGETPLATFORM/probod-bootstrap /usr/local/bin/probod-bootstrap
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/probod && \
|
RUN chmod +x /usr/local/bin/probod && \
|
||||||
|
chmod +x /usr/local/bin/probod-bootstrap && \
|
||||||
chmod +x /usr/local/bin/entrypoint.sh && \
|
chmod +x /usr/local/bin/entrypoint.sh && \
|
||||||
setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/probod && \
|
setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/probod && \
|
||||||
mkdir -p /etc/probod && \
|
mkdir -p /etc/probod && \
|
||||||
|
|||||||
44
cmd/probod-bootstrap/main.go
Normal file
44
cmd/probod-bootstrap/main.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.
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
186
entrypoint.sh
186
entrypoint.sh
@@ -4,195 +4,13 @@ set -e
|
|||||||
# Configuration file path
|
# Configuration file path
|
||||||
CONFIG_FILE="${CONFIG_FILE:-/etc/probod/config.yml}"
|
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)
|
# Check if config file already exists (e.g., mounted from ConfigMap)
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
echo "Using existing configuration file at: $CONFIG_FILE"
|
echo "Using existing configuration file at: $CONFIG_FILE"
|
||||||
else
|
else
|
||||||
echo "Generating configuration file from environment variables at: $CONFIG_FILE"
|
echo "Generating configuration file from environment variables at: $CONFIG_FILE"
|
||||||
|
# Generate configuration from environment variables
|
||||||
# Generate default SAML credentials if not provided
|
probod-bootstrap -output "$CONFIG_FILE"
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Execute probod with the generated config
|
# Execute probod with the generated config
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -39,6 +39,7 @@ require (
|
|||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
google.golang.org/api v0.269.0
|
google.golang.org/api v0.269.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -143,7 +144,6 @@ require (
|
|||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
gotest.tools/gotestsum v1.13.0 // indirect
|
gotest.tools/gotestsum v1.13.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -14,23 +14,20 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
import (
|
// CorsConfig contains CORS settings.
|
||||||
"net"
|
type CorsConfig struct {
|
||||||
)
|
AllowedOrigins []string `json:"allowed-origins"`
|
||||||
|
}
|
||||||
|
|
||||||
type (
|
// ProxyProtocolConfig contains proxy protocol settings.
|
||||||
corsConfig struct {
|
type ProxyProtocolConfig struct {
|
||||||
AllowedOrigins []string `json:"allowed-origins"`
|
TrustedProxies []string `json:"trusted-proxies"`
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyProtocolConfig struct {
|
// APIConfig contains HTTP API configuration.
|
||||||
TrustedProxies []net.IP `json:"trusted-proxies"`
|
type APIConfig struct {
|
||||||
}
|
Addr string `json:"addr"`
|
||||||
|
ProxyProtocol ProxyProtocolConfig `json:"proxy-protocol"`
|
||||||
apiConfig struct {
|
Cors CorsConfig `json:"cors"`
|
||||||
Addr string `json:"addr"`
|
ExtraHeaderFields map[string]string `json:"extra-header-fields"`
|
||||||
ProxyProtocol proxyProtocolConfig `json:"proxy-protocol"`
|
}
|
||||||
Cors corsConfig `json:"cors"`
|
|
||||||
ExtraHeaderFields map[string]string `json:"extra-header-fields"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -19,32 +19,33 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
// AuthConfig contains authentication configuration.
|
||||||
authConfig struct {
|
type AuthConfig struct {
|
||||||
Cookie cookieConfig `json:"cookie"`
|
Cookie CookieConfig `json:"cookie"`
|
||||||
Password passwordConfig `json:"password"`
|
Password PasswordConfig `json:"password"`
|
||||||
DisableSignup bool `json:"disable-signup"`
|
DisableSignup bool `json:"disable-signup"`
|
||||||
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
|
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
|
||||||
PasswordResetTokenValidity int `json:"password-reset-token-validity"`
|
PasswordResetTokenValidity int `json:"password-reset-token-validity"`
|
||||||
MagicLinkTokenValidity int `json:"magic-link-token-validity"`
|
MagicLinkTokenValidity int `json:"magic-link-token-validity"`
|
||||||
SAML samlConfig `json:"saml"`
|
SAML SAMLConfig `json:"saml"`
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieConfig struct {
|
// CookieConfig contains session cookie configuration.
|
||||||
Domain string `json:"domain"`
|
type CookieConfig struct {
|
||||||
Secret string `json:"secret"`
|
Domain string `json:"domain"`
|
||||||
Duration int `json:"duration"`
|
Secret string `json:"secret"`
|
||||||
Name string `json:"name"`
|
Duration int `json:"duration"`
|
||||||
Secure bool `json:"secure"`
|
Name string `json:"name"`
|
||||||
}
|
Secure bool `json:"secure"`
|
||||||
|
}
|
||||||
|
|
||||||
passwordConfig struct {
|
// PasswordConfig contains password hashing configuration.
|
||||||
Iterations uint32 `json:"iterations"`
|
type PasswordConfig struct {
|
||||||
Pepper string `json:"pepper"`
|
Iterations int `json:"iterations"`
|
||||||
}
|
Pepper string `json:"pepper"`
|
||||||
)
|
}
|
||||||
|
|
||||||
func (c authConfig) GetPepperBytes() ([]byte, error) {
|
func (c AuthConfig) GetPepperBytes() ([]byte, error) {
|
||||||
if c.Password.Pepper == "" {
|
if c.Password.Pepper == "" {
|
||||||
return nil, fmt.Errorf("pepper cannot be empty")
|
return nil, fmt.Errorf("pepper cannot be empty")
|
||||||
}
|
}
|
||||||
@@ -63,7 +64,7 @@ func (c authConfig) GetPepperBytes() ([]byte, error) {
|
|||||||
return []byte(c.Password.Pepper), nil
|
return []byte(c.Password.Pepper), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c authConfig) GetCookieSecretBytes() ([]byte, error) {
|
func (c AuthConfig) GetCookieSecretBytes() ([]byte, error) {
|
||||||
if c.Cookie.Secret == "" {
|
if c.Cookie.Secret == "" {
|
||||||
return nil, fmt.Errorf("cookie secret cannot be empty")
|
return nil, fmt.Errorf("cookie secret cannot be empty")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,12 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
type (
|
// AWSConfig contains AWS S3 configuration.
|
||||||
awsConfig struct {
|
type AWSConfig struct {
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
Bucket string `json:"bucket"`
|
Bucket string `json:"bucket"`
|
||||||
AccessKeyID string `json:"access-key-id"`
|
AccessKeyID string `json:"access-key-id"`
|
||||||
SecretAccessKey string `json:"secret-access-key"`
|
SecretAccessKey string `json:"secret-access-key"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
UsePathStyle bool `json:"use-path-style"`
|
UsePathStyle bool `json:"use-path-style"`
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|||||||
@@ -23,26 +23,32 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
// ConnectorConfig contains connector configuration.
|
||||||
connectorConfig struct {
|
type ConnectorConfig struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Protocol connector.ProtocolType `json:"protocol"`
|
Protocol connector.ProtocolType `json:"protocol"`
|
||||||
Config connector.Connector `json:"-"`
|
Config connector.Connector `json:"-"`
|
||||||
Settings any `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 {
|
for _, conn := range c.Connectors {
|
||||||
if conn.Provider == "SLACK" {
|
if conn.Provider == "SLACK" {
|
||||||
if settings, ok := conn.Settings.(map[string]any); ok {
|
if settings, ok := conn.Settings.(map[string]any); ok {
|
||||||
@@ -55,7 +61,7 @@ func (c *config) GetSlackSigningSecret() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *connectorConfig) UnmarshalJSON(data []byte) error {
|
func (c *ConnectorConfig) UnmarshalJSON(data []byte) error {
|
||||||
var tmp struct {
|
var tmp struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
@@ -80,7 +86,7 @@ func (c *connectorConfig) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
switch c.Protocol {
|
switch c.Protocol {
|
||||||
case connector.ProtocolOAuth2:
|
case connector.ProtocolOAuth2:
|
||||||
var config connectorConfigOAuth2
|
var config ConnectorConfigOAuth2
|
||||||
if err := json.NewDecoder(bytes.NewReader(tmp.RawConfig)).Decode(&config); err != nil {
|
if err := json.NewDecoder(bytes.NewReader(tmp.RawConfig)).Decode(&config); err != nil {
|
||||||
return fmt.Errorf("cannot unmarshal oauth2 connector config: %w", err)
|
return fmt.Errorf("cannot unmarshal oauth2 connector config: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,17 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
type customDomainsConfig struct {
|
// CustomDomainsConfig contains custom domain configuration.
|
||||||
|
type CustomDomainsConfig struct {
|
||||||
RenewalInterval int `json:"renewal-interval"`
|
RenewalInterval int `json:"renewal-interval"`
|
||||||
ProvisionInterval int `json:"provision-interval"`
|
ProvisionInterval int `json:"provision-interval"`
|
||||||
ResolverAddr string `json:"resolver-addr"`
|
ResolverAddr string `json:"resolver-addr"`
|
||||||
CnameTarget string `json:"cname-target"`
|
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"`
|
Directory string `json:"directory"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
KeyType string `json:"key-type"`
|
KeyType string `json:"key-type"`
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
type (
|
// MailerConfig contains email mailer configuration.
|
||||||
mailerConfig struct {
|
type MailerConfig struct {
|
||||||
MailerInterval int `json:"mailer-interval"`
|
MailerInterval int `json:"mailer-interval"`
|
||||||
SenderName string `json:"sender-name"`
|
SenderName string `json:"sender-name"`
|
||||||
SenderEmail string `json:"sender-email"`
|
SenderEmail string `json:"sender-email"`
|
||||||
SMTP smtpConfig `json:"smtp"`
|
SMTP SMTPConfig `json:"smtp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpConfig struct {
|
// SMTPConfig contains SMTP server configuration.
|
||||||
Addr string `json:"addr"`
|
type SMTPConfig struct {
|
||||||
User string `json:"user"`
|
Addr string `json:"addr"`
|
||||||
Password string `json:"password"`
|
User string `json:"user"`
|
||||||
TLSRequired bool `json:"tls-required"`
|
Password string `json:"password"`
|
||||||
}
|
TLSRequired bool `json:"tls-required"`
|
||||||
)
|
}
|
||||||
|
|||||||
@@ -14,13 +14,15 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
type notificationsConfig struct {
|
// NotificationsConfig contains notification configuration.
|
||||||
Mailer mailerConfig `json:"mailer"`
|
type NotificationsConfig struct {
|
||||||
Slack slackConfig `json:"slack"`
|
Mailer MailerConfig `json:"mailer"`
|
||||||
Webhook webhookConfig `json:"webhook"`
|
Slack SlackConfig `json:"slack"`
|
||||||
|
Webhook WebhookConfig `json:"webhook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type webhookConfig struct {
|
// WebhookConfig contains webhook configuration.
|
||||||
|
type WebhookConfig struct {
|
||||||
SenderInterval int `json:"sender-interval"`
|
SenderInterval int `json:"sender-interval"`
|
||||||
CacheTTL int `json:"cache-ttl"`
|
CacheTTL int `json:"cache-ttl"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
type openaiConfig struct {
|
// OpenAIConfig contains OpenAI API configuration.
|
||||||
|
type OpenAIConfig struct {
|
||||||
APIKey string `json:"api-key"`
|
APIKey string `json:"api-key"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
ModelName string `json:"model-name"`
|
ModelName string `json:"model-name"`
|
||||||
|
|||||||
@@ -21,19 +21,18 @@ import (
|
|||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
// PgConfig contains PostgreSQL database configuration.
|
||||||
pgConfig struct {
|
type PgConfig struct {
|
||||||
Addr string `json:"addr"`
|
Addr string `json:"addr"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
PoolSize int32 `json:"pool-size"`
|
PoolSize int32 `json:"pool-size"`
|
||||||
CACertBundle string `json:"ca-cert-bundle"`
|
CACertBundle string `json:"ca-cert-bundle"`
|
||||||
Debug bool `json:"debug"`
|
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{
|
opts := []pg.Option{
|
||||||
pg.WithAddr(cfg.Addr),
|
pg.WithAddr(cfg.Addr),
|
||||||
pg.WithUser(cfg.Username),
|
pg.WithUser(cfg.Username),
|
||||||
|
|||||||
@@ -67,34 +67,64 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
Implm struct {
|
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"`
|
TSAURL string `json:"tsa-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
config struct {
|
// Config represents the probod application configuration.
|
||||||
BaseURL *baseurl.BaseURL `json:"base-url"`
|
Config struct {
|
||||||
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
|
BaseURL string `json:"base-url"`
|
||||||
Pg pgConfig `json:"pg"`
|
EncryptionKey string `json:"encryption-key"`
|
||||||
Api apiConfig `json:"api"`
|
Pg PgConfig `json:"pg"`
|
||||||
Auth authConfig `json:"auth"`
|
Api APIConfig `json:"api"`
|
||||||
TrustCenter trustCenterConfig `json:"trust-center"`
|
Auth AuthConfig `json:"auth"`
|
||||||
AWS awsConfig `json:"aws"`
|
TrustCenter TrustCenterConfig `json:"trust-center"`
|
||||||
Notifications notificationsConfig `json:"notifications"`
|
AWS AWSConfig `json:"aws"`
|
||||||
Connectors []connectorConfig `json:"connectors"`
|
Notifications NotificationsConfig `json:"notifications"`
|
||||||
OpenAI openaiConfig `json:"openai"`
|
Connectors []ConnectorConfig `json:"connectors"`
|
||||||
ChromeDPAddr string `json:"chrome-dp-addr"`
|
OpenAI OpenAIConfig `json:"openai"`
|
||||||
CustomDomains customDomainsConfig `json:"custom-domains"`
|
ChromeDPAddr string `json:"chrome-dp-addr"`
|
||||||
SCIMBridge scimBridgeConfig `json:"scim-bridge"`
|
CustomDomains CustomDomainsConfig `json:"custom-domains"`
|
||||||
ESign esignConfig `json:"esign"`
|
SCIMBridge SCIMBridgeConfig `json:"scim-bridge"`
|
||||||
|
ESign ESignConfig `json:"esign"`
|
||||||
}
|
}
|
||||||
|
|
||||||
trustCenterConfig struct {
|
// TrustCenterConfig contains trust center server configuration.
|
||||||
|
TrustCenterConfig struct {
|
||||||
HTTPAddr string `json:"http-addr"`
|
HTTPAddr string `json:"http-addr"`
|
||||||
HTTPSAddr string `json:"https-addr"`
|
HTTPSAddr string `json:"https-addr"`
|
||||||
ProxyProtocol proxyProtocolConfig `json:"proxy-protocol"`
|
ProxyProtocol ProxyProtocolConfig `json:"proxy-protocol"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,12 +135,12 @@ var (
|
|||||||
|
|
||||||
func New() *Implm {
|
func New() *Implm {
|
||||||
return &Implm{
|
return &Implm{
|
||||||
cfg: config{
|
cfg: Config{
|
||||||
BaseURL: baseurl.MustParse("http://localhost:8080"),
|
BaseURL: "http://localhost:8080",
|
||||||
Api: apiConfig{
|
Api: APIConfig{
|
||||||
Addr: "localhost:8080",
|
Addr: "localhost:8080",
|
||||||
},
|
},
|
||||||
Pg: pgConfig{
|
Pg: PgConfig{
|
||||||
Addr: "localhost:5432",
|
Addr: "localhost:5432",
|
||||||
Username: "postgres",
|
Username: "postgres",
|
||||||
Password: "postgres",
|
Password: "postgres",
|
||||||
@@ -118,12 +148,12 @@ func New() *Implm {
|
|||||||
PoolSize: 100,
|
PoolSize: 100,
|
||||||
},
|
},
|
||||||
ChromeDPAddr: "localhost:9222",
|
ChromeDPAddr: "localhost:9222",
|
||||||
Auth: authConfig{
|
Auth: AuthConfig{
|
||||||
Password: passwordConfig{
|
Password: PasswordConfig{
|
||||||
Pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes",
|
Pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes",
|
||||||
Iterations: 1000000,
|
Iterations: 1000000,
|
||||||
},
|
},
|
||||||
Cookie: cookieConfig{
|
Cookie: CookieConfig{
|
||||||
Name: "SSID",
|
Name: "SSID",
|
||||||
Secret: "this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes",
|
Secret: "this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes",
|
||||||
Duration: 24,
|
Duration: 24,
|
||||||
@@ -134,53 +164,53 @@ func New() *Implm {
|
|||||||
InvitationConfirmationTokenValidity: 3600,
|
InvitationConfirmationTokenValidity: 3600,
|
||||||
PasswordResetTokenValidity: 3600,
|
PasswordResetTokenValidity: 3600,
|
||||||
MagicLinkTokenValidity: 900,
|
MagicLinkTokenValidity: 900,
|
||||||
SAML: samlConfig{
|
SAML: SAMLConfig{
|
||||||
SessionDuration: 604800,
|
SessionDuration: 604800,
|
||||||
CleanupIntervalSeconds: 86400,
|
CleanupIntervalSeconds: 86400,
|
||||||
DomainVerificationIntervalSeconds: 60,
|
DomainVerificationIntervalSeconds: 60,
|
||||||
DomainVerificationResolverAddr: "8.8.8.8:53",
|
DomainVerificationResolverAddr: "8.8.8.8:53",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
TrustCenter: trustCenterConfig{
|
TrustCenter: TrustCenterConfig{
|
||||||
HTTPAddr: ":80",
|
HTTPAddr: ":80",
|
||||||
HTTPSAddr: ":443",
|
HTTPSAddr: ":443",
|
||||||
},
|
},
|
||||||
AWS: awsConfig{
|
AWS: AWSConfig{
|
||||||
Region: "us-east-1",
|
Region: "us-east-1",
|
||||||
Bucket: "probod",
|
Bucket: "probod",
|
||||||
},
|
},
|
||||||
Notifications: notificationsConfig{
|
Notifications: NotificationsConfig{
|
||||||
Mailer: mailerConfig{
|
Mailer: MailerConfig{
|
||||||
MailerInterval: 60,
|
MailerInterval: 60,
|
||||||
SenderEmail: "no-reply@notification.getprobo.com",
|
SenderEmail: "no-reply@notification.getprobo.com",
|
||||||
SenderName: "Probo",
|
SenderName: "Probo",
|
||||||
SMTP: smtpConfig{
|
SMTP: SMTPConfig{
|
||||||
Addr: "localhost:1025",
|
Addr: "localhost:1025",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Slack: slackConfig{
|
Slack: SlackConfig{
|
||||||
SenderInterval: 60,
|
SenderInterval: 60,
|
||||||
},
|
},
|
||||||
Webhook: webhookConfig{
|
Webhook: WebhookConfig{
|
||||||
SenderInterval: 5,
|
SenderInterval: 5,
|
||||||
CacheTTL: 86400,
|
CacheTTL: 86400,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CustomDomains: customDomainsConfig{
|
CustomDomains: CustomDomainsConfig{
|
||||||
RenewalInterval: 3600,
|
RenewalInterval: 3600,
|
||||||
ProvisionInterval: 30,
|
ProvisionInterval: 30,
|
||||||
ResolverAddr: "8.8.8.8:53",
|
ResolverAddr: "8.8.8.8:53",
|
||||||
ACME: acmeConfig{
|
ACME: ACMEConfig{
|
||||||
Directory: "https://acme-v02.api.letsencrypt.org/directory",
|
Directory: "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
Email: "admin@getprobo.com",
|
Email: "admin@getprobo.com",
|
||||||
KeyType: "EC256",
|
KeyType: "EC256",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SCIMBridge: scimBridgeConfig{
|
SCIMBridge: SCIMBridgeConfig{
|
||||||
SyncInterval: 60, // 15 minutes
|
SyncInterval: 60, // 15 minutes
|
||||||
PollInterval: 30, // 30 seconds
|
PollInterval: 30, // 30 seconds
|
||||||
},
|
},
|
||||||
ESign: esignConfig{
|
ESign: ESignConfig{
|
||||||
TSAURL: "http://timestamp.digicert.com",
|
TSAURL: "http://timestamp.digicert.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -201,6 +231,19 @@ func (impl *Implm) Run(
|
|||||||
ctx, rootSpan := tracer.Start(parentCtx, "probod.Run")
|
ctx, rootSpan := tracer.Start(parentCtx, "probod.Run")
|
||||||
defer rootSpan.End()
|
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{}
|
wg := sync.WaitGroup{}
|
||||||
ctx, cancel := context.WithCancelCause(ctx)
|
ctx, cancel := context.WithCancelCause(ctx)
|
||||||
defer cancel(context.Canceled)
|
defer cancel(context.Canceled)
|
||||||
@@ -328,8 +371,8 @@ func (impl *Implm) Run(
|
|||||||
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
|
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
|
||||||
Bucket: impl.cfg.AWS.Bucket,
|
Bucket: impl.cfg.AWS.Bucket,
|
||||||
TokenSecret: impl.cfg.Auth.Cookie.Secret,
|
TokenSecret: impl.cfg.Auth.Cookie.Secret,
|
||||||
BaseURL: impl.cfg.BaseURL,
|
BaseURL: baseURL,
|
||||||
EncryptionKey: impl.cfg.EncryptionKey,
|
EncryptionKey: encryptionKey,
|
||||||
Certificate: samlCert,
|
Certificate: samlCert,
|
||||||
PrivateKey: samlKey,
|
PrivateKey: samlKey,
|
||||||
Logger: l.Named("iam"),
|
Logger: l.Named("iam"),
|
||||||
@@ -378,8 +421,8 @@ func (impl *Implm) Run(
|
|||||||
slackService := slack.NewService(
|
slackService := slack.NewService(
|
||||||
pgClient,
|
pgClient,
|
||||||
impl.cfg.GetSlackSigningSecret(),
|
impl.cfg.GetSlackSigningSecret(),
|
||||||
impl.cfg.BaseURL.String(),
|
baseURL.String(),
|
||||||
impl.cfg.EncryptionKey,
|
encryptionKey,
|
||||||
impl.cfg.Auth.Cookie.Secret,
|
impl.cfg.Auth.Cookie.Secret,
|
||||||
l.Named("slack"),
|
l.Named("slack"),
|
||||||
)
|
)
|
||||||
@@ -395,11 +438,11 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
proboService, err := probo.NewService(
|
proboService, err := probo.NewService(
|
||||||
ctx,
|
ctx,
|
||||||
impl.cfg.EncryptionKey,
|
encryptionKey,
|
||||||
pgClient,
|
pgClient,
|
||||||
s3Client,
|
s3Client,
|
||||||
impl.cfg.AWS.Bucket,
|
impl.cfg.AWS.Bucket,
|
||||||
impl.cfg.BaseURL.String(),
|
baseURL.String(),
|
||||||
impl.cfg.Auth.Cookie.Secret,
|
impl.cfg.Auth.Cookie.Secret,
|
||||||
agentConfig,
|
agentConfig,
|
||||||
html2pdfConverter,
|
html2pdfConverter,
|
||||||
@@ -418,8 +461,8 @@ func (impl *Implm) Run(
|
|||||||
pgClient,
|
pgClient,
|
||||||
s3Client,
|
s3Client,
|
||||||
impl.cfg.AWS.Bucket,
|
impl.cfg.AWS.Bucket,
|
||||||
impl.cfg.BaseURL.String(),
|
baseURL.String(),
|
||||||
impl.cfg.EncryptionKey,
|
encryptionKey,
|
||||||
impl.cfg.GetSlackSigningSecret(),
|
impl.cfg.GetSlackSigningSecret(),
|
||||||
iamService,
|
iamService,
|
||||||
esignService,
|
esignService,
|
||||||
@@ -439,7 +482,7 @@ func (impl *Implm) Run(
|
|||||||
ESign: esignService,
|
ESign: esignService,
|
||||||
Slack: slackService,
|
Slack: slackService,
|
||||||
ConnectorRegistry: defaultConnectorRegistry,
|
ConnectorRegistry: defaultConnectorRegistry,
|
||||||
BaseURL: impl.cfg.BaseURL,
|
BaseURL: baseURL,
|
||||||
Agent: agent,
|
Agent: agent,
|
||||||
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
||||||
TokenSecret: impl.cfg.Auth.Cookie.Secret,
|
TokenSecret: impl.cfg.Auth.Cookie.Secret,
|
||||||
@@ -495,7 +538,7 @@ func (impl *Implm) Run(
|
|||||||
)
|
)
|
||||||
|
|
||||||
slackSenderCtx, stopSlackSender := context.WithCancel(context.Background())
|
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,
|
Interval: time.Duration(impl.cfg.Notifications.Slack.SenderInterval) * time.Second,
|
||||||
})
|
})
|
||||||
wg.Go(
|
wg.Go(
|
||||||
@@ -510,7 +553,7 @@ func (impl *Implm) Run(
|
|||||||
webhookSender := webhook.NewSender(pgClient, l.Named("webhook-sender"), webhook.Config{
|
webhookSender := webhook.NewSender(pgClient, l.Named("webhook-sender"), webhook.Config{
|
||||||
Interval: time.Duration(impl.cfg.Notifications.Webhook.SenderInterval) * time.Second,
|
Interval: time.Duration(impl.cfg.Notifications.Webhook.SenderInterval) * time.Second,
|
||||||
CacheTTL: time.Duration(impl.cfg.Notifications.Webhook.CacheTTL) * time.Second,
|
CacheTTL: time.Duration(impl.cfg.Notifications.Webhook.CacheTTL) * time.Second,
|
||||||
EncryptionKey: impl.cfg.EncryptionKey,
|
EncryptionKey: encryptionKey,
|
||||||
})
|
})
|
||||||
wg.Go(
|
wg.Go(
|
||||||
func() {
|
func() {
|
||||||
@@ -560,6 +603,7 @@ func (impl *Implm) Run(
|
|||||||
serverHandler.TrustCenterHandler(),
|
serverHandler.TrustCenterHandler(),
|
||||||
acmeService,
|
acmeService,
|
||||||
proboService,
|
proboService,
|
||||||
|
encryptionKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
cancel(fmt.Errorf("trust center server crashed: %w", err))
|
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 {
|
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 = &proxyproto.Listener{
|
||||||
Listener: listener,
|
Listener: listener,
|
||||||
@@ -725,14 +769,15 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
trustRouter http.Handler,
|
trustRouter http.Handler,
|
||||||
acmeService *certmanager.ACMEService,
|
acmeService *certmanager.ACMEService,
|
||||||
proboService *probo.Service,
|
proboService *probo.Service,
|
||||||
|
encryptionKey cipher.EncryptionKey,
|
||||||
) error {
|
) error {
|
||||||
tracer := tp.Tracer("go.probo.inc/probo/pkg/probod")
|
tracer := tp.Tracer("go.probo.inc/probo/pkg/probod")
|
||||||
ctx, span := tracer.Start(ctx, "probod.runTrustCenterServer")
|
ctx, span := tracer.Start(ctx, "probod.runTrustCenterServer")
|
||||||
defer span.End()
|
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 {
|
if err := warmer.WarmCache(ctx); err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
l.ErrorCtx(ctx, "cannot warm certificate cache", log.Error(err))
|
l.ErrorCtx(ctx, "cannot warm certificate cache", log.Error(err))
|
||||||
@@ -743,13 +788,13 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
renewalInterval = time.Hour
|
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
|
certProvisioningInterval := time.Duration(impl.cfg.CustomDomains.ProvisionInterval) * time.Second
|
||||||
if certProvisioningInterval == 0 {
|
if certProvisioningInterval == 0 {
|
||||||
certProvisioningInterval = 30 * time.Second
|
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)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
@@ -772,7 +817,7 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
|
|
||||||
httpACMEHandler := certmanager.NewACMEChallengeHandler(
|
httpACMEHandler := certmanager.NewACMEChallengeHandler(
|
||||||
pgClient,
|
pgClient,
|
||||||
impl.cfg.EncryptionKey,
|
encryptionKey,
|
||||||
l.Named("http_acme_handler"),
|
l.Named("http_acme_handler"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -798,7 +843,7 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
defer func() { _ = listener.Close() }()
|
defer func() { _ = listener.Close() }()
|
||||||
|
|
||||||
if len(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies) > 0 {
|
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 = &proxyproto.Listener{
|
||||||
Listener: listener,
|
Listener: listener,
|
||||||
@@ -818,7 +863,7 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
|
|
||||||
acmeHandler := certmanager.NewACMEChallengeHandler(
|
acmeHandler := certmanager.NewACMEChallengeHandler(
|
||||||
pgClient,
|
pgClient,
|
||||||
impl.cfg.EncryptionKey,
|
encryptionKey,
|
||||||
l.Named("acme_handler"),
|
l.Named("acme_handler"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -884,7 +929,7 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
defer func() { _ = listener.Close() }()
|
defer func() { _ = listener.Close() }()
|
||||||
|
|
||||||
if len(impl.cfg.TrustCenter.ProxyProtocol.TrustedProxies) > 0 {
|
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 = &proxyproto.Listener{
|
||||||
Listener: listener,
|
Listener: listener,
|
||||||
@@ -935,3 +980,15 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
|
|
||||||
return ctx.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type samlConfig struct {
|
// SAMLConfig contains SAML authentication configuration.
|
||||||
|
type SAMLConfig struct {
|
||||||
SessionDuration int `json:"session-duration"`
|
SessionDuration int `json:"session-duration"`
|
||||||
CleanupIntervalSeconds int `json:"cleanup-interval-seconds"`
|
CleanupIntervalSeconds int `json:"cleanup-interval-seconds"`
|
||||||
Certificate string `json:"certificate"`
|
Certificate string `json:"certificate"`
|
||||||
@@ -27,14 +28,14 @@ type samlConfig struct {
|
|||||||
DomainVerificationResolverAddr string `json:"domain-verification-resolver-addr"`
|
DomainVerificationResolverAddr string `json:"domain-verification-resolver-addr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c samlConfig) SessionDurationTime() time.Duration {
|
func (c SAMLConfig) SessionDurationTime() time.Duration {
|
||||||
if c.SessionDuration == 0 {
|
if c.SessionDuration == 0 {
|
||||||
return 7 * 24 * time.Hour
|
return 7 * 24 * time.Hour
|
||||||
}
|
}
|
||||||
return time.Duration(c.SessionDuration) * time.Second
|
return time.Duration(c.SessionDuration) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c samlConfig) CleanupInterval() time.Duration {
|
func (c SAMLConfig) CleanupInterval() time.Duration {
|
||||||
if c.CleanupIntervalSeconds == 0 {
|
if c.CleanupIntervalSeconds == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,6 @@ func (c samlConfig) CleanupInterval() time.Duration {
|
|||||||
return time.Duration(c.CleanupIntervalSeconds) * time.Second
|
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
|
return time.Duration(c.DomainVerificationIntervalSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
package probod
|
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).
|
// SyncInterval is the time between sync attempts for each bridge (in seconds).
|
||||||
// Default: 900 (15 minutes)
|
// Default: 900 (15 minutes)
|
||||||
SyncInterval int `json:"sync-interval"`
|
SyncInterval int `json:"sync-interval"`
|
||||||
|
|||||||
@@ -14,9 +14,8 @@
|
|||||||
|
|
||||||
package probod
|
package probod
|
||||||
|
|
||||||
type (
|
// SlackConfig contains Slack notification configuration.
|
||||||
slackConfig struct {
|
type SlackConfig struct {
|
||||||
SenderInterval int `json:"sender-interval"`
|
SenderInterval int `json:"sender-interval"`
|
||||||
SigningSecret string `json:"signing-secret"`
|
SigningSecret string `json:"signing-secret"`
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user