From d34a4bfda348dcc126867e9ab61bdc986cc5461a Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 23 Apr 2026 16:00:56 +0530 Subject: [PATCH 01/18] feat: upgrade thunder version to 0.32.0 and update related configurations --- docker-compose.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f22250e..c46d6e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,6 @@ services: - ./mail-infra/services/silver-config/postfix/recipient_access:/etc/postfix/recipient_access:ro - ./mail-infra/conf/silver.yaml:/etc/postfix/silver.yaml - ./mail-infra/services/silver-config/certbot/keys/etc:/etc/letsencrypt:ro - - postfix-spool:/var/spool/postfix - - postfix-logs:/var/log networks: - mail-network depends_on: @@ -28,13 +26,12 @@ services: volumes: - ./mail-infra/services/silver-config/raven/conf:/etc/raven:rw - ./mail-infra/services/silver-config/raven/certs:/certs:ro - - postfix-spool:/var/spool/postfix + - /var/run/docker.sock:/var/run/docker.sock:ro environment: - DB_FILE=/app/data/databases/shared.db networks: - mail-network restart: unless-stopped - user: "0:0" opendkim-server: image: ghcr.io/lsflk/silver-dkim:main @@ -54,19 +51,21 @@ services: - no-new-privileges:true thunder-db-init: - image: ghcr.io/asgardeo/thunder:0.22.0 + image: ghcr.io/asgardeo/thunder:0.32.0 container_name: thunder-db-init - command: sh -c "cp -r /opt/thunder/repository/database/* /data/" + command: sh -c "cp -r /opt/thunder/repository/database/* /data/ && (cp -r /opt/thunder/consent/repository/database/* /consent-data/ 2>/dev/null || true)" volumes: - thunder-db:/data + - consent-db:/consent-data restart: "no" thunder-setup: - image: ghcr.io/asgardeo/thunder:0.22.0 + image: ghcr.io/asgardeo/thunder:0.32.0 container_name: thunder-setup command: ./setup.sh volumes: - thunder-db:/opt/thunder/repository/database + - consent-db:/opt/thunder/consent/repository/database - ./mail-infra/services/silver-config/thunder/deployment.yaml:/opt/thunder/repository/conf/deployment.yaml:ro depends_on: thunder-db-init: @@ -74,7 +73,7 @@ services: restart: "no" thunder: - image: ghcr.io/asgardeo/thunder:0.22.0 + image: ghcr.io/asgardeo/thunder:0.32.0 container_name: thunder-server depends_on: thunder-setup: @@ -83,10 +82,11 @@ services: - "8090:8090" volumes: - thunder-db:/opt/thunder/repository/database + - consent-db:/opt/thunder/consent/repository/database - ./mail-infra/services/silver-config/thunder/certs/server.cert:/opt/thunder/repository/resources/security/server.cert:ro - ./mail-infra/services/silver-config/thunder/certs/server.key:/opt/thunder/repository/resources/security/server.key:ro - ./mail-infra/services/silver-config/thunder/deployment.yaml:/opt/thunder/repository/conf/deployment.yaml:ro - - ./mail-infra/services/silver-config/thunder/develop-config.js:/opt/thunder/apps/develop/config.js:ro + - ./mail-infra/services/silver-config/thunder/console-config.js:/opt/thunder/apps/console/config.js:ro - ./mail-infra/services/silver-config/thunder/gate-config.js:/opt/thunder/apps/gate/config.js:ro networks: - mail-network @@ -126,10 +126,9 @@ services: - smtp-server volumes: - postfix-spool: - postfix-logs: dkim-keys: thunder-db: + consent-db: networks: mail-network: From 6ea5fb59a76a0e6268a4e84e501e95b82d9033a8 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 23 Apr 2026 17:22:43 +0530 Subject: [PATCH 02/18] feat: add console runtime configuration and update database path in deployment --- mail-infra/services/config-scripts/gen-raven-conf.sh | 2 ++ .../thunder/{develop-config.js => console-config.js} | 4 ++-- mail-infra/services/silver-config/thunder/deployment.yaml | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) rename mail-infra/services/silver-config/thunder/{develop-config.js => console-config.js} (78%) diff --git a/mail-infra/services/config-scripts/gen-raven-conf.sh b/mail-infra/services/config-scripts/gen-raven-conf.sh index ae4c9f8..dce9c09 100644 --- a/mail-infra/services/config-scripts/gen-raven-conf.sh +++ b/mail-infra/services/config-scripts/gen-raven-conf.sh @@ -31,6 +31,8 @@ mkdir -p "${RAVEN_CERT_PATH}" cat >"$OUTPUT_FILE" < Date: Thu, 23 Apr 2026 22:42:56 +0530 Subject: [PATCH 03/18] feat: update docker-compose and gen-thunder script for postfix volumes and console configuration --- docker-compose.yml | 4 ++++ .../services/config-scripts/gen-thunder.sh | 16 ++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c46d6e9..a8f59b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: - ./mail-infra/services/silver-config/postfix/recipient_access:/etc/postfix/recipient_access:ro - ./mail-infra/conf/silver.yaml:/etc/postfix/silver.yaml - ./mail-infra/services/silver-config/certbot/keys/etc:/etc/letsencrypt:ro + - postfix-spool:/var/spool/postfix + - postfix-logs:/var/log networks: - mail-network depends_on: @@ -126,6 +128,8 @@ services: - smtp-server volumes: + postfix-spool: + postfix-logs: dkim-keys: thunder-db: consent-db: diff --git a/mail-infra/services/config-scripts/gen-thunder.sh b/mail-infra/services/config-scripts/gen-thunder.sh index 32df09b..0b45cfc 100644 --- a/mail-infra/services/config-scripts/gen-thunder.sh +++ b/mail-infra/services/config-scripts/gen-thunder.sh @@ -11,7 +11,7 @@ readonly MAIL_DOMAIN=$(grep -m 1 '^\s*-\s*domain:' "${ROOT_DIR}/../conf/silver.y readonly LETSENCRYPT_PATH="${ROOT_DIR}/silver-config/certbot/keys/etc/live/${MAIL_DOMAIN}" readonly THUNDER_CERTS_PATH="${ROOT_DIR}/silver-config/thunder/certs" readonly THUNDER_DEPLOYMENT_FILE="${ROOT_DIR}/silver-config/thunder/deployment.yaml" -readonly THUNDER_DEVELOP_CONFIG="${ROOT_DIR}/silver-config/thunder/develop-config.js" +readonly THUNDER_CONSOLE_CONFIG="${ROOT_DIR}/silver-config/thunder/console-config.js" readonly THUNDER_GATE_CONFIG="${ROOT_DIR}/silver-config/thunder/gate-config.js" readonly THUNDER_PORT="8090" @@ -57,16 +57,16 @@ else echo -e "Warning: Thunder deployment.yaml not found at ${THUNDER_DEPLOYMENT_FILE}" fi -# Update develop-config.js with correct domain and port -if [[ -f "${THUNDER_DEVELOP_CONFIG}" ]]; then - echo -e "Updating Thunder develop-config.js..." +# Update console-config.js with correct domain and port +if [[ -f "${THUNDER_CONSOLE_CONFIG}" ]]; then + echo -e "Updating Thunder console-config.js..." - # Update public_url in develop-config.js - sed -i'' -e "s|public_url: 'https://[^']*'|public_url: 'https://${MAIL_DOMAIN}:${THUNDER_PORT}'|g" "${THUNDER_DEVELOP_CONFIG}" + # Update public_url in console-config.js + sed -i'' -e "s|public_url: 'https://[^']*'|public_url: 'https://${MAIL_DOMAIN}:${THUNDER_PORT}'|g" "${THUNDER_CONSOLE_CONFIG}" - echo -e "Thunder develop-config.js updated" + echo -e "Thunder console-config.js updated" else - echo -e "Warning: develop-config.js not found at ${THUNDER_DEVELOP_CONFIG}" + echo -e "Warning: console-config.js not found at ${THUNDER_CONSOLE_CONFIG}" fi # Update gate-config.js with correct domain and port From c8e3789633e0655213cf05b7b7d90263a19501f6 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 23 Apr 2026 23:18:10 +0530 Subject: [PATCH 04/18] feat: enhance certbot and OpenDKIM scripts for improved domain handling and configuration --- .../config-scripts/gen-certbot-certs.sh | 130 +++++++++--------- .../config-scripts/gen-opendkim-conf.sh | 96 ++++--------- 2 files changed, 96 insertions(+), 130 deletions(-) diff --git a/mail-infra/services/config-scripts/gen-certbot-certs.sh b/mail-infra/services/config-scripts/gen-certbot-certs.sh index db75d68..0ad9d86 100644 --- a/mail-infra/services/config-scripts/gen-certbot-certs.sh +++ b/mail-infra/services/config-scripts/gen-certbot-certs.sh @@ -1,21 +1,18 @@ #!/bin/bash # -# This script initializes the certbot certs +# This script initializes certbot certificates with wildcard support +# for ALL domains defined in silver.yaml # -# --- Sanity Checks & Configuration --- set -euo pipefail -# Define constant paths +# --- Paths --- readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly ROOT_DIR="$(dirname "$SCRIPT_DIR")" readonly SILVER_YAML_FILE="${ROOT_DIR}/../conf/silver.yaml" -readonly CONFIGS_PATH="${ROOT_DIR}/silver-config/certbot" readonly LETSENCRYPT_PATH="${ROOT_DIR}/silver-config/certbot/keys" -readonly DKIM_KEY_SIZE=2048 -# --- Main Logic --- -# Extract ALL domains from the domains list in silver.yaml +# Extract domains from silver.yaml DOMAINS=$(grep '^\s*-\s*domain:' "${SILVER_YAML_FILE}" | sed 's/.*domain:\s*//' | xargs) if [ -z "$DOMAINS" ]; then @@ -23,79 +20,84 @@ if [ -z "$DOMAINS" ]; then exit 1 fi -# Get primary domain for certificate name and email -PRIMARY_DOMAIN=$(echo "$DOMAINS" | awk '{print $1}') - echo "=========================================" -echo " Multi-Domain Certificate Setup" +echo " Wildcard Certificate Setup" echo "=========================================" echo "" -echo "Domains to be covered by this certificate:" +echo "Domains detected:" for domain in $DOMAINS; do - echo " • $domain" + echo " • $domain" done -echo " • mail.$PRIMARY_DOMAIN" echo "" -echo "Using HTTP-01 challenge (port 80 required)" -echo "Certificate will be non-interactive" +echo "Each domain will receive:" +echo " - domain certificate" +echo " - wildcard subdomain certificate" echo "" -if [ -d "${LETSENCRYPT_PATH}/etc/live/${PRIMARY_DOMAIN}" ]; then - echo "An existing certificate was found for ${PRIMARY_DOMAIN}." - read -p "Do you want to attempt to renew it? (y/n): " RENEW_CHOICE - if [[ "$RENEW_CHOICE" == "y" || "$RENEW_CHOICE" == "Y" ]]; then - echo "Attempting renewal..." - docker run --rm \ - -p 80:80 \ - -v "${LETSENCRYPT_PATH}/etc:/etc/letsencrypt" \ - -v "${LETSENCRYPT_PATH}/lib:/var/lib/letsencrypt" \ - -v "${LETSENCRYPT_PATH}/log:/var/log/letsencrypt" \ - certbot/certbot \ - renew - exit 0 - else - echo "Skipping renewal. If you want a new certificate, please remove the directory: ${LETSENCRYPT_PATH}/etc/live/${PRIMARY_DOMAIN}" - exit 0 - fi -fi +read -p "Press Enter to continue..." -echo "No existing certificate found. Requesting a new multi-domain certificate..." -echo "=========================================" -echo "" +# Loop through each domain +for DOMAIN in $DOMAINS; do -read -p "Press Enter to continue with certificate request..." + echo "" + echo "=========================================" + echo "Processing domain: ${DOMAIN}" + echo "=========================================" + + if [ -d "${LETSENCRYPT_PATH}/etc/live/${DOMAIN}" ]; then + echo "Existing certificate found for ${DOMAIN}" + read -p "Attempt renewal for ${DOMAIN}? (y/n): " RENEW_CHOICE + + if [[ "$RENEW_CHOICE" =~ ^[Yy]$ ]]; then + docker run --rm \ + -v "${LETSENCRYPT_PATH}/etc:/etc/letsencrypt" \ + -v "${LETSENCRYPT_PATH}/lib:/var/lib/letsencrypt" \ + -v "${LETSENCRYPT_PATH}/log:/var/log/letsencrypt" \ + certbot/certbot renew --cert-name "${DOMAIN}" + else + echo "Skipping ${DOMAIN}" + fi + + continue + fi + + echo "" + echo "Certificate will cover:" + echo " • ${DOMAIN}" + echo " • *.${DOMAIN}" + echo "" + + echo "DNS verification will be required." + echo "You will need to create TXT records manually." + echo "" + + read -p "Press Enter to request certificate for ${DOMAIN}..." + + docker run -it --rm \ + -v "${LETSENCRYPT_PATH}/etc:/etc/letsencrypt" \ + -v "${LETSENCRYPT_PATH}/lib:/var/lib/letsencrypt" \ + -v "${LETSENCRYPT_PATH}/log:/var/log/letsencrypt" \ + certbot/certbot \ + certonly \ + --manual \ + --preferred-challenges dns \ + --agree-tos \ + --email "admin@${DOMAIN}" \ + --key-type rsa \ + -d "${DOMAIN}" \ + -d "*.${DOMAIN}" + + echo "" + echo "✅ Certificate generated for ${DOMAIN}" -# Build the certbot command with all domains -DOMAIN_ARGS="" -for domain in $DOMAINS; do - DOMAIN_ARGS="$DOMAIN_ARGS -d $domain" done -# Add mail subdomain at the end -DOMAIN_ARGS="$DOMAIN_ARGS -d mail.$PRIMARY_DOMAIN" - -# Request certificate using HTTP-01 challenge for all domains -docker run --rm \ - -p 80:80 \ - -v "${LETSENCRYPT_PATH}/etc:/etc/letsencrypt" \ - -v "${LETSENCRYPT_PATH}/lib:/var/lib/letsencrypt" \ - -v "${LETSENCRYPT_PATH}/log:/var/log/letsencrypt" \ - certbot/certbot \ - certonly \ - --standalone \ - --non-interactive \ - --agree-tos \ - --email "admin@${PRIMARY_DOMAIN}" \ - --key-type rsa \ - --keep-until-expiring \ - --expand \ - $DOMAIN_ARGS echo "" echo "=========================================" -echo "✅ Certificate request completed!" +echo "🎉 All certificate operations completed!" echo "=========================================" echo "" -echo "Certificate files located at:" -echo " ${LETSENCRYPT_PATH}/etc/live/${PRIMARY_DOMAIN}/" +echo "Certificates stored in:" +echo "${LETSENCRYPT_PATH}/etc/live/" \ No newline at end of file diff --git a/mail-infra/services/config-scripts/gen-opendkim-conf.sh b/mail-infra/services/config-scripts/gen-opendkim-conf.sh index 672b58c..dd072c0 100644 --- a/mail-infra/services/config-scripts/gen-opendkim-conf.sh +++ b/mail-infra/services/config-scripts/gen-opendkim-conf.sh @@ -1,34 +1,29 @@ #!/bin/bash # -# This script initializes the OpenDKIM config files for all domains and subdomains +# This script initializes the OpenDKIM config files for all domains and their subdomains # -# --- Sanity Checks & Configuration --- set -euo pipefail -# Define constant paths +# --- Paths --- readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly ROOT_DIR="$(dirname "$SCRIPT_DIR")" readonly SILVER_YAML_FILE="${ROOT_DIR}/../conf/silver.yaml" readonly CONFIGS_PATH="${ROOT_DIR}/silver-config/opendkim" -# --- Helper function to extract domain config --- -# This awk script parses the YAML and extracts domain, selector, and key-size +# --- Helper: Extract domain configs from silver.yaml --- extract_domain_configs() { awk ' /^[[:space:]]*-[[:space:]]*domain:/ { - # Extract domain domain = $0 sub(/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*/, "", domain) sub(/[[:space:]]*$/, "", domain) - # Read next lines for selector and key-size - selector = "mail" # default - keysize = "2048" # default + selector = "mail" + keysize = "2048" while ((getline line) > 0) { if (line ~ /^[[:space:]]*-[[:space:]]*domain:/) { - # Next domain found, print current and reprocess this line print domain "," selector "," keysize domain = line sub(/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*/, "", domain) @@ -40,45 +35,33 @@ extract_domain_configs() { if (line ~ /^[[:space:]]*dkim-selector:/) { selector = line sub(/^[[:space:]]*dkim-selector:[[:space:]]*/, "", selector) - sub(/[[:space:]]*$/, "", selector) if (selector == "" || selector == "null") selector = "mail" } if (line ~ /^[[:space:]]*dkim-key-size:/) { keysize = line sub(/^[[:space:]]*dkim-key-size:[[:space:]]*/, "", keysize) - sub(/[[:space:]]*$/, "", keysize) if (keysize == "" || keysize == "null") keysize = "2048" } - # Stop if we hit a line that suggests end of domains section - if (line ~ /^[^[:space:]#]/ && line !~ /domains:/) { - break - } - } - - # Print last domain - if (domain != "" && domain != "null") { - print domain "," selector "," keysize + if (line ~ /^[^[:space:]#]/ && line !~ /domains:/) { break } } + if (domain != "" && domain != "null") { print domain "," selector "," keysize } } ' "$SILVER_YAML_FILE" } -# --- Main Logic --- -echo "=== Generating OpenDKIM configuration files for all domains ===" +# --- Main --- +echo "=== Generating OpenDKIM configuration files for all domains and subdomains ===" -# Extract all domain configurations DOMAIN_CONFIGS=$(extract_domain_configs) - if [ -z "$DOMAIN_CONFIGS" ]; then echo "Error: No domains found in ${SILVER_YAML_FILE}" exit 1 fi -# Create configs directory -mkdir -p "${CONFIGS_PATH}" +mkdir -p "$CONFIGS_PATH" -# --- Generate TrustedHosts file --- -echo "Generating TrustedHosts file..." +# --- TrustedHosts --- +echo "Generating TrustedHosts..." cat >"${CONFIGS_PATH}/TrustedHosts" <<'EOF' 127.0.0.1 localhost @@ -87,52 +70,33 @@ localhost 10.0.0.0/8 EOF -# Add each domain to TrustedHosts echo "$DOMAIN_CONFIGS" | while IFS=',' read -r DOMAIN SELECTOR KEYSIZE; do - if [ -n "$DOMAIN" ] && [ "$DOMAIN" != "null" ]; then - echo "*.${DOMAIN}" >> "${CONFIGS_PATH}/TrustedHosts" - fi + echo "$DOMAIN" >> "${CONFIGS_PATH}/TrustedHosts" + echo "*.$DOMAIN" >> "${CONFIGS_PATH}/TrustedHosts" done +echo "✓ TrustedHosts generated" -echo "✓ TrustedHosts file generated successfully" - -# --- Generate SigningTable file --- -echo "Generating SigningTable file..." -> "${CONFIGS_PATH}/SigningTable" # Clear the file - +# --- SigningTable --- +echo "Generating SigningTable..." +> "${CONFIGS_PATH}/SigningTable" echo "$DOMAIN_CONFIGS" | while IFS=',' read -r DOMAIN SELECTOR KEYSIZE; do - if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then - continue - fi - - # Add entry to SigningTable - echo "*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}" >> "${CONFIGS_PATH}/SigningTable" - echo " Added domain: ${DOMAIN} (selector: ${SELECTOR})" + echo "*@$DOMAIN $SELECTOR._domainkey.$DOMAIN" >> "${CONFIGS_PATH}/SigningTable" + echo "*@*.$DOMAIN $SELECTOR._domainkey.$DOMAIN" >> "${CONFIGS_PATH}/SigningTable" done +echo "✓ SigningTable generated" -echo "✓ SigningTable file generated successfully" - -# --- Generate KeyTable file --- -echo "Generating KeyTable file..." -> "${CONFIGS_PATH}/KeyTable" # Clear the file - +# --- KeyTable --- +echo "Generating KeyTable..." +> "${CONFIGS_PATH}/KeyTable" echo "$DOMAIN_CONFIGS" | while IFS=',' read -r DOMAIN SELECTOR KEYSIZE; do - if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then - continue - fi - - # Add entry to KeyTable - echo "${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:/etc/dkimkeys/${DOMAIN}/${SELECTOR}.private" >> "${CONFIGS_PATH}/KeyTable" - echo " Added key entry for: ${DOMAIN}" + echo "$SELECTOR._domainkey.$DOMAIN $DOMAIN:$SELECTOR:/etc/dkimkeys/$DOMAIN/$SELECTOR.private" >> "${CONFIGS_PATH}/KeyTable" done +echo "✓ KeyTable generated" -echo "✓ KeyTable file generated successfully" - -echo "" -echo "=== OpenDKIM configuration files generated successfully ===" -echo "Total domains configured: $(echo "$DOMAIN_CONFIGS" | wc -l | xargs)" echo "" -echo "Files generated:" +echo "=== OpenDKIM configuration completed ===" +echo "Domains configured: $(echo "$DOMAIN_CONFIGS" | wc -l | xargs)" +echo "Files:" echo " - ${CONFIGS_PATH}/TrustedHosts" echo " - ${CONFIGS_PATH}/SigningTable" -echo " - ${CONFIGS_PATH}/KeyTable" +echo " - ${CONFIGS_PATH}/KeyTable" \ No newline at end of file From a83075f6f6a1338d27c37bd2d874c64b76f217e5 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 24 Apr 2026 09:28:19 +0530 Subject: [PATCH 05/18] feat: remove deprecated Thunder user scripts and update configuration scripts for streamlined deployment Co-authored-by: Copilot --- mail-infra/conf/users.yaml | 30 -- mail-infra/scripts/service/start-silver.sh | 50 ---- mail-infra/scripts/user/add_user.sh | 261 ----------------- mail-infra/scripts/utils/thunder-auth.sh | 270 ------------------ .../services/config-scripts/gen-thunder.sh | 33 +-- 5 files changed, 14 insertions(+), 630 deletions(-) delete mode 100644 mail-infra/conf/users.yaml delete mode 100755 mail-infra/scripts/user/add_user.sh delete mode 100755 mail-infra/scripts/utils/thunder-auth.sh diff --git a/mail-infra/conf/users.yaml b/mail-infra/conf/users.yaml deleted file mode 100644 index 6c6b61f..0000000 --- a/mail-infra/conf/users.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# =============================================================== -# Silver Mail Users Configuration (`users.yaml`) -# =============================================================== -# -# This file defines users for each domain. -# Users are grouped by their domain. -# -# Example configuration: -# domains: -# - domain: example.com -# users: -# - username: alice -# - username: bob -# - username: charlie -# -# - domain: another-domain.com -# users: -# - username: john -# - username: jane -# -# Notes: -# - Each user gets their own personal mailbox (user_db_{userID}.db) -# - Usernames must be unique within each domain - -domains: - - domain: - users: - - username: - - username: - - username: diff --git a/mail-infra/scripts/service/start-silver.sh b/mail-infra/scripts/service/start-silver.sh index 36371d1..1de372b 100755 --- a/mail-infra/scripts/service/start-silver.sh +++ b/mail-infra/scripts/service/start-silver.sh @@ -114,56 +114,6 @@ fi sleep 1 # Additional wait after Postfix reload -# ================================ -# Step 4: Initialize Thunder User Schema -# ================================ - -THUNDER_HOST=${MAIL_DOMAIN} -THUNDER_PORT=8090 - -echo -e "\n${YELLOW}Step 4/4: Creating default user schema in Thunder${NC}" - -# Source Thunder authentication utility -source "${SCRIPT_DIR}/../utils/thunder-auth.sh" - -# Step 4.1 & 4.2: Authenticate with Thunder -if ! thunder_authenticate "$THUNDER_HOST" "$THUNDER_PORT"; then - exit 1 -fi - -# Step 4.3: Create organization unit -if ! thunder_create_org_unit "$THUNDER_HOST" "$THUNDER_PORT" "$BEARER_TOKEN" "silver" "Silver Mail" "Organization Unit for Silver Mail"; then - exit 1 -fi - -# Step 4.4: Create user schema -echo " - Creating user schema..." -SCHEMA_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - "https://${THUNDER_HOST}:${THUNDER_PORT}/user-schemas" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Authorization: Bearer ${BEARER_TOKEN}" \ - -d "{ - \"name\": \"emailuser\", - \"ouId\": \"${ORG_UNIT_ID}\", - \"schema\": { - \"username\": { \"type\": \"string\", \"unique\": true }, - \"password\": { \"type\": \"string\" }, - \"email\": { \"type\": \"string\", \"unique\": true } - } - }") - -SCHEMA_BODY=$(echo "$SCHEMA_RESPONSE" | head -n -1) -SCHEMA_STATUS=$(echo "$SCHEMA_RESPONSE" | tail -n1) - -if [ "$SCHEMA_STATUS" -eq 201 ] || [ "$SCHEMA_STATUS" -eq 200 ]; then - echo -e "${GREEN} ✓ User schema 'emailuser' created successfully (HTTP $SCHEMA_STATUS)${NC}" -else - echo -e "${RED}✗ Failed to create user schema (HTTP $SCHEMA_STATUS)${NC}" - echo "Response: $SCHEMA_BODY" - exit 1 -fi - # ================================ # Public DKIM Key Instructions # ================================ diff --git a/mail-infra/scripts/user/add_user.sh b/mail-infra/scripts/user/add_user.sh deleted file mode 100755 index 276a1b9..0000000 --- a/mail-infra/scripts/user/add_user.sh +++ /dev/null @@ -1,261 +0,0 @@ -#!/bin/bash - -# ============================================ -# Silver Mail - Thunder User Registration Only -# ============================================ - -# ------------------------------- -# Configuration -# ------------------------------- -# Colors -CYAN="\033[0;36m" -GREEN="\033[0;32m" -YELLOW="\033[1;33m" -RED="\033[0;31m" -BLUE="\033[0;34m" -NC="\033[0m" # No Color - -# Directories & files -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Services directory contains docker-compose.yaml and silver-config -SERVICES_DIR="$(cd "${SCRIPT_DIR}/../../services" && pwd)" -# Conf directory contains config files -CONF_DIR="$(cd "${SCRIPT_DIR}/../../conf" && pwd)" -CONFIG_FILE="${CONF_DIR}/silver.yaml" -USERS_FILE="${CONF_DIR}/users.yaml" -INVITE_URLS_DIR="${SCRIPT_DIR}/../../scripts/user" -INVITE_URLS_FILE="${INVITE_URLS_DIR}/user_invite_urls.txt" - -echo -e "${CYAN}---------------------------------------------${NC}" -echo -e " 🚀 ${GREEN}Thunder User Registration${NC}" -echo -e "${CYAN}---------------------------------------------${NC}\n" - -# ------------------------------- -# Helper Functions -# ------------------------------- - -# Make a Thunder API call and parse response -# Usage: thunder_api_call -# Sets global variables: API_RESPONSE_BODY and API_RESPONSE_STATUS -thunder_api_call() { - local url="$1" - local json_data="$2" - local description="$3" - - local full_response=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Authorization: Bearer ${BEARER_TOKEN}" \ - "$url" \ - -d "$json_data") - - API_RESPONSE_BODY=$(echo "$full_response" | head -n -1) - API_RESPONSE_STATUS=$(echo "$full_response" | tail -n1) - - if [ "$API_RESPONSE_STATUS" -ne 200 ]; then - echo -e "${RED}✗ Failed to $description (HTTP $API_RESPONSE_STATUS)${NC}" - echo -e "${RED}Response: $API_RESPONSE_BODY${NC}" - return 1 - fi - - return 0 -} - - - -# ------------------------------- -# Step 0: Validate config files exist -# ------------------------------- -if [ ! -f "$CONFIG_FILE" ]; then - echo -e "${RED}✗ Configuration file not found: $CONFIG_FILE${NC}" - exit 1 -fi - -if [ ! -f "$USERS_FILE" ]; then - echo -e "${RED}✗ Users file not found: $USERS_FILE${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Configuration files found${NC}" - -# ------------------------------- -# Step 1: Extract primary domain for Thunder -# ------------------------------- -# Get the first domain from users.yaml as primary domain for Thunder -PRIMARY_DOMAIN=$(grep -m 1 '^\s*-\s*domain:' "$USERS_FILE" | sed 's/.*domain:\s*//' | xargs) - -if [ -z "$PRIMARY_DOMAIN" ]; then - echo -e "${RED}✗ No domains found in $USERS_FILE${NC}" - exit 1 -fi - -THUNDER_HOST=${PRIMARY_DOMAIN} -THUNDER_PORT="8090" -echo -e "${GREEN}✓ Thunder host set to: $THUNDER_HOST:$THUNDER_PORT (primary domain)${NC}" - -# ------------------------------- -# Step 2: Authenticate with Thunder and get organization unit -# ------------------------------- -# Source Thunder authentication utility -source "${SCRIPT_DIR}/../utils/thunder-auth.sh" - -# Authenticate with Thunder -if ! thunder_authenticate "$THUNDER_HOST" "$THUNDER_PORT"; then - exit 1 -fi - -# Get organization unit ID for "silver" -if ! thunder_get_org_unit "$THUNDER_HOST" "$THUNDER_PORT" "$BEARER_TOKEN" "silver"; then - exit 1 -fi - -# ------------------------------- -# Step 3: Validate users.yaml -# ------------------------------- -YAML_USER_COUNT=$(grep -c "username:" "$USERS_FILE" 2>/dev/null || echo "0") -if [ "$YAML_USER_COUNT" -eq 0 ]; then - echo -e "${RED}✗ No users defined in $USERS_FILE${NC}" - exit 1 -fi - -# Initialize invite URLs file -mkdir -p "$INVITE_URLS_DIR" -echo "# Silver Mail User Invite URLs - Generated on $(date)" >"$INVITE_URLS_FILE" -echo "# Users must complete registration using these URLs to set their passwords" >>"$INVITE_URLS_FILE" -echo "" >>"$INVITE_URLS_FILE" - -# ------------------------------- -# Step 4: Register users in Thunder -# ------------------------------- -ADDED_COUNT=0 -CURRENT_DOMAIN="" -USER_USERNAME="" -IN_USERS_SECTION=false - -while IFS= read -r line; do - trimmed_line=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') - - # Match domain line: "- domain: example.com" or " - domain: example.com" - if [[ $line =~ ^[[:space:]]*-[[:space:]]+domain:[[:space:]]+(.+)$ ]]; then - CURRENT_DOMAIN="${BASH_REMATCH[1]}" - CURRENT_DOMAIN=$(echo "$CURRENT_DOMAIN" | xargs) - IN_USERS_SECTION=false - - if [ -n "$CURRENT_DOMAIN" ]; then - echo -e "\n${CYAN}========================================${NC}" - echo -e "${CYAN}Processing domain: ${GREEN}${CURRENT_DOMAIN}${NC}" - echo -e "${CYAN}========================================${NC}" - - # Validate domain format - if ! [[ "$CURRENT_DOMAIN" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then - echo -e "${RED}✗ Invalid domain: $CURRENT_DOMAIN${NC}" - CURRENT_DOMAIN="" - continue - fi - fi - fi - - # Match "users:" section marker - if [[ $trimmed_line =~ ^users:[[:space:]]*$ ]] && [ -n "$CURRENT_DOMAIN" ]; then - IN_USERS_SECTION=true - continue - fi - - # Match username line: "- username: alice" or " - username: alice" - if [[ $line =~ ^[[:space:]]+-[[:space:]]+username:[[:space:]]+(.+)$ ]] && [ "$IN_USERS_SECTION" = true ] && [ -n "$CURRENT_DOMAIN" ]; then - USER_USERNAME="${BASH_REMATCH[1]}" - USER_USERNAME=$(echo "$USER_USERNAME" | xargs) - - if [ -n "$USER_USERNAME" ]; then - USER_EMAIL="${USER_USERNAME}@${CURRENT_DOMAIN}" - - echo -e "\n${YELLOW}Creating user $USER_EMAIL in Thunder...${NC}" - - # Step 1: Start USER_ONBOARDING flow - echo -e "${CYAN} → Step 1: Starting USER_ONBOARDING flow...${NC}" - if ! thunder_api_call "https://${THUNDER_HOST}:${THUNDER_PORT}/flow/execute" \ - '{"flowType":"USER_ONBOARDING","verbose":true}' \ - "start USER_ONBOARDING flow"; then - USER_USERNAME="" - continue - fi - - FLOW_ID=$(echo "$API_RESPONSE_BODY" | grep -o '"flowId":"[^"]*' | sed 's/"flowId":"//') - if [ -z "$FLOW_ID" ]; then - echo -e "${RED}✗ Failed to extract flowId${NC}" - USER_USERNAME="" - continue - fi - - echo -e "${GREEN} ✓ Flow started: $FLOW_ID${NC}" - - # Step 2: Submit user type (emailuser) - echo -e "${CYAN} → Step 2: Submitting user type...${NC}" - if ! thunder_api_call "https://${THUNDER_HOST}:${THUNDER_PORT}/flow/execute" \ - "{\"flowId\":\"${FLOW_ID}\",\"inputs\":{\"userType\":\"emailuser\"},\"verbose\":true,\"action\":\"usertype_submit\"}" \ - "submit user type"; then - USER_USERNAME="" - continue - fi - - echo -e "${GREEN} ✓ User type submitted${NC}" - - # Step 3: Submit email address - echo -e "${CYAN} → Step 3: Submitting email address...${NC}" - if ! thunder_api_call "https://${THUNDER_HOST}:${THUNDER_PORT}/flow/execute" \ - "{\"flowId\":\"${FLOW_ID}\",\"inputs\":{\"email\":\"${USER_EMAIL}\"},\"verbose\":true,\"action\":\"action_submit_email\"}" \ - "submit email"; then - USER_USERNAME="" - continue - fi - - echo -e "${GREEN} ✓ Email submitted${NC}" - - # Extract invite link from response - INVITE_URL=$(echo "$API_RESPONSE_BODY" | grep -o '"inviteLink":"[^"]*' | sed 's/"inviteLink":"//;s/\\u0026/\&/g;s/\\//g') - - if [ -z "$INVITE_URL" ]; then - echo -e "${RED}✗ Failed to extract invite link from response${NC}" - echo -e "${YELLOW}Response: $API_RESPONSE_BODY${NC}" - USER_USERNAME="" - continue - fi - - echo -e "${GREEN}✓ User $USER_EMAIL created successfully in Thunder${NC}" - echo -e "${GREEN} Invite link generated${NC}" - - # Store invite URL - echo "EMAIL: $USER_EMAIL" >>"$INVITE_URLS_FILE" - echo "INVITE URL: $INVITE_URL" >>"$INVITE_URLS_FILE" - echo "" >>"$INVITE_URLS_FILE" - - # Display info - echo -e "${BLUE}📧 Email: ${GREEN}$USER_EMAIL${NC}" - echo -e "${BLUE}🔗 Invite URL: ${YELLOW}$INVITE_URL${NC}" - echo -e "${CYAN} User must visit this URL to set their password${NC}" - - ADDED_COUNT=$((ADDED_COUNT + 1)) - - USER_USERNAME="" - fi - fi -done <"$USERS_FILE" - -# ------------------------------- -# Final Summary -# ------------------------------- -echo -e "\n${CYAN}==============================================${NC}" -echo -e " 🎉 ${GREEN}Thunder User Registration Complete!${NC}" -echo " Total users registered in Thunder: $ADDED_COUNT" -echo "" -echo -e "${BLUE}🔐 User Registration:${NC}" -echo -e " Invite URLs saved to: ${YELLOW}$INVITE_URLS_FILE${NC}" -echo -e " Users must visit their invite URLs to complete registration and set passwords" -echo "" -echo -e "${CYAN}Next Steps:${NC}" -echo -e " 1. Share the invite URLs with the respective users" -echo -e " 2. Users will complete registration by setting their passwords" -echo -e " 3. Users can then authenticate via Thunder IDP" -echo "" -echo -e "${GREEN}✅ All users registered in Thunder IDP successfully!${NC}" -echo -e "${CYAN}==============================================${NC}" \ No newline at end of file diff --git a/mail-infra/scripts/utils/thunder-auth.sh b/mail-infra/scripts/utils/thunder-auth.sh deleted file mode 100755 index 6c1b334..0000000 --- a/mail-infra/scripts/utils/thunder-auth.sh +++ /dev/null @@ -1,270 +0,0 @@ -#!/bin/bash - -# ============================================ -# Thunder Authentication Utility -# ============================================ -# -# This utility provides shared authentication functions for Thunder API. -# Source this file in your scripts to use the authentication functions. -# -# Usage: -# source "$(dirname "$0")/../utils/thunder-auth.sh" -# thunder_authenticate "$THUNDER_HOST" "$THUNDER_PORT" -# # Now you can use: $SAMPLE_APP_ID, $BEARER_TOKEN -# -# thunder_get_org_unit "$THUNDER_HOST" "$THUNDER_PORT" "$BEARER_TOKEN" "silver" -# # Now you can use: $ORG_UNIT_ID -# - -# Colors -CYAN="\033[0;36m" -GREEN="\033[0;32m" -YELLOW="\033[1;33m" -RED="\033[0;31m" -NC="\033[0m" # No Color - -# ============================================ -# Function: Extract Sample App ID from Thunder setup logs -# ============================================ -thunder_get_sample_app_id() { - local sample_app_id - sample_app_id=$(docker logs thunder-setup 2>&1 | grep 'Sample App ID:' | head -n1 | grep -o '[a-f0-9-]\{36\}') - - if [ -z "$sample_app_id" ]; then - echo -e "${RED}✗ Failed to extract Sample App ID from Thunder setup logs${NC}" >&2 - echo "Please ensure Thunder setup container has completed successfully." >&2 - return 1 - fi - - echo "$sample_app_id" - return 0 -} - -# ============================================ -# Function: Authenticate with Thunder and get Bearer token -# ============================================ -# Arguments: -# $1 - Thunder host (e.g., "example.com") -# $2 - Thunder port (e.g., "8090") -# Returns: -# 0 on success, 1 on failure -# Exports: -# SAMPLE_APP_ID - The application ID extracted from logs -# BEARER_TOKEN - The authentication token -# ============================================ -thunder_authenticate() { - local thunder_host="$1" - local thunder_port="$2" - - if [ -z "$thunder_host" ] || [ -z "$thunder_port" ]; then - echo -e "${RED}✗ Thunder host and port are required${NC}" >&2 - return 1 - fi - - echo -e "${YELLOW}Authenticating with Thunder...${NC}" - - # Step 1: Extract Sample App ID - echo " - Extracting Sample App ID from Thunder setup logs..." - SAMPLE_APP_ID=$(thunder_get_sample_app_id) - - if [ $? -ne 0 ] || [ -z "$SAMPLE_APP_ID" ]; then - echo -e "${RED}✗ Failed to extract Sample App ID${NC}" >&2 - return 1 - fi - - echo -e "${GREEN} ✓ Sample App ID extracted: $SAMPLE_APP_ID${NC}" - - # Step 2: Start authentication flow (get flowId) - echo " - Starting authentication flow..." - local start_response - start_response=$(curl -s -w "\n%{http_code}" -X POST \ - "https://${thunder_host}:${thunder_port}/flow/execute" \ - -H "Content-Type: application/json" \ - -d "{\"applicationId\":\"${SAMPLE_APP_ID}\",\"flowType\":\"AUTHENTICATION\"}") - - local start_body - local start_status - start_body=$(echo "$start_response" | head -n -1) - start_status=$(echo "$start_response" | tail -n1) - - if [ "$start_status" -ne 200 ]; then - echo -e "${RED}✗ Failed to start authentication flow (HTTP $start_status)${NC}" >&2 - echo "Response: $start_body" >&2 - return 1 - fi - - FLOW_ID=$(echo "$start_body" | grep -o '"flowId":"[^"]*' | sed 's/"flowId":"//') - - if [ -z "$FLOW_ID" ]; then - echo -e "${RED}✗ Failed to extract flowId${NC}" >&2 - return 1 - fi - - echo -e "${GREEN} ✓ Flow started (flowId: $FLOW_ID)${NC}" - - # Step 3: Complete authentication flow (get assertion) - echo " - Completing authentication flow..." - local auth_response - auth_response=$(curl -s -w "\n%{http_code}" -X POST \ - "https://${thunder_host}:${thunder_port}/flow/execute" \ - -H "Content-Type: application/json" \ - -d "{\"flowId\":\"${FLOW_ID}\",\"inputs\":{\"username\":\"admin\",\"password\":\"admin\",\"requested_permissions\":\"system\"},\"action\":\"action_001\"}") - - local auth_body - local auth_status - auth_body=$(echo "$auth_response" | head -n -1) - auth_status=$(echo "$auth_response" | tail -n1) - - if [ "$auth_status" -ne 200 ]; then - echo -e "${RED}✗ Failed to authenticate with Thunder (HTTP $auth_status)${NC}" >&2 - echo "Response: $auth_body" >&2 - return 1 - fi - - # Step 4: Extract System API token (assertion) - BEARER_TOKEN=$(echo "$auth_body" | grep -o '"assertion":"[^"]*' | sed 's/"assertion":"//') - - if [ -z "$BEARER_TOKEN" ]; then - echo -e "${RED}✗ Failed to extract assertion from authentication response${NC}" >&2 - return 1 - fi - - echo -e "${GREEN} ✓ Authentication successful${NC}" - - # Export variables for use in calling script - export SAMPLE_APP_ID - export FLOW_ID - export BEARER_TOKEN - - return 0 -} - -# ============================================ -# Function: Get organization unit ID by handle -# ============================================ -# Arguments: -# $1 - Thunder host (e.g., "example.com") -# $2 - Thunder port (e.g., "8090") -# $3 - Bearer token -# $4 - Organization handle (e.g., "silver") -# Returns: -# 0 on success, 1 on failure -# Exports: -# ORG_UNIT_ID - The organization unit ID -# ============================================ -thunder_get_org_unit() { - local thunder_host="$1" - local thunder_port="$2" - local bearer_token="$3" - local org_handle="$4" - - if [ -z "$thunder_host" ] || [ -z "$thunder_port" ] || [ -z "$bearer_token" ] || [ -z "$org_handle" ]; then - echo -e "${RED}✗ All parameters (host, port, token, handle) are required${NC}" >&2 - return 1 - fi - - echo -e "${YELLOW}Fetching organization unit '${org_handle}'...${NC}" - - local ou_response - ou_response=$(curl -s -w "\n%{http_code}" -X GET \ - "https://${thunder_host}:${thunder_port}/organization-units" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${bearer_token}") - - local ou_body - local ou_status - ou_body=$(echo "$ou_response" | head -n -1) - ou_status=$(echo "$ou_response" | tail -n1) - - if [ "$ou_status" -ne 200 ]; then - echo -e "${RED}✗ Failed to fetch organization units (HTTP $ou_status)${NC}" >&2 - echo "Response: $ou_body" >&2 - return 1 - fi - - # Extract organization unit ID for the specified handle - ORG_UNIT_ID=$(echo "$ou_body" | grep -o "{[^}]*\"handle\":\"${org_handle}\"[^}]*}" | grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//') - - if [ -z "$ORG_UNIT_ID" ]; then - echo -e "${RED}✗ Failed to find '${org_handle}' organization unit${NC}" >&2 - echo "Available organizations in response:" >&2 - echo "$ou_body" | grep -o '"handle":"[^"]*"' | sed 's/"handle":"//;s/"//g' | sed 's/^/ - /' >&2 - return 1 - fi - - echo -e "${GREEN}✓ Organization unit '${org_handle}' found (ID: $ORG_UNIT_ID)${NC}" - - # Export variable for use in calling script - export ORG_UNIT_ID - - return 0 -} - -# ============================================ -# Function: Create organization unit -# ============================================ -# Arguments: -# $1 - Thunder host -# $2 - Thunder port -# $3 - Bearer token -# $4 - Organization handle -# $5 - Organization name -# $6 - Organization description -# Returns: -# 0 on success, 1 on failure -# Exports: -# ORG_UNIT_ID - The created organization unit ID -# ============================================ -thunder_create_org_unit() { - local thunder_host="$1" - local thunder_port="$2" - local bearer_token="$3" - local org_handle="$4" - local org_name="$5" - local org_description="$6" - - if [ -z "$thunder_host" ] || [ -z "$thunder_port" ] || [ -z "$bearer_token" ] || [ -z "$org_handle" ] || [ -z "$org_name" ]; then - echo -e "${RED}✗ Required parameters missing (host, port, token, handle, name)${NC}" >&2 - return 1 - fi - - echo " - Creating organization unit..." - - local ou_response - ou_response=$(curl -s -w "\n%{http_code}" -X POST \ - "https://${thunder_host}:${thunder_port}/organization-units" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${bearer_token}" \ - -d "{ - \"handle\": \"${org_handle}\", - \"name\": \"${org_name}\", - \"description\": \"${org_description}\", - \"parent\": null - }") - - local ou_body - local ou_status - ou_body=$(echo "$ou_response" | head -n -1) - ou_status=$(echo "$ou_response" | tail -n1) - - if [ "$ou_status" -ne 201 ] && [ "$ou_status" -ne 200 ]; then - echo -e "${RED}✗ Failed to create organization unit (HTTP $ou_status)${NC}" >&2 - echo "Response: $ou_body" >&2 - return 1 - fi - - # Extract organization unit ID - ORG_UNIT_ID=$(echo "$ou_body" | grep -o '"id":"[^"]*"' | head -n1 | sed 's/"id":"//;s/"//') - - if [ -z "$ORG_UNIT_ID" ]; then - echo -e "${RED}✗ Failed to extract organization unit ID from response${NC}" >&2 - return 1 - fi - - echo -e "${GREEN} ✓ Organization unit created successfully (ID: $ORG_UNIT_ID)${NC}" - - # Export variable for use in calling script - export ORG_UNIT_ID - - return 0 -} \ No newline at end of file diff --git a/mail-infra/services/config-scripts/gen-thunder.sh b/mail-infra/services/config-scripts/gen-thunder.sh index 0b45cfc..c1f53cb 100644 --- a/mail-infra/services/config-scripts/gen-thunder.sh +++ b/mail-infra/services/config-scripts/gen-thunder.sh @@ -33,21 +33,12 @@ if [[ -f "${THUNDER_DEPLOYMENT_FILE}" ]]; then # Create a temporary file for editing cp "${THUNDER_DEPLOYMENT_FILE}" "${THUNDER_DEPLOYMENT_FILE}.bak" - - # Update server.public_url - sed -i'' -e "/^server:/,/^[^ ]/ s|public_url:.*|public_url: \"https://${MAIL_DOMAIN}:${THUNDER_PORT}\"|" "${THUNDER_DEPLOYMENT_FILE}" - - # Update gate_client.hostname - sed -i'' -e "/^gate_client:/,/^[^ ]/ s|hostname:.*|hostname: \"${MAIL_DOMAIN}\"|" "${THUNDER_DEPLOYMENT_FILE}" - - # Update gate_client.port (if needed) - sed -i'' -e "/^gate_client:/,/^[^ ]/ s|port:.*|port: ${THUNDER_PORT}|" "${THUNDER_DEPLOYMENT_FILE}" - - # Update cors.allowed_origins - replace any https://domain:port pattern - sed -i'' -e "/^cors:/,/^[^ ]/ s|https://[^:\"]*:[0-9]*|https://${MAIL_DOMAIN}:${THUNDER_PORT}|g" "${THUNDER_DEPLOYMENT_FILE}" - - # Update passkey.allowed_origins - replace any https://domain:port pattern - sed -i'' -e "/^passkey:/,/^[^ ]/ s|https://[^:\"]*:[0-9]*|https://${MAIL_DOMAIN}:${THUNDER_PORT}|g" "${THUNDER_DEPLOYMENT_FILE}" + + # Replace template placeholders directly so all Thunder fields update together. + sed -i '' \ + -e "s||${MAIL_DOMAIN}|g" \ + -e "s||${THUNDER_PORT}|g" \ + "${THUNDER_DEPLOYMENT_FILE}" # Remove backup file rm -f "${THUNDER_DEPLOYMENT_FILE}.bak" @@ -61,8 +52,10 @@ fi if [[ -f "${THUNDER_CONSOLE_CONFIG}" ]]; then echo -e "Updating Thunder console-config.js..." - # Update public_url in console-config.js - sed -i'' -e "s|public_url: 'https://[^']*'|public_url: 'https://${MAIL_DOMAIN}:${THUNDER_PORT}'|g" "${THUNDER_CONSOLE_CONFIG}" + sed -i '' \ + -e "s||${MAIL_DOMAIN}|g" \ + -e "s||${THUNDER_PORT}|g" \ + "${THUNDER_CONSOLE_CONFIG}" echo -e "Thunder console-config.js updated" else @@ -73,8 +66,10 @@ fi if [[ -f "${THUNDER_GATE_CONFIG}" ]]; then echo -e "Updating Thunder gate-config.js..." - # Update public_url in gate-config.js - sed -i'' -e "s|public_url: 'https://[^']*'|public_url: 'https://${MAIL_DOMAIN}:${THUNDER_PORT}'|g" "${THUNDER_GATE_CONFIG}" + sed -i '' \ + -e "s||${MAIL_DOMAIN}|g" \ + -e "s||${THUNDER_PORT}|g" \ + "${THUNDER_GATE_CONFIG}" echo -e "Thunder gate-config.js updated" else From a1eebbf6d0e0d69d771a5ccb4645c0c1a049729e Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 24 Apr 2026 14:43:51 +0530 Subject: [PATCH 06/18] Add script to create a single-page application resource in Thunder - Introduced `02-sample-resources.sh` to automate the creation of a SPA resource. - The script sources common functions and loads environment variables from a `.env` file. - It defines default parameters for the SPA and includes functions to extract JSON values, get organization unit IDs, and fetch flow IDs. - Implements the `create_spa_application` function to handle the API call for creating the SPA with necessary configurations. - Logs success or error messages based on the API response. --- .../scripts/thunder/01-default-resources.sh | 1405 +++++++++++++++++ .../scripts/thunder/02-sample-resources.sh | 223 +++ 2 files changed, 1628 insertions(+) create mode 100755 mail-infra/scripts/thunder/01-default-resources.sh create mode 100755 mail-infra/scripts/thunder/02-sample-resources.sh diff --git a/mail-infra/scripts/thunder/01-default-resources.sh b/mail-infra/scripts/thunder/01-default-resources.sh new file mode 100755 index 0000000..e048aba --- /dev/null +++ b/mail-infra/scripts/thunder/01-default-resources.sh @@ -0,0 +1,1405 @@ +#!/bin/bash +# Bootstrap Script: Default Resources Setup +# Creates default organization unit, user schema, admin user, system resource server, system action, admin role, and CONSOLE application + +set -e + +# Parse command line arguments for custom redirect URIs +CUSTOM_CONSOLE_REDIRECT_URIS="" +while [[ $# -gt 0 ]]; do + case $1 in + --console-redirect-uris) + CUSTOM_CONSOLE_REDIRECT_URIS="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +# Source common functions from the same directory as this script +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]:-$0}")" +source "${SCRIPT_DIR}/common.sh" + +log_info "Creating default Thunder resources..." +echo "" + +# ============================================================================ +# Create Default Organization Unit +# ============================================================================ + +log_info "Creating default organization unit..." + +RESPONSE=$(thunder_api_call POST "/organization-units" '{ + "handle": "default", + "name": "Default", + "description": "Default organization unit", + "logoUrl": "emoji:🏛️" +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Organization unit created successfully" + DEFAULT_OU_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$DEFAULT_OU_ID" ]]; then + log_info "Default OU ID: $DEFAULT_OU_ID" + else + log_error "Could not extract OU ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Organization unit already exists, retrieving OU ID..." + # Get existing OU ID by handle to ensure we get the correct "default" OU + RESPONSE=$(thunder_api_call GET "/organization-units/tree/default") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + DEFAULT_OU_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$DEFAULT_OU_ID" ]]; then + log_success "Found OU ID: $DEFAULT_OU_ID" + else + log_error "Could not find OU ID in response" + exit 1 + fi + else + log_error "Failed to fetch organization unit by handle 'default' (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create organization unit (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create Default User Schema +# ============================================================================ + +log_info "Creating default user schema (person)..." + +RESPONSE=$(thunder_api_call POST "/user-schemas" '{ + "name": "Person", + "ouId": "'${DEFAULT_OU_ID}'", + "schema": { + "username": { + "type": "string", + "displayName": "Username", + "required": true, + "unique": true + }, + "email": { + "type": "string", + "displayName": "Email", + "required": true, + "unique": true, + "regex": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + "email_verified": { + "type": "boolean", + "displayName": "Email Verified", + "required": false + }, + "given_name": { + "type": "string", + "displayName": "First Name", + "required": false + }, + "family_name": { + "type": "string", + "displayName": "Last Name", + "required": false + }, + "mobileNumber": { + "type": "string", + "displayName": "Mobile Number", + "required": false + }, + "phone_number": { + "type": "string", + "displayName": "Phone Number", + "required": false + }, + "phone_number_verified": { + "type": "boolean", + "displayName": "Phone Number Verified", + "required": false + }, + "sub": { + "type": "string", + "displayName": "Subject", + "required": false + }, + "name": { + "type": "string", + "displayName": "Full Name", + "required": false + }, + "picture": { + "type": "string", + "displayName": "Picture", + "required": false + }, + "password": { + "type": "string", + "displayName": "Password", + "required": false, + "credential": true + } + }, + "systemAttributes": { + "display": "username" + } +}') + +HTTP_CODE="${RESPONSE: -3}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "User schema created successfully" +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "User schema already exists, skipping" +else + log_error "Failed to create user schema (HTTP $HTTP_CODE)" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create Admin User +# ============================================================================ + +log_info "Creating admin user..." + +# Read admin credentials from environment variables with defaults +ADMIN_USERNAME="${THUNDER_ADMIN_USERNAME:-admin}" +ADMIN_PASSWORD="${THUNDER_ADMIN_PASSWORD:-admin}" + +log_info "Using admin username: ${ADMIN_USERNAME}" + +RESPONSE=$(thunder_api_call POST "/users" '{ + "type": "Person", + "ouId": "'${DEFAULT_OU_ID}'", + "attributes": { + "username": "'${ADMIN_USERNAME}'", + "password": "'${ADMIN_PASSWORD}'", + "sub": "admin", + "email": "admin@thunder.dev", + "email_verified": true, + "name": "Administrator", + "given_name": "Admin", + "family_name": "User", + "picture": "https://example.com/avatar.jpg", + "phone_number": "+12345678920", + "phone_number_verified": true + } +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Admin user created successfully" + log_info "Username: ${ADMIN_USERNAME}" + log_info "Password: ${ADMIN_PASSWORD}" + + # Extract admin user ID + ADMIN_USER_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -z "$ADMIN_USER_ID" ]]; then + log_warning "Could not extract admin user ID from response" + else + log_info "Admin user ID: $ADMIN_USER_ID" + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Admin user already exists, retrieving user ID..." + + # Get existing admin user ID + RESPONSE=$(thunder_api_call GET "/users") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + # Parse JSON to find admin user + ADMIN_USER_ID=$(echo "$BODY" | grep -o '"id":"[^"]*","[^"]*":"[^"]*","attributes":{[^}]*"username":"'"${ADMIN_USERNAME}"'"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + + # Fallback parsing + if [[ -z "$ADMIN_USER_ID" ]]; then + ADMIN_USER_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"username":"'"${ADMIN_USERNAME}"'"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + fi + + if [[ -n "$ADMIN_USER_ID" ]]; then + log_success "Found admin user ID: $ADMIN_USER_ID" + else + log_error "Could not find admin user in response" + exit 1 + fi + else + log_error "Failed to fetch users (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create admin user (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create System Resource Server +# ============================================================================ + +log_info "Creating system resource server..." + +if [[ -z "$DEFAULT_OU_ID" ]]; then + log_error "Default OU ID is not available. Cannot create resource server." + exit 1 +fi + +RESPONSE=$(thunder_api_call POST "/resource-servers" "{ + \"name\": \"System\", + \"description\": \"System resource server\", + \"identifier\": \"system\", + \"ouId\": \"${DEFAULT_OU_ID}\" +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Resource server created successfully" + SYSTEM_RS_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$SYSTEM_RS_ID" ]]; then + log_info "System resource server ID: $SYSTEM_RS_ID" + else + log_error "Could not extract resource server ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Resource server already exists, retrieving ID..." + # Get existing resource server ID + RESPONSE=$(thunder_api_call GET "/resource-servers") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + SYSTEM_RS_ID=$(echo "$BODY" | grep -o '"id":"[^"]*","[^"]*":"System"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + + # Fallback parsing + if [[ -z "$SYSTEM_RS_ID" ]]; then + SYSTEM_RS_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"identifier":"system"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + fi + + if [[ -n "$SYSTEM_RS_ID" ]]; then + log_success "Found resource server ID: $SYSTEM_RS_ID" + else + log_error "Could not find resource server ID in response" + exit 1 + fi + else + log_error "Failed to fetch resource servers (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create resource server (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create System Resource Permissions (hierarchical permission model) +# ============================================================================ +# +# Permission auto-derivation: +# Resource Server identifier "system" +# └── Resource handle "system" → permission "system" +# └── Resource handle "ou" → permission "system:ou" +# └── Action handle "view" → permission "system:ou:view" +# └── Resource handle "user" → permission "system:user" +# └── Action handle "view" → permission "system:user:view" +# └── Resource handle "group" → permission "system:group" +# └── Action handle "view" → permission "system:group:view" +# └── Resource handle "userschema" → permission "system:userschema" +# └── Action handle "view" → permission "system:userschema:view" +# ============================================================================ + +log_info "Creating 'system' resource under the system resource server..." + +if [[ -z "$SYSTEM_RS_ID" ]]; then + log_error "System resource server ID is not available. Cannot create system resource." + exit 1 +fi + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources" '{ + "name": "System", + "description": "System resource", + "handle": "system" +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "System resource created successfully (permission: system)" + SYSTEM_RESOURCE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$SYSTEM_RESOURCE_ID" ]]; then + log_info "System resource ID: $SYSTEM_RESOURCE_ID" + else + log_error "Could not extract system resource ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "System resource already exists, retrieving ID..." + RESPONSE=$(thunder_api_call GET "/resource-servers/${SYSTEM_RS_ID}/resources") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + SYSTEM_RESOURCE_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"handle":"system"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$SYSTEM_RESOURCE_ID" ]]; then + log_success "Found system resource ID: $SYSTEM_RESOURCE_ID" + else + log_error "Could not find system resource in response" + exit 1 + fi + else + log_error "Failed to fetch resources (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create system resource (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'ou' sub-resource under the 'system' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources" "{ + \"name\": \"Organization Unit\", + \"description\": \"Organization unit resource\", + \"handle\": \"ou\", + \"parent\": \"${SYSTEM_RESOURCE_ID}\" +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "OU resource created successfully (permission: system:ou)" + OU_RESOURCE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$OU_RESOURCE_ID" ]]; then + log_info "OU resource ID: $OU_RESOURCE_ID" + else + log_error "Could not extract OU resource ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "OU resource already exists, retrieving ID..." + RESPONSE=$(thunder_api_call GET "/resource-servers/${SYSTEM_RS_ID}/resources?parentId=${SYSTEM_RESOURCE_ID}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + OU_RESOURCE_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"handle":"ou"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$OU_RESOURCE_ID" ]]; then + log_success "Found OU resource ID: $OU_RESOURCE_ID" + else + log_error "Could not find OU resource in response" + exit 1 + fi + else + log_error "Failed to fetch resources (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create OU resource (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'view' action under the 'ou' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources/${OU_RESOURCE_ID}/actions" '{ + "name": "View", + "description": "Read-only access to organization units", + "handle": "view" +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "OU view action created successfully (permission: system:ou:view)" +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "OU view action already exists, skipping" +else + log_error "Failed to create OU view action (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'user' sub-resource under the 'system' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources" "{ + \"name\": \"User\", + \"description\": \"User resource\", + \"handle\": \"user\", + \"parent\": \"${SYSTEM_RESOURCE_ID}\" +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "User resource created successfully (permission: system:user)" + USER_RESOURCE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$USER_RESOURCE_ID" ]]; then + log_info "User resource ID: $USER_RESOURCE_ID" + else + log_error "Could not extract user resource ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "User resource already exists, retrieving ID..." + RESPONSE=$(thunder_api_call GET "/resource-servers/${SYSTEM_RS_ID}/resources?parentId=${SYSTEM_RESOURCE_ID}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + USER_RESOURCE_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"handle":"user"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$USER_RESOURCE_ID" ]]; then + log_success "Found user resource ID: $USER_RESOURCE_ID" + else + log_error "Could not find user resource in response" + exit 1 + fi + else + log_error "Failed to fetch resources (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create user resource (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'view' action under the 'user' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources/${USER_RESOURCE_ID}/actions" '{ + "name": "View", + "description": "Read-only access to users", + "handle": "view" +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "User view action created successfully (permission: system:user:view)" +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "User view action already exists, skipping" +else + log_error "Failed to create user view action (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'userschema' sub-resource under the 'system' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources" "{ + \"name\": \"User Schema\", + \"description\": \"User schema resource\", + \"handle\": \"userschema\", + \"parent\": \"${SYSTEM_RESOURCE_ID}\" +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "User schema resource created successfully (permission: system:userschema)" + USER_SCHEMA_RESOURCE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$USER_SCHEMA_RESOURCE_ID" ]]; then + log_info "User schema resource ID: $USER_SCHEMA_RESOURCE_ID" + else + log_error "Could not extract user schema resource ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "User schema resource already exists, retrieving ID..." + RESPONSE=$(thunder_api_call GET "/resource-servers/${SYSTEM_RS_ID}/resources?parentId=${SYSTEM_RESOURCE_ID}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + USER_SCHEMA_RESOURCE_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"handle":"userschema"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$USER_SCHEMA_RESOURCE_ID" ]]; then + log_success "Found user schema resource ID: $USER_SCHEMA_RESOURCE_ID" + else + log_error "Could not find user schema resource in response" + exit 1 + fi + else + log_error "Failed to fetch resources (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create user schema resource (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'view' action under the 'userschema' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources/${USER_SCHEMA_RESOURCE_ID}/actions" '{ + "name": "View", + "description": "Read-only access to user schemas", + "handle": "view" +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "User schema view action created successfully (permission: system:userschema:view)" +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "User schema view action already exists, skipping" +else + log_error "Failed to create user schema view action (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +log_info "Creating 'group' sub-resource under the 'system' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources" "{ + \"name\": \"Group\", + \"description\": \"Group resource\", + \"handle\": \"group\", + \"parent\": \"${SYSTEM_RESOURCE_ID}\" +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Group resource created successfully (permission: system:group)" + GROUP_RESOURCE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$GROUP_RESOURCE_ID" ]]; then + log_info "Group resource ID: $GROUP_RESOURCE_ID" + else + log_error "Could not extract group resource ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Group resource already exists, retrieving ID..." + RESPONSE=$(thunder_api_call GET "/resource-servers/${SYSTEM_RS_ID}/resources?parentId=${SYSTEM_RESOURCE_ID}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + GROUP_RESOURCE_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"handle":"group"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$GROUP_RESOURCE_ID" ]]; then + log_success "Found group resource ID: $GROUP_RESOURCE_ID" + else + log_error "Could not find group resource in response" + exit 1 + fi + else + log_error "Failed to fetch resources (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create group resource (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +log_info "Creating 'view' action under the 'group' resource..." + +RESPONSE=$(thunder_api_call POST "/resource-servers/${SYSTEM_RS_ID}/resources/${GROUP_RESOURCE_ID}/actions" '{ + "name": "View", + "description": "Read-only access to groups", + "handle": "view" +}') + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Group view action created successfully (permission: system:group:view)" +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Group view action already exists, skipping" +else + log_error "Failed to create group view action (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create Administrator Group +# ============================================================================ + +log_info "Creating administrator group..." + +if [[ -z "$DEFAULT_OU_ID" ]]; then + log_error "Default OU ID is not available. Cannot create administrator group." + exit 1 +fi + +if [[ -z "$ADMIN_USER_ID" ]]; then + log_error "Admin user ID is not available. Cannot create administrator group with user membership." + exit 1 +fi + +RESPONSE=$(thunder_api_call POST "/groups" "{ + \"name\": \"Administrators\", + \"description\": \"System administrators group\", + \"ouId\": \"${DEFAULT_OU_ID}\", + \"members\": [ + { + \"id\": \"${ADMIN_USER_ID}\", + \"type\": \"user\" + } + ] +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Administrator group created successfully" + ADMIN_GROUP_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$ADMIN_GROUP_ID" ]]; then + log_info "Administrator group ID: $ADMIN_GROUP_ID" + else + log_error "Could not extract administrator group ID from response" + exit 1 + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Administrator group already exists, retrieving ID..." + RESPONSE=$(thunder_api_call GET "/groups/tree/default?limit=100") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + ADMIN_GROUP_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"name":"Administrators"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$ADMIN_GROUP_ID" ]]; then + log_success "Found administrator group ID: $ADMIN_GROUP_ID" + else + log_error "Could not find administrator group in response" + exit 1 + fi + else + log_error "Failed to fetch groups under default OU (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create administrator group (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create Admin Role +# ============================================================================ + +log_info "Creating admin role with 'system' permission..." + +if [[ -z "$ADMIN_GROUP_ID" ]]; then + log_error "Administrator group ID is not available. Cannot create role." + exit 1 +fi + +if [[ -z "$DEFAULT_OU_ID" ]]; then + log_error "Default OU ID is not available. Cannot create role." + exit 1 +fi + +if [[ -z "$SYSTEM_RS_ID" ]]; then + log_error "System resource server ID is not available. Cannot create role." + exit 1 +fi + +RESPONSE=$(thunder_api_call POST "/roles" "{ + \"name\": \"Administrator\", + \"description\": \"System administrator role with full permissions\", + \"ouId\": \"${DEFAULT_OU_ID}\", + \"permissions\": [ + { + \"resourceServerId\": \"${SYSTEM_RS_ID}\", + \"permissions\": [\"system\"] + } + ], + \"assignments\": [ + { + \"id\": \"${ADMIN_GROUP_ID}\", + \"type\": \"group\" + } + ] +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Admin role created and assigned to administrator group" + ADMIN_ROLE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$ADMIN_ROLE_ID" ]]; then + log_info "Admin role ID: $ADMIN_ROLE_ID" + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "Admin role already exists" +else + log_error "Failed to create admin role (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create Default Flows +# ============================================================================ + +log_info "Creating default flows..." + +# Path to flow definitions directories +AUTH_FLOWS_DIR="${SCRIPT_DIR}/flows/authentication" +REG_FLOWS_DIR="${SCRIPT_DIR}/flows/registration" +USER_ONBOARDING_FLOWS_DIR="${SCRIPT_DIR}/flows/user_onboarding" + +# Check if flows directory exists +if [[ ! -d "$AUTH_FLOWS_DIR" ]] && [[ ! -d "$REG_FLOWS_DIR" ]] && [[ ! -d "$USER_ONBOARDING_FLOWS_DIR" ]]; then + log_warning "Flow definition directories not found, skipping flow creation" +else + FLOW_COUNT=0 + FLOW_SUCCESS=0 + FLOW_SKIPPED=0 + + # Process authentication flows + if [[ -d "$AUTH_FLOWS_DIR" ]]; then + shopt -s nullglob + AUTH_FILES=("$AUTH_FLOWS_DIR"/*.json) + shopt -u nullglob + + if [[ ${#AUTH_FILES[@]} -gt 0 ]]; then + log_info "Processing authentication flows..." + + # Fetch existing auth flows + RESPONSE=$(thunder_api_call GET "/flows?flowType=AUTHENTICATION&limit=200") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + # Store existing auth flows as "handle|id" pairs + EXISTING_AUTH_FLOWS="" + if [[ "$HTTP_CODE" == "200" ]]; then + while IFS= read -r line; do + FLOW_ID=$(echo "$line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + FLOW_HANDLE=$(echo "$line" | grep -o '"handle":"[^"]*"' | cut -d'"' -f4) + if [[ -n "$FLOW_ID" ]] && [[ -n "$FLOW_HANDLE" ]]; then + EXISTING_AUTH_FLOWS="${EXISTING_AUTH_FLOWS}${FLOW_HANDLE}|${FLOW_ID}"$'\n' + log_debug "Found existing auth flow: handle=$FLOW_HANDLE (ID: $FLOW_ID)" + fi + done < <(echo "$BODY" | grep -o '{[^}]*"id":"[^"]*"[^}]*"handle":"[^"]*"[^}]*}') + fi + + log_debug "Total existing auth flows found: $(echo "$EXISTING_AUTH_FLOWS" | grep -c '|' || echo 0)" + + for FLOW_FILE in "$AUTH_FLOWS_DIR"/*.json; do + [[ ! -f "$FLOW_FILE" ]] && continue + + FLOW_COUNT=$((FLOW_COUNT + 1)) + FLOW_HANDLE=$(grep -o '"handle"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLOW_FILE" | head -1 | sed 's/"handle"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + FLOW_NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLOW_FILE" | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + log_debug "Processing flow file: $FLOW_FILE with handle: $FLOW_HANDLE, name: $FLOW_NAME" + + # Check if flow exists by handle + if echo "$EXISTING_AUTH_FLOWS" | grep -q "^${FLOW_HANDLE}|"; then + # Update existing flow + FLOW_ID=$(echo "$EXISTING_AUTH_FLOWS" | grep "^${FLOW_HANDLE}|" | cut -d'|' -f2) + log_info "Updating existing auth flow: $FLOW_NAME (handle: $FLOW_HANDLE)" + update_flow "$FLOW_ID" "$FLOW_FILE" + RESULT=$? + if [[ $RESULT -eq 0 ]]; then + FLOW_SUCCESS=$((FLOW_SUCCESS + 1)) + fi + else + # Create new flow + create_flow "$FLOW_FILE" + RESULT=$? + if [[ $RESULT -eq 0 ]]; then + FLOW_SUCCESS=$((FLOW_SUCCESS + 1)) + elif [[ $RESULT -eq 2 ]]; then + FLOW_SKIPPED=$((FLOW_SKIPPED + 1)) + fi + fi + done + else + log_warning "No authentication flow files found" + fi + fi + + # Process registration flows + if [[ -d "$REG_FLOWS_DIR" ]]; then + shopt -s nullglob + REG_FILES=("$REG_FLOWS_DIR"/*.json) + shopt -u nullglob + + if [[ ${#REG_FILES[@]} -gt 0 ]]; then + log_info "Processing registration flows..." + + # Fetch existing registration flows + RESPONSE=$(thunder_api_call GET "/flows?flowType=REGISTRATION&limit=200") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + # Store existing registration flows as "handle|id" pairs + EXISTING_REG_FLOWS="" + if [[ "$HTTP_CODE" == "200" ]]; then + while IFS= read -r line; do + FLOW_ID=$(echo "$line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + FLOW_HANDLE=$(echo "$line" | grep -o '"handle":"[^"]*"' | cut -d'"' -f4) + if [[ -n "$FLOW_ID" ]] && [[ -n "$FLOW_HANDLE" ]]; then + EXISTING_REG_FLOWS="${EXISTING_REG_FLOWS}${FLOW_HANDLE}|${FLOW_ID}"$'\n' + fi + done < <(echo "$BODY" | grep -o '{[^}]*"id":"[^"]*"[^}]*"handle":"[^"]*"[^}]*}') + fi + + for FLOW_FILE in "$REG_FLOWS_DIR"/*.json; do + [[ ! -f "$FLOW_FILE" ]] && continue + + FLOW_COUNT=$((FLOW_COUNT + 1)) + FLOW_HANDLE=$(grep -o '"handle"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLOW_FILE" | head -1 | sed 's/"handle"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + FLOW_NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLOW_FILE" | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + + # Check if flow exists by handle + if echo "$EXISTING_REG_FLOWS" | grep -q "^${FLOW_HANDLE}|"; then + # Update existing flow + FLOW_ID=$(echo "$EXISTING_REG_FLOWS" | grep "^${FLOW_HANDLE}|" | cut -d'|' -f2) + log_info "Updating existing registration flow: $FLOW_NAME (handle: $FLOW_HANDLE)" + update_flow "$FLOW_ID" "$FLOW_FILE" + RESULT=$? + if [[ $RESULT -eq 0 ]]; then + FLOW_SUCCESS=$((FLOW_SUCCESS + 1)) + fi + else + # Create new flow + create_flow "$FLOW_FILE" + RESULT=$? + if [[ $RESULT -eq 0 ]]; then + FLOW_SUCCESS=$((FLOW_SUCCESS + 1)) + elif [[ $RESULT -eq 2 ]]; then + FLOW_SKIPPED=$((FLOW_SKIPPED + 1)) + fi + fi + done + else + log_warning "No registration flow files found" + fi + fi + + # Process user onboarding flows + if [[ -d "$USER_ONBOARDING_FLOWS_DIR" ]]; then + shopt -s nullglob + INVITE_FILES=("$USER_ONBOARDING_FLOWS_DIR"/*.json) + shopt -u nullglob + + if [[ ${#INVITE_FILES[@]} -gt 0 ]]; then + log_info "Processing user onboarding flows..." + + # Fetch existing user onboarding flows + RESPONSE=$(thunder_api_call GET "/flows?flowType=USER_ONBOARDING&limit=200") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + # Store existing user onboarding flows as "handle|id" pairs + EXISTING_INVITE_FLOWS="" + if [[ "$HTTP_CODE" == "200" ]]; then + while IFS= read -r line; do + FLOW_ID=$(echo "$line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + FLOW_HANDLE=$(echo "$line" | grep -o '"handle":"[^"]*"' | cut -d'"' -f4) + if [[ -n "$FLOW_ID" ]] && [[ -n "$FLOW_HANDLE" ]]; then + EXISTING_INVITE_FLOWS="${EXISTING_INVITE_FLOWS}${FLOW_HANDLE}|${FLOW_ID}"$'\n' + fi + done < <(echo "$BODY" | grep -o '{[^}]*"id":"[^"]*"[^}]*"handle":"[^"]*"[^}]*}') + fi + + for FLOW_FILE in "$USER_ONBOARDING_FLOWS_DIR"/*.json; do + [[ ! -f "$FLOW_FILE" ]] && continue + + FLOW_COUNT=$((FLOW_COUNT + 1)) + FLOW_HANDLE=$(grep -o '"handle"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLOW_FILE" | head -1 | sed 's/"handle"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + FLOW_NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLOW_FILE" | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + + # Check if flow exists by handle + if echo "$EXISTING_INVITE_FLOWS" | grep -q "^${FLOW_HANDLE}|"; then + # Update existing flow + FLOW_ID=$(echo "$EXISTING_INVITE_FLOWS" | grep "^${FLOW_HANDLE}|" | cut -d'|' -f2) + log_info "Updating existing user onboarding flow: $FLOW_NAME (handle: $FLOW_HANDLE)" + update_flow "$FLOW_ID" "$FLOW_FILE" + RESULT=$? + if [[ $RESULT -eq 0 ]]; then + FLOW_SUCCESS=$((FLOW_SUCCESS + 1)) + fi + else + # Create new flow + create_flow "$FLOW_FILE" + RESULT=$? + if [[ $RESULT -eq 0 ]]; then + FLOW_SUCCESS=$((FLOW_SUCCESS + 1)) + elif [[ $RESULT -eq 2 ]]; then + FLOW_SKIPPED=$((FLOW_SKIPPED + 1)) + fi + fi + done + else + log_debug "No user onboarding flow files found" + fi + fi + + if [[ $FLOW_COUNT -gt 0 ]]; then + log_info "Flow creation summary: $FLOW_SUCCESS created/updated, $FLOW_SKIPPED skipped, $((FLOW_COUNT - FLOW_SUCCESS - FLOW_SKIPPED)) failed" + fi +fi + +echo "" + +# ============================================================================ +# Create Application-Specific Flows +# ============================================================================ + +log_info "Creating application-specific flows..." + +APPS_FLOWS_DIR="${SCRIPT_DIR}/flows/apps" + +# Store application flow IDs as "app_name|auth_flow_id|reg_flow_id" pairs +APP_FLOW_IDS="" + +if [[ -d "$APPS_FLOWS_DIR" ]]; then + # Fetch all existing flows once + log_info "Fetching existing flows for application flow processing..." + + # Get auth flows + RESPONSE=$(thunder_api_call GET "/flows?flowType=AUTHENTICATION&limit=200") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + EXISTING_APP_AUTH_FLOWS="" + if [[ "$HTTP_CODE" == "200" ]]; then + while IFS= read -r line; do + FLOW_ID=$(echo "$line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + FLOW_HANDLE=$(echo "$line" | grep -o '"handle":"[^"]*"' | cut -d'"' -f4) + if [[ -n "$FLOW_ID" ]] && [[ -n "$FLOW_HANDLE" ]]; then + EXISTING_APP_AUTH_FLOWS="${EXISTING_APP_AUTH_FLOWS}${FLOW_HANDLE}|${FLOW_ID}"$'\n' + fi + done < <(echo "$BODY" | grep -o '{[^}]*"id":"[^"]*"[^}]*"handle":"[^"]*"[^}]*}') + fi + + # Get registration flows + RESPONSE=$(thunder_api_call GET "/flows?flowType=REGISTRATION&limit=200") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + EXISTING_APP_REG_FLOWS="" + if [[ "$HTTP_CODE" == "200" ]]; then + while IFS= read -r line; do + FLOW_ID=$(echo "$line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + FLOW_HANDLE=$(echo "$line" | grep -o '"handle":"[^"]*"' | cut -d'"' -f4) + if [[ -n "$FLOW_ID" ]] && [[ -n "$FLOW_HANDLE" ]]; then + EXISTING_APP_REG_FLOWS="${EXISTING_APP_REG_FLOWS}${FLOW_HANDLE}|${FLOW_ID}"$'\n' + fi + done < <(echo "$BODY" | grep -o '{[^}]*"id":"[^"]*"[^}]*"handle":"[^"]*"[^}]*}') + fi + + # Process each application directory + for APP_DIR in "$APPS_FLOWS_DIR"/*; do + [[ ! -d "$APP_DIR" ]] && continue + + APP_NAME=$(basename "$APP_DIR") + APP_AUTH_FLOW_ID="" + APP_REG_FLOW_ID="" + + log_info "Processing flows for application: $APP_NAME" + + # Process authentication flow for app + shopt -s nullglob + AUTH_FLOW_FILES=("$APP_DIR"/auth_*.json) + shopt -u nullglob + + if [[ ${#AUTH_FLOW_FILES[@]} -gt 0 ]]; then + AUTH_FLOW_FILE="${AUTH_FLOW_FILES[0]}" + FLOW_HANDLE=$(grep -o '"handle"[[:space:]]*:[[:space:]]*"[^"]*"' "$AUTH_FLOW_FILE" | head -1 | sed 's/"handle"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + FLOW_NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$AUTH_FLOW_FILE" | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + + # Check if auth flow exists by handle + if echo "$EXISTING_APP_AUTH_FLOWS" | grep -q "^${FLOW_HANDLE}|"; then + # Update existing flow + APP_AUTH_FLOW_ID=$(echo "$EXISTING_APP_AUTH_FLOWS" | grep "^${FLOW_HANDLE}|" | cut -d'|' -f2) + log_info "Updating existing auth flow: $FLOW_NAME (handle: $FLOW_HANDLE)" + update_flow "$APP_AUTH_FLOW_ID" "$AUTH_FLOW_FILE" + else + # Create new flow + APP_AUTH_FLOW_ID=$(create_flow "$AUTH_FLOW_FILE") + fi + + # Re-fetch registration flows after creating auth flow + if [[ -n "$APP_AUTH_FLOW_ID" ]]; then + RESPONSE=$(thunder_api_call GET "/flows?flowType=REGISTRATION&limit=200") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + EXISTING_APP_REG_FLOWS="" + if [[ "$HTTP_CODE" == "200" ]]; then + while IFS= read -r line; do + FLOW_ID=$(echo "$line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) + FLOW_HANDLE_TEMP=$(echo "$line" | grep -o '"handle":"[^"]*"' | cut -d'"' -f4) + if [[ -n "$FLOW_ID" ]] && [[ -n "$FLOW_HANDLE_TEMP" ]]; then + EXISTING_APP_REG_FLOWS="${EXISTING_APP_REG_FLOWS}${FLOW_HANDLE_TEMP}|${FLOW_ID}"$'\n' + fi + done < <(echo "$BODY" | grep -o '{[^}]*"id":"[^"]*"[^}]*"handle":"[^"]*"[^}]*}') + fi + fi + else + log_warning "No authentication flow file found for app: $APP_NAME" + fi + + # Process registration flow for app + shopt -s nullglob + REG_FLOW_FILES=("$APP_DIR"/registration_*.json) + shopt -u nullglob + + if [[ ${#REG_FLOW_FILES[@]} -gt 0 ]]; then + REG_FLOW_FILE="${REG_FLOW_FILES[0]}" + FLOW_HANDLE=$(grep -o '"handle"[[:space:]]*:[[:space:]]*"[^"]*"' "$REG_FLOW_FILE" | head -1 | sed 's/"handle"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + FLOW_NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$REG_FLOW_FILE" | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + + # Check if registration flow exists by handle + if echo "$EXISTING_APP_REG_FLOWS" | grep -q "^${FLOW_HANDLE}|"; then + # Update existing flow + APP_REG_FLOW_ID=$(echo "$EXISTING_APP_REG_FLOWS" | grep "^${FLOW_HANDLE}|" | cut -d'|' -f2) + log_info "Updating existing registration flow: $FLOW_NAME (handle: $FLOW_HANDLE)" + update_flow "$APP_REG_FLOW_ID" "$REG_FLOW_FILE" + else + # Create new flow + APP_REG_FLOW_ID=$(create_flow "$REG_FLOW_FILE") + fi + else + log_warning "No registration flow file found for app: $APP_NAME" + fi + + # Store the flow IDs for this app + log_debug "Storing flow IDs for $APP_NAME: auth=$APP_AUTH_FLOW_ID, reg=$APP_REG_FLOW_ID" + APP_FLOW_IDS="${APP_FLOW_IDS}${APP_NAME}|${APP_AUTH_FLOW_ID}|${APP_REG_FLOW_ID}"$'\n' + done +else + log_warning "Application flows directory not found at $APPS_FLOWS_DIR" +fi + +echo "" + +# ============================================================================ +# Create CONSOLE Application +# ============================================================================ + +log_info "Creating CONSOLE application..." + +# Get flow IDs for console app from the APP_FLOW_IDS created/found during flow processing +CONSOLE_AUTH_FLOW_ID=$(echo "$APP_FLOW_IDS" | grep "^console|" | cut -d'|' -f2) +CONSOLE_REG_FLOW_ID=$(echo "$APP_FLOW_IDS" | grep "^console|" | cut -d'|' -f3) +log_debug "Extracted flow IDs: auth=$CONSOLE_AUTH_FLOW_ID, reg=$CONSOLE_REG_FLOW_ID" + +# Validate that flow IDs are available +if [[ -z "$CONSOLE_AUTH_FLOW_ID" ]]; then + log_error "Console authentication flow ID not found, cannot create CONSOLE application" + exit 1 +fi +if [[ -z "$CONSOLE_REG_FLOW_ID" ]]; then + log_error "Console registration flow ID not found, cannot create CONSOLE application" + exit 1 +fi + +# Use THUNDER_PUBLIC_URL for redirect URIs, fallback to THUNDER_API_BASE if not set +PUBLIC_URL="${THUNDER_PUBLIC_URL:-$THUNDER_API_BASE}" + +# Build redirect URIs array - default + custom if provided +REDIRECT_URIS="\"${PUBLIC_URL}/console\"" +if [[ -n "$CUSTOM_CONSOLE_REDIRECT_URIS" ]]; then + log_info "Adding custom redirect URIs: $CUSTOM_CONSOLE_REDIRECT_URIS" + # Split comma-separated URIs and append to array + IFS=',' read -ra URI_ARRAY <<< "$CUSTOM_CONSOLE_REDIRECT_URIS" + for uri in "${URI_ARRAY[@]}"; do + # Trim whitespace + uri=$(echo "$uri" | xargs) + REDIRECT_URIS="${REDIRECT_URIS},\"${uri}\"" + done +fi + +RESPONSE=$(thunder_api_call POST "/applications" "{ + \"name\": \"Console\", + \"description\": \"Management application for Thunder\", + \"ouId\": \"${DEFAULT_OU_ID}\", + \"url\": \"${PUBLIC_URL}/console\", + \"logoUrl\": \"emoji:👨‍💻\", + \"authFlowId\": \"${CONSOLE_AUTH_FLOW_ID}\", + \"registrationFlowId\": \"${CONSOLE_REG_FLOW_ID}\", + \"isRegistrationFlowEnabled\": false, + \"allowedUserTypes\": [\"Person\"], + \"userAttributes\": [\"given_name\",\"family_name\",\"email\",\"groups\", \"name\", \"ouId\"], + \"inboundAuthConfig\": [{ + \"type\": \"oauth2\", + \"config\": { + \"clientId\": \"CONSOLE\", + \"redirectUris\": [${REDIRECT_URIS}], + \"grantTypes\": [\"authorization_code\"], + \"responseTypes\": [\"code\"], + \"pkceRequired\": true, + \"tokenEndpointAuthMethod\": \"none\", + \"publicClient\": true, + \"token\": { + \"accessToken\": { + \"validityPeriod\": 3600, + \"userAttributes\": [\"given_name\",\"family_name\",\"email\",\"groups\", \"name\", \"ouId\"] + }, + \"idToken\": { + \"validityPeriod\": 3600, + \"userAttributes\": [\"given_name\",\"family_name\",\"email\",\"groups\", \"name\", \"ouId\"] + } + }, + \"scopeClaims\": { + \"profile\": [\"name\",\"given_name\",\"family_name\",\"picture\"], + \"email\": [\"email\",\"email_verified\"], + \"phone\": [\"phone_number\",\"phone_number_verified\"], + \"group\": [\"groups\"], + \"ou\": [\"ouId\"] + } + } + }] +}") + +HTTP_CODE="${RESPONSE: -3}" +BODY="${RESPONSE%???}" + +if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "CONSOLE application created successfully" + CONSOLE_APP_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$CONSOLE_APP_ID" ]]; then + log_info "CONSOLE_APP_ID: $CONSOLE_APP_ID" + else + log_warning "Could not extract CONSOLE application ID from response" + fi +elif [[ "$HTTP_CODE" == "409" ]]; then + log_warning "CONSOLE application already exists, retrieving application ID..." + RESPONSE=$(thunder_api_call GET "/applications") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + CONSOLE_APP_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"client_id":"CONSOLE"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -z "$CONSOLE_APP_ID" ]]; then + CONSOLE_APP_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"clientId":"CONSOLE"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + fi + + if [[ -n "$CONSOLE_APP_ID" ]]; then + log_success "Found CONSOLE application ID: $CONSOLE_APP_ID" + log_info "CONSOLE_APP_ID: $CONSOLE_APP_ID" + else + log_error "Could not find CONSOLE application in response" + exit 1 + fi + else + log_error "Failed to fetch applications (HTTP $HTTP_CODE)" + exit 1 + fi +elif [[ "$HTTP_CODE" == "400" ]] && [[ "$BODY" =~ (Application already exists|APP-1022) ]]; then + log_warning "CONSOLE application already exists, retrieving application ID..." + RESPONSE=$(thunder_api_call GET "/applications") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + CONSOLE_APP_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"client_id":"CONSOLE"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -z "$CONSOLE_APP_ID" ]]; then + CONSOLE_APP_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"clientId":"CONSOLE"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + fi + + if [[ -n "$CONSOLE_APP_ID" ]]; then + log_success "Found CONSOLE application ID: $CONSOLE_APP_ID" + log_info "CONSOLE_APP_ID: $CONSOLE_APP_ID" + else + log_error "Could not find CONSOLE application in response" + exit 1 + fi + else + log_error "Failed to fetch applications (HTTP $HTTP_CODE)" + exit 1 + fi +else + log_error "Failed to create CONSOLE application (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 +fi + +echo "" + +# ============================================================================ +# Create Themes +# ============================================================================ + +log_info "Creating themes..." + +THEMES_DIR="${SCRIPT_DIR}/themes" + +if [[ ! -d "$THEMES_DIR" ]]; then + log_warning "Themes directory not found at ${THEMES_DIR}, skipping theme creation" +else + shopt -s nullglob + THEME_FILES=("$THEMES_DIR"/*.json) + shopt -u nullglob + + if [[ ${#THEME_FILES[@]} -gt 0 ]]; then + log_info "Processing themes from ${THEMES_DIR}..." + + THEME_COUNT=0 + THEME_CREATED=0 + THEME_UPDATED=0 + + for THEME_FILE in "${THEME_FILES[@]}"; do + [[ ! -f "$THEME_FILE" ]] && continue + + THEME_COUNT=$((THEME_COUNT + 1)) + THEME_NAME=$(grep -o '"displayName"[[:space:]]*:[[:space:]]*"[^"]*"' "$THEME_FILE" | head -1 | sed 's/"displayName"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + if [[ -z "$THEME_NAME" ]]; then + THEME_NAME=$(basename "$THEME_FILE" .json) + fi + THEME_HANDLE=$(grep -o '"handle"[[:space:]]*:[[:space:]]*"[^"]*"' "$THEME_FILE" | head -1 | sed 's/"handle"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/') + + THEME_PAYLOAD=$(cat "$THEME_FILE") + + log_info "Creating theme: ${THEME_NAME} (from $(basename "$THEME_FILE"))" + RESPONSE=$(thunder_api_call POST "/design/themes" "${THEME_PAYLOAD}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then + log_success "Theme '${THEME_NAME}' created successfully" + THEME_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -n "$THEME_ID" ]]; then + log_info "Theme ID: $THEME_ID" + fi + THEME_CREATED=$((THEME_CREATED + 1)) + elif [[ "$HTTP_CODE" == "409" ]] || (echo "$BODY" | grep -q '"THM-1015"'); then + log_warning "Theme '${THEME_NAME}' already exists, updating..." + RESPONSE=$(thunder_api_call GET "/design/themes") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + THEME_ID=$(echo "$BODY" | sed 's/},{/}\n{/g' | grep '"handle":"'"${THEME_HANDLE}"'"' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -z "$THEME_ID" ]]; then + log_error "Failed to retrieve existing theme ID for '${THEME_NAME}'" + exit 1 + fi + log_info "Found existing theme ID: $THEME_ID" + RESPONSE=$(thunder_api_call PUT "/design/themes/${THEME_ID}" "${THEME_PAYLOAD}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + if [[ "$HTTP_CODE" == "200" ]]; then + log_success "Theme '${THEME_NAME}' updated successfully" + THEME_UPDATED=$((THEME_UPDATED + 1)) + else + log_error "Failed to update theme '${THEME_NAME}' (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 + fi + else + log_error "Failed to create theme '${THEME_NAME}' (HTTP $HTTP_CODE)" + echo "Response: $BODY" + exit 1 + fi + done + + echo "" + log_info "Theme creation summary: ${THEME_CREATED} created, ${THEME_UPDATED} updated (Total: ${THEME_COUNT})" + else + log_warning "No theme files found in ${THEMES_DIR}" + fi +fi + +echo "" + +# ============================================================================ +# Seed i18n Translations +# ============================================================================ + +log_info "Seeding i18n translations..." + +I18N_DIR="${SCRIPT_DIR}/i18n" + +if [[ ! -d "$I18N_DIR" ]]; then + log_warning "i18n directory not found at ${I18N_DIR}, skipping translation seeding" +else + shopt -s nullglob + I18N_FILES=("$I18N_DIR"/*.json) + shopt -u nullglob + + if [[ ${#I18N_FILES[@]} -gt 0 ]]; then + log_info "Processing i18n translations from ${I18N_DIR}..." + + I18N_COUNT=0 + I18N_SUCCESS=0 + + for I18N_FILE in "${I18N_FILES[@]}"; do + [[ ! -f "$I18N_FILE" ]] && continue + + I18N_COUNT=$((I18N_COUNT + 1)) + + # Extract language from filename (e.g., en-US.json -> en-US) + LANGUAGE=$(basename "$I18N_FILE" .json) + + log_info "Seeding translations for language: ${LANGUAGE} (from $(basename "$I18N_FILE"))" + + PAYLOAD=$(cat "$I18N_FILE") + + RESPONSE=$(thunder_api_call POST "/i18n/languages/${LANGUAGE}/translations" "$PAYLOAD") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" == "200" ]]; then + TOTAL=$(echo "$BODY" | grep -o '"totalResults":[0-9]*' | cut -d':' -f2) + log_success "Translations for '${LANGUAGE}' seeded successfully (${TOTAL:-?} translations)" + I18N_SUCCESS=$((I18N_SUCCESS + 1)) + else + log_error "Failed to seed translations for '${LANGUAGE}' (HTTP $HTTP_CODE)" + log_error "Response: $BODY" + exit 1 + fi + done + + echo "" + log_info "Translation seeding summary: ${I18N_SUCCESS} seeded (Total: ${I18N_COUNT})" + else + log_warning "No i18n translation files found in ${I18N_DIR}" + fi +fi + +echo "" + +# ============================================================================ +# Summary +# ============================================================================ + +log_success "Default resources setup completed successfully!" +echo "" +log_info "👤 Admin credentials:" +log_info " Username: ${ADMIN_USERNAME}" +log_info " Role: Administrator (system permission via Administrators group)" +echo "" \ No newline at end of file diff --git a/mail-infra/scripts/thunder/02-sample-resources.sh b/mail-infra/scripts/thunder/02-sample-resources.sh new file mode 100755 index 0000000..e31cdb1 --- /dev/null +++ b/mail-infra/scripts/thunder/02-sample-resources.sh @@ -0,0 +1,223 @@ +set -e + +# Source common functions from the same directory as this script +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]:-$0}")" +source "${SCRIPT_DIR}/common.sh" + +# Load .env values when available (useful for local execution). +ENV_FILE="${SCRIPT_DIR}/.env" +if [[ -f "$ENV_FILE" ]]; then + set -a + source "$ENV_FILE" + set +a +fi + +# Default SPA parameters (can be overridden via env vars). +SPA_APP_NAME="${THUNDER_SPA_APP_NAME:-Email App}" +SPA_APP_DESCRIPTION="${THUNDER_SPA_APP_DESCRIPTION:-Application for email client to use OAuth2 authentication}" +SPA_CLIENT_ID="${THUNDER_SPA_CLIENT_ID:-EMAIL_APP}" +SPA_ALLOWED_USER_TYPE="${THUNDER_SPA_ALLOWED_USER_TYPE:-Person}" +SPA_OU_HANDLE="${THUNDER_SPA_OU_HANDLE:-default}" + +log_info "Creating single-page application resource..." +echo "" + +# ============================================================================ +# Helpers +# ============================================================================ + +extract_json_value() { + local JSON_STRING="$1" + local KEY="$2" + + echo "$JSON_STRING" | grep -o "\"${KEY}\":\"[^\"]*\"" | head -1 | cut -d'"' -f4 +} + +get_ou_id_by_handle() { + local OU_HANDLE="$1" + local RESPONSE HTTP_CODE BODY OU_ID + + RESPONSE=$(thunder_api_call GET "/organization-units/tree/${OU_HANDLE}") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" != "200" ]]; then + log_error "Failed to resolve OU '${OU_HANDLE}' (HTTP $HTTP_CODE)" + echo "Response: $BODY" + return 1 + fi + + OU_ID=$(extract_json_value "$BODY" "id") + if [[ -z "$OU_ID" ]]; then + log_error "Could not extract OU ID for handle '${OU_HANDLE}'" + return 1 + fi + + echo "$OU_ID" +} + +get_first_flow_id() { + local FLOW_TYPE="$1" + local RESPONSE HTTP_CODE BODY FLOW_ID + + RESPONSE=$(thunder_api_call GET "/flows?flowType=${FLOW_TYPE}&limit=1") + HTTP_CODE="${RESPONSE: -3}" + BODY="${RESPONSE%???}" + + if [[ "$HTTP_CODE" != "200" ]]; then + log_error "Failed to fetch ${FLOW_TYPE} flows (HTTP $HTTP_CODE)" + echo "Response: $BODY" + return 1 + fi + + FLOW_ID=$(extract_json_value "$BODY" "id") + if [[ -z "$FLOW_ID" ]]; then + log_error "No ${FLOW_TYPE} flow found. Run default resource bootstrap first." + return 1 + fi + + echo "$FLOW_ID" +} + +create_spa_application() { + local APP_NAME="$1" + local APP_DESCRIPTION="$2" + local CLIENT_ID="$3" + local ALLOWED_USER_TYPE="$4" + local RESPONSE HTTP_CODE BODY + local APP_ID APP_CLIENT_ID + local APP_OU_ID AUTH_FLOW_ID REG_FLOW_ID + + log_info "Creating ${APP_NAME} application..." + + APP_OU_ID=$(get_ou_id_by_handle "$SPA_OU_HANDLE") || exit 1 + AUTH_FLOW_ID=$(get_first_flow_id "AUTHENTICATION") || exit 1 + REG_FLOW_ID=$(get_first_flow_id "REGISTRATION") || exit 1 + + read -r -d '' APP_PAYLOAD < Date: Fri, 24 Apr 2026 15:00:21 +0530 Subject: [PATCH 07/18] feat: upgrade authentication to use XOAUTH2 and remove password requirement Co-authored-by: Copilot --- README.md | 7 ++- api-server/README.md | 13 ++-- api-server/entrypoint.sh | 7 --- api-server/internal/api/auth.go | 87 ++++---------------------- api-server/internal/api/handlers.go | 12 ++-- api-server/internal/emailer/emailer.go | 41 ++++++++++-- api-server/main.go | 23 +------ 7 files changed, 71 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index de8bc4f..1d3df6d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ cp .env.example .env ``` - Edit the `.env` file to set your `DOMAIN` for SSL certificate configuration. Note that SMTP credentials are provided in the API request body, not in this file. + Edit the `.env` file to set your `DOMAIN` for SSL certificate configuration. 5. **Start the API server** ```bash @@ -61,15 +61,18 @@ 6. **Send a test email** ```bash curl -k -X POST https://localhost:8443/notify \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "smtp_host": "your_smtp_host", "smtp_port": 587, "smtp_username": "your_smtp_username", - "smtp_password": "your_smtp_password", "smtp_sender": "sender@example.com", "recipient_email": "user@example.com" }' + ``` + + The API forwards the bearer access token to SMTP using XOAUTH2. ## Architecture diff --git a/api-server/README.md b/api-server/README.md index 823b16d..2d4e4bf 100644 --- a/api-server/README.md +++ b/api-server/README.md @@ -33,12 +33,12 @@ The API will be available at `https://your-domain:8443/notify` ```bash curl -X POST https://your-domain:8443/notify \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "smtp_host": "smtp-server-container", "smtp_port": 587, "smtp_username": "user@yourdomain.com", - "smtp_password": "your-password", "smtp_sender": "noreply@yourdomain.com", "recipient_email": "recipient@example.com", "recipient_name": "John Doe", @@ -53,10 +53,12 @@ curl -X POST https://your-domain:8443/notify \ | `smtp_host` | string | SMTP server hostname | | `smtp_port` | integer | SMTP server port (587 for TLS) | | `smtp_username` | string | SMTP authentication username | -| `smtp_password` | string | SMTP authentication password | | `smtp_sender` | string | Sender email address | | `recipient_email` | string | Recipient's email address | +The request must include an `Authorization: Bearer ` header. The +API forwards this token to SMTP using XOAUTH2 and does not validate it locally. + ### Optional Fields | Field | Type | Default | Description | @@ -97,12 +99,12 @@ Access custom data in your template using `{{.FieldName}}`: ```bash curl -X POST https://your-domain:8443/notify \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "smtp_host": "smtp-server", "smtp_port": 587, "smtp_username": "user@domain.com", - "smtp_password": "password", "smtp_sender": "noreply@domain.com", "recipient_email": "user@example.com", "template": "{{define \"subject\"}}Password Reset{{end}}{{define \"plainBody\"}}Hi {{.Name}}, your code is {{.Code}}{{end}}{{define \"htmlBody\"}}

Hi {{.Name}}, your code is {{.Code}}

{{end}}", @@ -117,11 +119,12 @@ curl -X POST https://your-domain:8443/notify \ ### Success Response -**Status:** `200 OK` +**Status:** `202 Accepted` ```json { - "message": "Email sent successfully" + "message": "Email queued successfully", + "status": "ok" } ``` diff --git a/api-server/entrypoint.sh b/api-server/entrypoint.sh index d0c6147..0176104 100644 --- a/api-server/entrypoint.sh +++ b/api-server/entrypoint.sh @@ -4,13 +4,6 @@ set -e # Use positional parameters to safely build the command. This avoids issues with # word splitting if variables contain spaces or special characters. set -- ./api-server -port "${PORT}" -# Add OAuth2 introspection URL -if [ -n "${OAUTH2_INTROSPECT_URL}" ]; then - set -- "$@" -oauth2-introspect-url "${OAUTH2_INTROSPECT_URL}" -else - echo "Error: OAUTH2_INTROSPECT_URL must be set" >&2 - exit 1 -fi # Add TLS configuration if provided if [ -n "${CERT_FILE}" ] && [ -n "${KEY_FILE}" ]; then set -- "$@" -cert "${CERT_FILE}" -key "${KEY_FILE}" diff --git a/api-server/internal/api/auth.go b/api-server/internal/api/auth.go index 47008c7..9983e05 100644 --- a/api-server/internal/api/auth.go +++ b/api-server/internal/api/auth.go @@ -1,28 +1,15 @@ package api import ( - "encoding/json" + "context" "fmt" - "io" "net/http" - "net/url" "strings" - "time" ) -// IntrospectionResponse represents the OAuth2 token introspection response -type IntrospectionResponse struct { - Active bool `json:"active"` - ClientID string `json:"client_id,omitempty"` - TokenType string `json:"token_type,omitempty"` - Exp int64 `json:"exp,omitempty"` - Iat int64 `json:"iat,omitempty"` - Nbf int64 `json:"nbf,omitempty"` - Sub string `json:"sub,omitempty"` - Aud string `json:"aud,omitempty"` - Iss string `json:"iss,omitempty"` - Jti string `json:"jti,omitempty"` -} +type contextKey string + +const accessTokenContextKey contextKey = "access_token" // extractBearerToken extracts the Bearer token from the Authorization header func extractBearerToken(r *http.Request) (string, error) { @@ -39,60 +26,16 @@ func extractBearerToken(r *http.Request) (string, error) { return parts[1], nil } -// validateAccessToken validates the provided access token using OAuth2 token introspection -func (app *App) validateAccessToken(token string) error { - // Create form data with the token - data := url.Values{} - data.Set("token", token) - - // #nosec G704 -- IntrospectURL is from server configuration, not user input - req, err := http.NewRequest("POST", app.config.OAuth2.IntrospectURL, strings.NewReader(data.Encode())) - if err != nil { - return fmt.Errorf("failed to create introspection request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - // #nosec G704 -- IntrospectURL is from server configuration, validated at startup - resp, err := app.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to introspect token: %w", err) - } - defer func() { - if closeErr := resp.Body.Close(); closeErr != nil { - app.logger.Warn("failed to close response body", "error", closeErr) - } - }() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("introspection failed with status %d: %s", resp.StatusCode, string(bodyBytes)) - } - - var introspection IntrospectionResponse - if err := json.NewDecoder(resp.Body).Decode(&introspection); err != nil { - return fmt.Errorf("failed to decode introspection response: %w", err) - } - - // Check if token is active - if !introspection.Active { - return fmt.Errorf("token is not active") - } - - // Optionally validate additional claims - if introspection.Exp > 0 && time.Now().Unix() > introspection.Exp { - return fmt.Errorf("token has expired") +func accessTokenFromContext(ctx context.Context) (string, bool) { + token, ok := ctx.Value(accessTokenContextKey).(string) + if !ok || token == "" { + return "", false } - - app.logger.Info("token validated successfully", - "client_id", introspection.ClientID, - "sub", introspection.Sub, - "exp", introspection.Exp) - - return nil + return token, true } -// authMiddleware validates the Bearer token before allowing access to protected endpoints +// authMiddleware extracts the Bearer token and stores it for downstream SMTP XOAUTH2 auth. +// Token validation is intentionally not performed in this API server. func (app *App) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token, err := extractBearerToken(r) @@ -102,12 +45,6 @@ func (app *App) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return } - if err := app.validateAccessToken(token); err != nil { - app.logger.Warn("token validation failed", "error", err) - http.Error(w, "Unauthorized: invalid or expired token", http.StatusUnauthorized) - return - } - - next(w, r) + next(w, r.WithContext(context.WithValue(r.Context(), accessTokenContextKey, token))) } } diff --git a/api-server/internal/api/handlers.go b/api-server/internal/api/handlers.go index cb2526e..963c65f 100644 --- a/api-server/internal/api/handlers.go +++ b/api-server/internal/api/handlers.go @@ -30,7 +30,6 @@ type notifyRequest struct { SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` SMTPSender string `json:"smtp_sender"` RecipientName string `json:"recipient_name"` RecipientEmail string `json:"recipient_email"` @@ -58,8 +57,14 @@ func (app *App) handleNotify(w http.ResponseWriter, r *http.Request) { } applyNotifyDefaults(&req) + accessToken, _ := accessTokenFromContext(r.Context()) + if accessToken == "" { + http.Error(w, "Unauthorized: missing access token", http.StatusUnauthorized) + return + } + // Create a new mailer instance with the provided SMTP configuration - mailer := emailer.NewMailer(req.SMTPHost, req.SMTPPort, req.SMTPUsername, req.SMTPPassword, req.SMTPSender) + mailer := emailer.NewMailer(req.SMTPHost, req.SMTPPort, req.SMTPUsername, req.SMTPSender, accessToken) defaultUser := buildDefaultUser(req) @@ -105,9 +110,6 @@ func validateNotifyRequest(w http.ResponseWriter, req notifyRequest) bool { if !required(req.SMTPUsername != "", "Missing required field: smtp_username") { return false } - if !required(req.SMTPPassword != "", "Missing required field: smtp_password") { - return false - } if !required(req.SMTPSender != "", "Missing required field: smtp_sender") { return false } diff --git a/api-server/internal/emailer/emailer.go b/api-server/internal/emailer/emailer.go index 5b7eadc..4878152 100644 --- a/api-server/internal/emailer/emailer.go +++ b/api-server/internal/emailer/emailer.go @@ -3,7 +3,11 @@ package emailer import ( "bytes" "embed" + "encoding/base64" + "errors" + "fmt" "html/template" + "net/smtp" "time" "github.com/go-mail/mail/v2" @@ -20,17 +24,46 @@ type Mailer struct { sender string } -// New returns a new Mailer instance configured with the provided SMTP settings -// and a default 2-second connection timeout. -func NewMailer(host string, port int, username, password, sender string) Mailer { - dialer := mail.NewDialer(host, port, username, password) +// NewMailer returns a Mailer configured for SMTP auth. When accessToken is +// provided, it uses XOAUTH2 with the SMTP username embedded as an encoded +// user identifier in the auth payload. +func NewMailer(host string, port int, username, sender, accessToken string) Mailer { + dialer := mail.NewDialer(host, port, "", "") + dialer.Auth = &xoauth2Auth{ + username: username, + token: accessToken, + } dialer.Timeout = 2 * time.Second + return Mailer{ dialer: dialer, sender: sender, } } +type xoauth2Auth struct { + username string + token string +} + +func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) { + if !server.TLS { + return "", nil, errors.New("xoauth2 requires TLS") + } + + encodedUser := base64.RawStdEncoding.EncodeToString([]byte(a.username)) + initialResponse := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", encodedUser, a.token) + + return "XOAUTH2", []byte(initialResponse), nil +} + +func (a *xoauth2Auth) Next(_ []byte, more bool) ([]byte, error) { + if more { + return nil, errors.New("unexpected server challenge during XOAUTH2 authentication") + } + return nil, nil +} + // Send renders the specified template with the provided data and delivers // the email to the recipient. The template file must define "subject", // "plainBody", and "htmlBody" blocks. diff --git a/api-server/main.go b/api-server/main.go index 7d2778d..d0f157e 100644 --- a/api-server/main.go +++ b/api-server/main.go @@ -4,7 +4,6 @@ import ( "flag" "log/slog" "net/http" - "net/url" "os" "time" @@ -18,34 +17,16 @@ func main() { flag.StringVar(&cfg.Version, "version", "0.1.0", "Version") flag.StringVar(&cfg.CertFile, "cert", "", "Path to TLS certificate file (e.g., /path/to/fullchain.pem)") flag.StringVar(&cfg.KeyFile, "key", "", "Path to TLS key file (e.g., /path/to/privkey.pem)") - flag.StringVar(&cfg.OAuth2.IntrospectURL, "oauth2-introspect-url", "", "OAuth2 token introspection endpoint URL") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) if cfg.Dev { - logger.Warn("dev mode enabled: OAuth validation is disabled") - } else { - // Validate OAuth2 introspection URL - if cfg.OAuth2.IntrospectURL == "" { - logger.Error("OAuth2 introspection URL must be provided via -oauth2-introspect-url flag") - os.Exit(1) - } - - // Validate that the introspection URL is properly formatted and uses HTTP/S. - introspectURL, err := url.Parse(cfg.OAuth2.IntrospectURL) - if err != nil { - logger.Error("invalid OAuth2 introspection URL", "error", err) - os.Exit(1) - } - if introspectURL.Scheme != "https" { - logger.Error("OAuth2 introspection URL must use https scheme") - os.Exit(1) - } + logger.Warn("dev mode enabled: authorization middleware is bypassed") } - // Create a shared HTTP client for token introspection. + // Keep a shared HTTP client for future integrations. httpClient := &http.Client{ Timeout: 10 * time.Second, } From bdcbee2d737358c83c27aaa99319a6ff488d7820 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 24 Apr 2026 15:24:20 +0530 Subject: [PATCH 08/18] feat: update NewMailer function to use sender as username for XOAUTH2 authentication --- api-server/internal/emailer/emailer.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api-server/internal/emailer/emailer.go b/api-server/internal/emailer/emailer.go index 4878152..2eb7ead 100644 --- a/api-server/internal/emailer/emailer.go +++ b/api-server/internal/emailer/emailer.go @@ -25,12 +25,12 @@ type Mailer struct { } // NewMailer returns a Mailer configured for SMTP auth. When accessToken is -// provided, it uses XOAUTH2 with the SMTP username embedded as an encoded +// provided, it uses XOAUTH2 with the SMTP sender embedded as an encoded // user identifier in the auth payload. -func NewMailer(host string, port int, username, sender, accessToken string) Mailer { +func NewMailer(host string, port int, _ string, sender, accessToken string) Mailer { dialer := mail.NewDialer(host, port, "", "") dialer.Auth = &xoauth2Auth{ - username: username, + username: sender, token: accessToken, } dialer.Timeout = 2 * time.Second From 0a06c661a6aa026207e2c2454e808bad3b35fbb8 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 24 Apr 2026 15:31:31 +0530 Subject: [PATCH 09/18] refactor: simplify XOAUTH2 user identifier handling in NewMailer Co-authored-by: Copilot --- api-server/internal/emailer/emailer.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/api-server/internal/emailer/emailer.go b/api-server/internal/emailer/emailer.go index 2eb7ead..3bbdcc5 100644 --- a/api-server/internal/emailer/emailer.go +++ b/api-server/internal/emailer/emailer.go @@ -3,7 +3,6 @@ package emailer import ( "bytes" "embed" - "encoding/base64" "errors" "fmt" "html/template" @@ -25,8 +24,8 @@ type Mailer struct { } // NewMailer returns a Mailer configured for SMTP auth. When accessToken is -// provided, it uses XOAUTH2 with the SMTP sender embedded as an encoded -// user identifier in the auth payload. +// provided, it uses XOAUTH2 with the SMTP sender as the user identifier in +// the auth payload. func NewMailer(host string, port int, _ string, sender, accessToken string) Mailer { dialer := mail.NewDialer(host, port, "", "") dialer.Auth = &xoauth2Auth{ @@ -51,8 +50,7 @@ func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) { return "", nil, errors.New("xoauth2 requires TLS") } - encodedUser := base64.RawStdEncoding.EncodeToString([]byte(a.username)) - initialResponse := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", encodedUser, a.token) + initialResponse := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token) return "XOAUTH2", []byte(initialResponse), nil } From 21cda1e55cd06e60708b697c96a7c7efe28829f7 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 24 Apr 2026 21:27:28 +0530 Subject: [PATCH 10/18] feat: add OAuth client email authorization configuration and update Raven config script for OAUTHBEARER support Co-authored-by: Copilot --- docker-compose.yml | 1 + mail-infra/conf/oauth_client_email_authorization.json | 6 ++++++ mail-infra/services/config-scripts/gen-raven-conf.sh | 9 +++++++++ 3 files changed, 16 insertions(+) create mode 100644 mail-infra/conf/oauth_client_email_authorization.json diff --git a/docker-compose.yml b/docker-compose.yml index a8f59b5..fb5d4e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - ./mail-infra/services/silver-config/raven/conf:/etc/raven:rw - ./mail-infra/services/silver-config/raven/certs:/certs:ro - /var/run/docker.sock:/var/run/docker.sock:ro + - ./mail-infra/conf/oauth_client_email_authorization.json:/app/config/oauth_client_email_authorization.json:ro environment: - DB_FILE=/app/data/databases/shared.db networks: diff --git a/mail-infra/conf/oauth_client_email_authorization.json b/mail-infra/conf/oauth_client_email_authorization.json new file mode 100644 index 0000000..aefe4c0 --- /dev/null +++ b/mail-infra/conf/oauth_client_email_authorization.json @@ -0,0 +1,6 @@ +{ + "client_id": [ + "email_address_1", + "email_address_2" + ] +} diff --git a/mail-infra/services/config-scripts/gen-raven-conf.sh b/mail-infra/services/config-scripts/gen-raven-conf.sh index dce9c09..d1f427f 100644 --- a/mail-infra/services/config-scripts/gen-raven-conf.sh +++ b/mail-infra/services/config-scripts/gen-raven-conf.sh @@ -33,6 +33,15 @@ domain: ${MAIL_DOMAIN} auth_server_url: https://thunder-server:8090/auth/credentials/authenticate sasl_scope: "tcp_only" + +# OAUTHBEARER Token Validation (RFC 7628) +# Required when enabling AUTH=OAUTHBEARER for IMAP/SASL. +oauth_issuer_url: "https://${MAIL_DOMAIN}:8090" +oauth_jwks_url: "https://${MAIL_DOMAIN}:8090/oauth2/jwks" +oauth_audience: + - "client_id_for_application" +oauth_clock_skew_seconds: 60 +oauth_client_email_authorization_file: "/app/config/oauth_client_email_authorization.json" EOF echo "✅ Generated: $OUTPUT_FILE (domain: ${MAIL_DOMAIN})" From 6d4be28530f892d87aa94597112d73fe2fa9e372 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 24 Apr 2026 21:46:12 +0530 Subject: [PATCH 11/18] refactor: remove unused sample resource creation logic from script Co-authored-by: Copilot --- .../scripts/thunder/02-sample-resources.sh | 224 +----------------- 1 file changed, 2 insertions(+), 222 deletions(-) diff --git a/mail-infra/scripts/thunder/02-sample-resources.sh b/mail-infra/scripts/thunder/02-sample-resources.sh index e31cdb1..eb026ab 100755 --- a/mail-infra/scripts/thunder/02-sample-resources.sh +++ b/mail-infra/scripts/thunder/02-sample-resources.sh @@ -1,223 +1,3 @@ -set -e - -# Source common functions from the same directory as this script -SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]:-$0}")" -source "${SCRIPT_DIR}/common.sh" - -# Load .env values when available (useful for local execution). -ENV_FILE="${SCRIPT_DIR}/.env" -if [[ -f "$ENV_FILE" ]]; then - set -a - source "$ENV_FILE" - set +a -fi - -# Default SPA parameters (can be overridden via env vars). -SPA_APP_NAME="${THUNDER_SPA_APP_NAME:-Email App}" -SPA_APP_DESCRIPTION="${THUNDER_SPA_APP_DESCRIPTION:-Application for email client to use OAuth2 authentication}" -SPA_CLIENT_ID="${THUNDER_SPA_CLIENT_ID:-EMAIL_APP}" -SPA_ALLOWED_USER_TYPE="${THUNDER_SPA_ALLOWED_USER_TYPE:-Person}" -SPA_OU_HANDLE="${THUNDER_SPA_OU_HANDLE:-default}" - -log_info "Creating single-page application resource..." +log_info "No sample resources to create. Skipping application provisioning." echo "" - -# ============================================================================ -# Helpers -# ============================================================================ - -extract_json_value() { - local JSON_STRING="$1" - local KEY="$2" - - echo "$JSON_STRING" | grep -o "\"${KEY}\":\"[^\"]*\"" | head -1 | cut -d'"' -f4 -} - -get_ou_id_by_handle() { - local OU_HANDLE="$1" - local RESPONSE HTTP_CODE BODY OU_ID - - RESPONSE=$(thunder_api_call GET "/organization-units/tree/${OU_HANDLE}") - HTTP_CODE="${RESPONSE: -3}" - BODY="${RESPONSE%???}" - - if [[ "$HTTP_CODE" != "200" ]]; then - log_error "Failed to resolve OU '${OU_HANDLE}' (HTTP $HTTP_CODE)" - echo "Response: $BODY" - return 1 - fi - - OU_ID=$(extract_json_value "$BODY" "id") - if [[ -z "$OU_ID" ]]; then - log_error "Could not extract OU ID for handle '${OU_HANDLE}'" - return 1 - fi - - echo "$OU_ID" -} - -get_first_flow_id() { - local FLOW_TYPE="$1" - local RESPONSE HTTP_CODE BODY FLOW_ID - - RESPONSE=$(thunder_api_call GET "/flows?flowType=${FLOW_TYPE}&limit=1") - HTTP_CODE="${RESPONSE: -3}" - BODY="${RESPONSE%???}" - - if [[ "$HTTP_CODE" != "200" ]]; then - log_error "Failed to fetch ${FLOW_TYPE} flows (HTTP $HTTP_CODE)" - echo "Response: $BODY" - return 1 - fi - - FLOW_ID=$(extract_json_value "$BODY" "id") - if [[ -z "$FLOW_ID" ]]; then - log_error "No ${FLOW_TYPE} flow found. Run default resource bootstrap first." - return 1 - fi - - echo "$FLOW_ID" -} - -create_spa_application() { - local APP_NAME="$1" - local APP_DESCRIPTION="$2" - local CLIENT_ID="$3" - local ALLOWED_USER_TYPE="$4" - local RESPONSE HTTP_CODE BODY - local APP_ID APP_CLIENT_ID - local APP_OU_ID AUTH_FLOW_ID REG_FLOW_ID - - log_info "Creating ${APP_NAME} application..." - - APP_OU_ID=$(get_ou_id_by_handle "$SPA_OU_HANDLE") || exit 1 - AUTH_FLOW_ID=$(get_first_flow_id "AUTHENTICATION") || exit 1 - REG_FLOW_ID=$(get_first_flow_id "REGISTRATION") || exit 1 - - read -r -d '' APP_PAYLOAD < Date: Sat, 25 Apr 2026 21:55:19 +0530 Subject: [PATCH 12/18] feat: upgrade server to enforce HTTPS and update related configurations Co-authored-by: Copilot --- api-server/Dockerfile | 7 +++---- api-server/Makefile | 11 +---------- api-server/README.md | 8 +------- api-server/entrypoint.sh | 11 ++++++----- api-server/internal/api/app.go | 1 - api-server/internal/api/routes.go | 9 ++------- api-server/internal/api/server.go | 15 +++++---------- api-server/main.go | 7 +------ 8 files changed, 19 insertions(+), 50 deletions(-) diff --git a/api-server/Dockerfile b/api-server/Dockerfile index 77441e1..5f673f2 100644 --- a/api-server/Dockerfile +++ b/api-server/Dockerfile @@ -52,12 +52,11 @@ RUN chown -R appuser:appuser /app # Since we mount them read-only (:ro), this is acceptable # For production, consider using Docker secrets or adjusting host file permissions -# Expose ports -# 8080 for HTTP, 8443 for HTTPS -EXPOSE 8080 8443 +# Expose HTTPS port +EXPOSE 8443 # Set environment variables (can be overridden) -ENV PORT=8080 +ENV PORT=8443 ENV CERT_FILE="" ENV KEY_FILE="" diff --git a/api-server/Makefile b/api-server/Makefile index fe6ee8d..357f4d6 100644 --- a/api-server/Makefile +++ b/api-server/Makefile @@ -2,15 +2,9 @@ DOMAIN ?= yourdomain.com CERT_DIR ?= $(PWD)/../mail-infra/services/silver-config/certbot/keys/etc -.PHONY: run build clean run-https docker-build docker-run docker-run-https docker-stop docker-clean +.PHONY: run build clean docker-build docker-run-https docker-stop docker-clean run: - go run . - -dev: - go run . -dev - -run-https: go run . -port 8443 -cert $(CERT_DIR)/live/$(DOMAIN)/fullchain.pem -key $(CERT_DIR)/live/$(DOMAIN)/privkey.pem build: @@ -23,9 +17,6 @@ clean: docker-build: docker build -t pingmailer-api . -docker-run: - docker run -d -p 8080:8080 --name pingmailer-api pingmailer-api - docker-run-https: docker run -d \ -p 8443:8443 \ diff --git a/api-server/README.md b/api-server/README.md index 2d4e4bf..a575544 100644 --- a/api-server/README.md +++ b/api-server/README.md @@ -151,12 +151,9 @@ curl -X POST https://your-domain:8443/notify \ ### Running Locally ```bash -# Run with HTTP (default port 8080) +# Run with HTTPS (requires valid cert/key files) make run -# Run with HTTPS -make run-https DOMAIN=yourdomain.com - # Build binary make build ``` @@ -167,9 +164,6 @@ make build # Build image make docker-build -# Run with HTTP -make docker-run - # Run with HTTPS make docker-run-https DOMAIN=yourdomain.com diff --git a/api-server/entrypoint.sh b/api-server/entrypoint.sh index 0176104..d799e6a 100644 --- a/api-server/entrypoint.sh +++ b/api-server/entrypoint.sh @@ -1,11 +1,12 @@ #!/bin/sh set -e +if [ -z "${CERT_FILE}" ] || [ -z "${KEY_FILE}" ]; then + echo "ERROR: HTTPS is required. Set CERT_FILE and KEY_FILE." >&2 + exit 1 +fi + # Use positional parameters to safely build the command. This avoids issues with # word splitting if variables contain spaces or special characters. -set -- ./api-server -port "${PORT}" -# Add TLS configuration if provided -if [ -n "${CERT_FILE}" ] && [ -n "${KEY_FILE}" ]; then - set -- "$@" -cert "${CERT_FILE}" -key "${KEY_FILE}" -fi +set -- ./api-server -port "${PORT}" -cert "${CERT_FILE}" -key "${KEY_FILE}" exec "$@" diff --git a/api-server/internal/api/app.go b/api-server/internal/api/app.go index ff8805b..6598b39 100644 --- a/api-server/internal/api/app.go +++ b/api-server/internal/api/app.go @@ -13,7 +13,6 @@ type Config struct { CertFile string KeyFile string OAuth2 OAuth2Config - Dev bool } // OAuth2Config holds the OAuth2 server configuration. diff --git a/api-server/internal/api/routes.go b/api-server/internal/api/routes.go index 43ede39..e9ef2b7 100644 --- a/api-server/internal/api/routes.go +++ b/api-server/internal/api/routes.go @@ -5,13 +5,8 @@ import "net/http" func (app *App) routes() http.Handler { mux := http.NewServeMux() - if app.config.Dev { - // Development mode: bypass auth middleware. - mux.HandleFunc("/notify", app.handleNotify) - } else { - // Protected endpoint - requires Bearer token authentication. - mux.HandleFunc("/notify", app.authMiddleware(app.handleNotify)) - } + // Protected endpoint - requires Bearer token authentication. + mux.HandleFunc("/notify", app.authMiddleware(app.handleNotify)) // Health check endpoint (unprotected) mux.HandleFunc("/healthcheck", app.handleHealthCheck) diff --git a/api-server/internal/api/server.go b/api-server/internal/api/server.go index 4ff3a4f..b24accd 100644 --- a/api-server/internal/api/server.go +++ b/api-server/internal/api/server.go @@ -39,18 +39,13 @@ func (app *App) Serve() error { shutdownError <- err }() - var err error - if app.config.CertFile != "" || app.config.KeyFile != "" { - if app.config.CertFile == "" || app.config.KeyFile == "" { - return errors.New("for HTTPS, both certificate and key files must be provided") - } - app.logger.Info("starting HTTPS server", "addr", srv.Addr, "version", app.config.Version, "cert", app.config.CertFile, "key", app.config.KeyFile) - err = srv.ListenAndServeTLS(app.config.CertFile, app.config.KeyFile) - } else { - app.logger.Info("starting HTTP server", "addr", srv.Addr, "version", app.config.Version) - err = srv.ListenAndServe() + if app.config.CertFile == "" || app.config.KeyFile == "" { + return errors.New("HTTPS is required: both certificate and key files must be provided") } + app.logger.Info("starting HTTPS server", "addr", srv.Addr, "version", app.config.Version, "cert", app.config.CertFile, "key", app.config.KeyFile) + err := srv.ListenAndServeTLS(app.config.CertFile, app.config.KeyFile) + if !errors.Is(err, http.ErrServerClosed) { return err } diff --git a/api-server/main.go b/api-server/main.go index d0f157e..adbfe81 100644 --- a/api-server/main.go +++ b/api-server/main.go @@ -12,8 +12,7 @@ import ( func main() { var cfg api.Config - flag.IntVar(&cfg.Port, "port", 8080, "API server port") - flag.BoolVar(&cfg.Dev, "dev", false, "Development mode: bypass OAuth token validation") + flag.IntVar(&cfg.Port, "port", 8443, "API server HTTPS port") flag.StringVar(&cfg.Version, "version", "0.1.0", "Version") flag.StringVar(&cfg.CertFile, "cert", "", "Path to TLS certificate file (e.g., /path/to/fullchain.pem)") flag.StringVar(&cfg.KeyFile, "key", "", "Path to TLS key file (e.g., /path/to/privkey.pem)") @@ -22,10 +21,6 @@ func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - if cfg.Dev { - logger.Warn("dev mode enabled: authorization middleware is bypassed") - } - // Keep a shared HTTP client for future integrations. httpClient := &http.Client{ Timeout: 10 * time.Second, From ef0998b7f095237efead41abeeab4dae46376ed6 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Sat, 25 Apr 2026 22:07:35 +0530 Subject: [PATCH 13/18] feat: update email recipient handling to support multiple recipients Co-authored-by: Copilot --- README.md | 7 ++- api-server/README.md | 22 +++++-- api-server/internal/api/handlers.go | 94 +++++++++++++++++++++++------ 3 files changed, 98 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1d3df6d..87916c1 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,12 @@ "smtp_port": 587, "smtp_username": "your_smtp_username", "smtp_sender": "sender@example.com", - "recipient_email": "user@example.com" + "recipients": [ + { + "email": "user@example.com", + "name": "User" + } + ] }' ``` diff --git a/api-server/README.md b/api-server/README.md index a575544..161eff9 100644 --- a/api-server/README.md +++ b/api-server/README.md @@ -40,8 +40,16 @@ curl -X POST https://your-domain:8443/notify \ "smtp_port": 587, "smtp_username": "user@yourdomain.com", "smtp_sender": "noreply@yourdomain.com", - "recipient_email": "recipient@example.com", - "recipient_name": "John Doe", + "recipients": [ + { + "email": "recipient1@example.com", + "name": "John Doe" + }, + { + "email": "recipient2@example.com", + "name": "Jane Doe" + } + ], "app_name": "MyApp" }' ``` @@ -54,7 +62,7 @@ curl -X POST https://your-domain:8443/notify \ | `smtp_port` | integer | SMTP server port (587 for TLS) | | `smtp_username` | string | SMTP authentication username | | `smtp_sender` | string | Sender email address | -| `recipient_email` | string | Recipient's email address | +| `recipients` or `recipient_email` | array or string | Recipient list or a single recipient email (legacy) | The request must include an `Authorization: Bearer ` header. The API forwards this token to SMTP using XOAUTH2 and does not validate it locally. @@ -64,6 +72,7 @@ API forwards this token to SMTP using XOAUTH2 and does not validate it locally. | Field | Type | Default | Description | |-------|------|---------|-------------| | `recipient_name` | string | "User" | Recipient's name | +| `recipients` | array | - | List of recipients: `{ "email": "...", "name": "..." }` | | `app_name` | string | "Application" | Application name for email template | | `template` | string | - | Custom email template | | `template_data` | object | - | Data for custom template | @@ -106,7 +115,12 @@ curl -X POST https://your-domain:8443/notify \ "smtp_port": 587, "smtp_username": "user@domain.com", "smtp_sender": "noreply@domain.com", - "recipient_email": "user@example.com", + "recipients": [ + { + "email": "user@example.com", + "name": "John" + } + ], "template": "{{define \"subject\"}}Password Reset{{end}}{{define \"plainBody\"}}Hi {{.Name}}, your code is {{.Code}}{{end}}{{define \"htmlBody\"}}

Hi {{.Name}}, your code is {{.Code}}

{{end}}", "template_data": { "Name": "John", diff --git a/api-server/internal/api/handlers.go b/api-server/internal/api/handlers.go index 963c65f..a04bfff 100644 --- a/api-server/internal/api/handlers.go +++ b/api-server/internal/api/handlers.go @@ -2,8 +2,11 @@ package api import ( "encoding/json" + "errors" + "fmt" "net/http" "net/mail" + "strconv" "github.com/Silver-Mail-Platform/pingmailer/internal/emailer" ) @@ -26,11 +29,17 @@ type user struct { APP string } +type recipient struct { + Name string `json:"name"` + Email string `json:"email"` +} + type notifyRequest struct { SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` SMTPUsername string `json:"smtp_username"` SMTPSender string `json:"smtp_sender"` + Recipients []recipient `json:"recipients,omitempty"` RecipientName string `json:"recipient_name"` RecipientEmail string `json:"recipient_email"` AppName string `json:"app_name"` @@ -66,13 +75,13 @@ func (app *App) handleNotify(w http.ResponseWriter, r *http.Request) { // Create a new mailer instance with the provided SMTP configuration mailer := emailer.NewMailer(req.SMTPHost, req.SMTPPort, req.SMTPUsername, req.SMTPSender, accessToken) - defaultUser := buildDefaultUser(req) + recipients := buildRecipients(req) app.background(func() { - if err := sendNotifyEmail(mailer, req, defaultUser); err != nil { + if err := sendNotifyEmail(mailer, req, recipients); err != nil { app.logger.Error("failed to send email", "error", err) } else { - app.logger.Info("email sent successfully", "recipient", req.RecipientEmail) + app.logger.Info("email sent successfully", "recipient_count", len(recipients)) } }) @@ -113,7 +122,7 @@ func validateNotifyRequest(w http.ResponseWriter, req notifyRequest) bool { if !required(req.SMTPSender != "", "Missing required field: smtp_sender") { return false } - if !required(req.RecipientEmail != "", "Missing required field: recipient_email") { + if !required(len(req.Recipients) > 0 || req.RecipientEmail != "", "Missing required recipient field: recipient_email or recipients") { return false } @@ -121,9 +130,22 @@ func validateNotifyRequest(w http.ResponseWriter, req notifyRequest) bool { http.Error(w, "Invalid smtp_sender email format: "+err.Error(), http.StatusBadRequest) return false } - if _, err := mail.ParseAddress(req.RecipientEmail); err != nil { - http.Error(w, "Invalid recipient_email format: "+err.Error(), http.StatusBadRequest) - return false + if req.RecipientEmail != "" { + if _, err := mail.ParseAddress(req.RecipientEmail); err != nil { + http.Error(w, "Invalid recipient_email format: "+err.Error(), http.StatusBadRequest) + return false + } + } + + for i, rcp := range req.Recipients { + if rcp.Email == "" { + http.Error(w, "Missing recipients["+strconv.Itoa(i)+"].email", http.StatusBadRequest) + return false + } + if _, err := mail.ParseAddress(rcp.Email); err != nil { + http.Error(w, "Invalid recipients["+strconv.Itoa(i)+"].email format: "+err.Error(), http.StatusBadRequest) + return false + } } return true @@ -136,24 +158,56 @@ func applyNotifyDefaults(req *notifyRequest) { if req.AppName == "" { req.AppName = "Application" } -} -func buildDefaultUser(req notifyRequest) user { - return user{ - Name: req.RecipientName, - Email: req.RecipientEmail, - APP: req.AppName, + for i := range req.Recipients { + if req.Recipients[i].Name == "" { + req.Recipients[i].Name = "User" + } } } -func sendNotifyEmail(mailer emailer.Mailer, req notifyRequest, defaultUser user) error { - if req.Template == "" { - return mailer.Send(defaultUser.Email, "welcome.tmpl", defaultUser) +func buildRecipients(req notifyRequest) []user { + recipients := make([]user, 0, len(req.Recipients)+1) + + if req.RecipientEmail != "" { + recipients = append(recipients, user{ + Name: req.RecipientName, + Email: req.RecipientEmail, + APP: req.AppName, + }) } - templateData := any(defaultUser) - if len(req.TemplateData) > 0 { - templateData = req.TemplateData + for _, rcp := range req.Recipients { + recipients = append(recipients, user{ + Name: rcp.Name, + Email: rcp.Email, + APP: req.AppName, + }) } - return mailer.SendWithCustomTemplate(req.RecipientEmail, req.Template, templateData) + + return recipients +} + +func sendNotifyEmail(mailer emailer.Mailer, req notifyRequest, recipients []user) error { + var sendErrors []error + + for _, recipient := range recipients { + if req.Template == "" { + if err := mailer.Send(recipient.Email, "welcome.tmpl", recipient); err != nil { + sendErrors = append(sendErrors, fmt.Errorf("recipient %s: %w", recipient.Email, err)) + } + continue + } + + templateData := any(recipient) + if len(req.TemplateData) > 0 { + templateData = req.TemplateData + } + + if err := mailer.SendWithCustomTemplate(recipient.Email, req.Template, templateData); err != nil { + sendErrors = append(sendErrors, fmt.Errorf("recipient %s: %w", recipient.Email, err)) + } + } + + return errors.Join(sendErrors...) } From 01af8a5946ff4b102254de2845a376c47dafa0df Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Sun, 24 May 2026 00:06:13 +0530 Subject: [PATCH 14/18] feat: add initial Helm chart for silver-smtp with configuration and deployment templates --- mail-infra/helm/smtp-server/.helmignore | 9 ++ mail-infra/helm/smtp-server/Chart.yaml | 21 +++ mail-infra/helm/smtp-server/README.md | 126 +++++++++++++++ .../helm/smtp-server/templates/NOTES.txt | 59 +++++++ .../helm/smtp-server/templates/_helpers.tpl | 65 ++++++++ .../helm/smtp-server/templates/configmap.yaml | 130 +++++++++++++++ .../smtp-server/templates/deployment.yaml | 139 ++++++++++++++++ .../helm/smtp-server/templates/pvc.yaml | 17 ++ .../helm/smtp-server/templates/service.yaml | 28 ++++ .../smtp-server/templates/serviceaccount.yaml | 12 ++ mail-infra/helm/smtp-server/values.yaml | 149 ++++++++++++++++++ 11 files changed, 755 insertions(+) create mode 100644 mail-infra/helm/smtp-server/.helmignore create mode 100644 mail-infra/helm/smtp-server/Chart.yaml create mode 100644 mail-infra/helm/smtp-server/README.md create mode 100644 mail-infra/helm/smtp-server/templates/NOTES.txt create mode 100644 mail-infra/helm/smtp-server/templates/_helpers.tpl create mode 100644 mail-infra/helm/smtp-server/templates/configmap.yaml create mode 100644 mail-infra/helm/smtp-server/templates/deployment.yaml create mode 100644 mail-infra/helm/smtp-server/templates/pvc.yaml create mode 100644 mail-infra/helm/smtp-server/templates/service.yaml create mode 100644 mail-infra/helm/smtp-server/templates/serviceaccount.yaml create mode 100644 mail-infra/helm/smtp-server/values.yaml diff --git a/mail-infra/helm/smtp-server/.helmignore b/mail-infra/helm/smtp-server/.helmignore new file mode 100644 index 0000000..22432d1 --- /dev/null +++ b/mail-infra/helm/smtp-server/.helmignore @@ -0,0 +1,9 @@ +.DS_Store +.git/ +.gitignore +.idea/ +.vscode/ +*.tmproj +*.swp +*.bak +*.tgz diff --git a/mail-infra/helm/smtp-server/Chart.yaml b/mail-infra/helm/smtp-server/Chart.yaml new file mode 100644 index 0000000..4b19901 --- /dev/null +++ b/mail-infra/helm/smtp-server/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: silver-smtp +description: | + Helm chart for the Postfix-based SMTP server (ghcr.io/lsflk/silver-smtp) + used by the Pingmailer / Silver Mail stack. Renders main.cf / master.cf / + recipient_access from values, wires Postfix to opendkim-server, + raven-sasl, and (optionally) rspamd-server, mounts TLS certs from a + cert-manager Secret, and persists the mail queue on a PVC. +type: application +version: 0.1.0 +appVersion: "main" +keywords: + - postfix + - smtp + - email + - pingmailer +maintainers: + - name: LSFLK + url: https://github.com/lsflk +sources: + - https://github.com/LSFLK/silver diff --git a/mail-infra/helm/smtp-server/README.md b/mail-infra/helm/smtp-server/README.md new file mode 100644 index 0000000..81e5906 --- /dev/null +++ b/mail-infra/helm/smtp-server/README.md @@ -0,0 +1,126 @@ +# silver-smtp Helm chart + +Helm chart for the Postfix-based SMTP server (`ghcr.io/lsflk/silver-smtp`) — +the Kubernetes counterpart of the `smtp-server` block in this repo's +[docker-compose.yml](../../../docker-compose.yml). + +This is the third of four Pingmailer mail-plane charts. Install order: + +1. [silver-certificates](../certbot-server/) — issues TLS certs via cert-manager. +2. [silver-opendkim](../opendkim-server/) — DKIM milter on `:8891`. +3. [silver-raven-sasl](../raven-sasl-server/) — SASL auth daemon on `:12345`. +4. **silver-smtp** (this chart) — Postfix on `:25` (+ optional submission `:587`). + +## What this chart deploys + +- A `ConfigMap` that templates `main.cf`, `master.cf`, `silver.yaml`, and + `recipient_access` from your values. The docker-compose flow renders + these via a host-side `gen-postfix-conf.sh`; the chart does it in Helm. +- A `Deployment` whose **init container**: + - seeds an `emptyDir` with the image's stock `/etc/postfix/` contents + (`postfix-script`, `postfix-files.d`, `dynamicmaps.cf.d`, …), + - overlays the four rendered configs, + - runs `postmap recipient_access` so Postfix can read the compiled + lookup table (this is the step the docker-compose flow does *after* + container start via `docker exec` — we move it pre-start where it + belongs). +- A main container that runs the stock image entrypoint (`service postfix + start && sleep infinity`). +- A `Service` on port 25 (SMTP). Port 587 (submission, STARTTLS-enforced) + is added when `postfix.submission.enabled=true` (the default). +- A `PersistentVolumeClaim` for `/var/spool/postfix` — the mail queue + must survive pod restarts so in-flight messages aren't dropped. +- An optional TLS `Secret` mount: when `tlsSecret.name` is set, the chart + remaps a cert-manager-managed Secret's `tls.crt` / `tls.key` keys to + `fullchain.pem` / `privkey.pem` and mounts them at + `/etc/letsencrypt/live//` — exactly where Postfix's `main.cf` + expects to find them. + +What this chart **does not** do vs. docker-compose: + +- `/var/log` is not a persistent volume. Logs are redirected to + `/dev/stdout` (`maillog_file = /dev/stdout` in `main.cf`) so + `kubectl logs` works. +- The "shared SQLite database at `/app/data/databases/shared.db`" referenced + in the image's entrypoint is not actually shared between containers in + the original docker-compose either (no volume mount on the `smtp-server` + service), so the chart does not provision one. Mail flow does not depend + on it; the entrypoint simply warns. + +## Install + +Sensitive values stay out of `values.yaml`: + +```yaml +# my-smtp-values.yaml +domain: mail.example.com +tlsSecret: + name: mail-example-com-tls # produced by the silver-certificates chart +``` + +```bash +helm upgrade --install smtp-server ./mail-infra/helm/smtp-server \ + --namespace pingmailer --create-namespace \ + -f my-smtp-values.yaml +``` + +The chart `fail`s with a clear message if `domain` is missing, or if +`postfix.submission.enabled=true` but no `tlsSecret.name` is provided (port +587 enforces STARTTLS — running submission without certs would be broken). + +## Exposing to the internet + +This chart defaults to **`service.type: ClusterIP`** because: + +- LoadBalancers may incur cloud costs / require a provider. +- Mail can be fronted by an external TCP LB or HAProxy you already manage. +- ClusterIP installs cleanly in any cluster without extra requirements. + +To expose externally: + +```yaml +service: + type: LoadBalancer + loadBalancerIP: 203.0.113.5 # optional, depends on cloud provider + annotations: + # e.g. for GKE / EKS / AKS L4 LB annotations +``` + +Then publish DNS: + +``` +mail.example.com. A 203.0.113.5 +example.com. MX 10 mail.example.com. +``` + +Outbound port 25 is blocked by most cloud providers and ISPs by default — +plan to use a relay (Mailgun, SES, SendGrid) for outbound, or open a +support case to unblock 25. + +## Values + +| Key | Default | Notes | +|----------------------------------------|-------------------------------------------|--------------------------------------------------------| +| `domain` | `""` (**required**) | Primary mail domain | +| `hostname` | `""` → `mail.` | `myhostname` in `main.cf` | +| `tlsSecret.name` | `""` (required when submission enabled) | cert-manager Secret name | +| `tlsSecret.alreadyRenamed` | `false` | `true` if Secret already uses fullchain.pem/privkey.pem | +| `postfix.submission.enabled` | `true` | Enables `:587` STARTTLS submission | +| `postfix.milters` | `["inet:opendkim-server:8891"]` | Inbound smtpd milters | +| `postfix.submissionMilters` | `["inet:opendkim-server:8891"]` | Submission milters (add rspamd here when deployed) | +| `postfix.sasl.path` | `inet:raven-sasl:12345` | Where Postfix reaches the SASL daemon | +| `postfix.tls.securityLevel` | `may` | `may` opportunistic / `encrypt` required | +| `postfix.recipientAccess.rejectAtDomain` | `true` | Mirrors docker-compose: REJECT mail to `@` | +| `postfix.maillogFile` | `/dev/stdout` | Set to `/var/log/mail.log` for legacy file logging | +| `persistence.spool.size` | `1Gi` | Postfix queue | +| `replicaCount` | `1` (**do not change**) | Singleton — PVC is RWO and the queue is single-writer | +| `service.type` | `ClusterIP` | Switch to `LoadBalancer` to expose externally | + +## Uninstall + +```bash +helm uninstall smtp-server -n pingmailer +``` + +The spool PVC is preserved (in-flight mail). Delete it explicitly to +discard queued messages. diff --git a/mail-infra/helm/smtp-server/templates/NOTES.txt b/mail-infra/helm/smtp-server/templates/NOTES.txt new file mode 100644 index 0000000..dfd791c --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/NOTES.txt @@ -0,0 +1,59 @@ +silver-smtp has been installed. + +Release: {{ .Release.Name }} +Namespace: {{ .Release.Namespace }} +Hostname: {{ include "silver-smtp.hostname" . }} +Domain: {{ .Values.domain }} + +Service: {{ include "silver-smtp.fullname" . }} + SMTP: :{{ .Values.service.smtpPort }} + {{- if .Values.postfix.submission.enabled }} + Submission: :{{ .Values.service.submissionPort }} + {{- end }} + +----- 1. Check the pod is up ----- + + kubectl -n {{ .Release.Namespace }} logs -f deploy/{{ include "silver-smtp.fullname" . }} + kubectl -n {{ .Release.Namespace }} get pod -l app.kubernetes.io/instance={{ .Release.Name }} + +----- 2. Service exposure ----- + +Type: {{ .Values.service.type }} +{{- if eq .Values.service.type "ClusterIP" }} + + ClusterIP only — reachable from inside the cluster but NOT from the + public internet. To accept external mail, either: + a) Switch service.type to LoadBalancer (if your cloud provides L4 LB), OR + b) Front this ClusterIP with an external TCP load balancer / firewall NAT. + +{{- else if eq .Values.service.type "LoadBalancer" }} + + Watch the LoadBalancer get its address: + kubectl -n {{ .Release.Namespace }} get svc {{ include "silver-smtp.fullname" . }} -w + + Then publish DNS: + MX {{ .Values.domain }}. 10 {{ include "silver-smtp.hostname" . }}. + A {{ include "silver-smtp.hostname" . }}. +{{- end }} + +----- 3. Dependencies (charts you should already have installed) ----- + + silver-opendkim (Service: opendkim-server:8891) + silver-raven-sasl (Service: raven-sasl:12345) + silver-certificates (TLS Secret: {{ .Values.tlsSecret.name | default "(not set)" }}) + +----- 4. Mail queue ----- + +The Postfix mail queue lives on the PVC `{{ include "silver-smtp.spoolClaim" . }}` +({{ .Values.persistence.spool.size }}). Survives `helm uninstall`; delete +explicitly to discard in-flight mail: + + kubectl -n {{ .Release.Namespace }} delete pvc {{ include "silver-smtp.spoolClaim" . }} + +----- 5. Smoke-test SMTP ----- + + kubectl -n {{ .Release.Namespace }} run -it --rm tmp --image=busybox --restart=Never -- \ + sh -c 'nc -v {{ include "silver-smtp.fullname" . }} {{ .Values.service.smtpPort }} <`. +*/}} +{{- define "silver-smtp.hostname" -}} +{{- if .Values.hostname -}} +{{ .Values.hostname }} +{{- else -}} +mail.{{ required "domain is required" .Values.domain }} +{{- end -}} +{{- end }} + +{{/* +PVC claim name for the postfix spool. +*/}} +{{- define "silver-smtp.spoolClaim" -}} +{{- if .Values.persistence.spool.existingClaim }} +{{- .Values.persistence.spool.existingClaim }} +{{- else }} +{{- printf "%s-spool" (include "silver-smtp.fullname" .) }} +{{- end }} +{{- end }} diff --git a/mail-infra/helm/smtp-server/templates/configmap.yaml b/mail-infra/helm/smtp-server/templates/configmap.yaml new file mode 100644 index 0000000..4808001 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/configmap.yaml @@ -0,0 +1,130 @@ +{{- if not .Values.domain }} +{{- fail "domain is required — pass via -f my-values.yaml or --set domain=example.com" }} +{{- end }} +{{- if and .Values.postfix.submission.enabled (not .Values.tlsSecret.name) }} +{{- fail "tlsSecret.name is required when postfix.submission.enabled=true (submission/587 enforces STARTTLS). Point this at a cert-manager Secret produced by the silver-certificates chart." }} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "silver-smtp.fullname" . }}-conf + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} +data: + silver.yaml: | + domains: + - domain: {{ .Values.domain }} + + main.cf: | + # =========================================================== + # main.cf — rendered by the silver-smtp Helm chart + # =========================================================== + + # --- Identity --- + myhostname = {{ include "silver-smtp.hostname" . }} + mydomain = {{ .Values.domain }} + myorigin = $mydomain + mydestination = localhost.localdomain, localhost + mynetworks = {{ join " " .Values.postfix.myNetworks }} + inet_interfaces = all + inet_protocols = ipv4 + + # --- Logging --- + maillog_file = {{ .Values.postfix.maillogFile }} + + # --- TLS --- + smtpd_tls_cert_file = /etc/letsencrypt/live/{{ .Values.domain }}/fullchain.pem + smtpd_tls_key_file = /etc/letsencrypt/live/{{ .Values.domain }}/privkey.pem + smtpd_tls_CAfile = /etc/ssl/certs/ca-certificates.crt + smtpd_tls_security_level = {{ .Values.postfix.tls.securityLevel }} + smtpd_tls_protocols = {{ .Values.postfix.tls.protocols }} + smtpd_tls_mandatory_protocols = {{ .Values.postfix.tls.mandatoryProtocols }} + smtpd_tls_ciphers = {{ .Values.postfix.tls.ciphers }} + smtpd_tls_mandatory_ciphers = {{ .Values.postfix.tls.mandatoryCiphers }} + smtpd_tls_exclude_ciphers = {{ .Values.postfix.tls.excludeCiphers }} + smtpd_tls_loglevel = 1 + smtpd_tls_received_header = yes + + # --- SASL --- + smtpd_sasl_type = {{ .Values.postfix.sasl.type }} + smtpd_sasl_path = {{ .Values.postfix.sasl.path }} + smtpd_sasl_auth_enable = yes + broken_sasl_auth_clients = yes + smtpd_sasl_security_options = noanonymous + + # --- Milters --- + smtpd_milters = {{ join " " .Values.postfix.milters }} + non_smtpd_milters = {{ join " " .Values.postfix.milters }} + milter_protocol = {{ .Values.postfix.milterProtocol }} + milter_default_action = {{ .Values.postfix.milterDefaultAction }} + + # --- Restrictions --- + smtpd_relay_restrictions = + permit_mynetworks + permit_sasl_authenticated + defer_unauth_destination + + smtpd_recipient_restrictions = + permit_sasl_authenticated + {{- if .Values.postfix.recipientAccess.rejectAtDomain }} + check_recipient_access hash:/etc/postfix/recipient_access + {{- end }} + reject + + # --- Rate limits --- + smtpd_client_connection_rate_limit = {{ .Values.postfix.rateLimits.connectionPerClient }} + smtpd_client_message_rate_limit = {{ .Values.postfix.rateLimits.messagePerClient }} + smtpd_client_recipient_rate_limit = {{ .Values.postfix.rateLimits.recipientPerClient }} + smtpd_client_connection_count_limit = {{ .Values.postfix.rateLimits.connectionCount }} + + {{- range .Values.postfix.extraMainCf }} + {{ . }} + {{- end }} + + master.cf: | + # =========================================================== + # master.cf — rendered by the silver-smtp Helm chart + # =========================================================== + smtp inet n - y - - smtpd + pickup unix n - y 60 1 pickup + cleanup unix n - y - 0 cleanup + qmgr unix n - n 300 1 qmgr + tlsmgr unix - - y 1000? 1 tlsmgr + rewrite unix - - y - - trivial-rewrite + bounce unix - - y - 0 bounce + defer unix - - y - 0 bounce + trace unix - - y - 0 bounce + verify unix - - y - 1 verify + flush unix n - y 1000? 0 flush + proxymap unix - - n - - proxymap + proxywrite unix - - n - 1 proxymap + smtp unix - - y - - smtp + relay unix - - y - - smtp + showq unix n - y - - showq + error unix - - y - - error + retry unix - - y - - error + discard unix - - y - - discard + local unix - n n - - local + virtual unix - n n - - virtual + lmtp unix - - y - - lmtp + anvil unix - - y - 1 anvil + scache unix - - y - 1 scache + postlog unix-dgram n - n - 1 postlogd + {{- if .Values.postfix.submission.enabled }} + + submission inet n - y - - smtpd + -o syslog_name=postfix/submission + -o smtpd_tls_security_level=encrypt + -o smtpd_sasl_auth_enable=yes + -o smtpd_tls_auth_only=yes + -o smtpd_relay_restrictions=permit_sasl_authenticated,reject + -o smtpd_milters={{ join "," .Values.postfix.submissionMilters }} + {{- end }} + + recipient_access: | + # Recipient access table — Helm-rendered. + # `postmap` runs in the init container to compile this into recipient_access.db. + {{- if .Values.postfix.recipientAccess.rejectAtDomain }} + @{{ .Values.domain }} REJECT This email address does not accept incoming messages + {{- end }} diff --git a/mail-infra/helm/smtp-server/templates/deployment.yaml b/mail-infra/helm/smtp-server/templates/deployment.yaml new file mode 100644 index 0000000..a0809f7 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/deployment.yaml @@ -0,0 +1,139 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "silver-smtp.fullname" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate # Postfix queue PVC is RWO — avoid rolling. + selector: + matchLabels: + {{- include "silver-smtp.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "silver-smtp.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "silver-smtp.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + # Postfix needs /etc/postfix to be WRITABLE because `postmap` compiles + # recipient_access into recipient_access.db next to the source file. + # ConfigMap subPath mounts are read-only, so we: + # 1. Seed an emptyDir with the image's stock /etc/postfix contents + # (postfix-script, postfix-files.d, dynamicmaps.cf.d, …). + # 2. Overlay our four rendered configs. + # 3. Run `postmap` to build the lookup table. + - name: prepare-postfix + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /bin/sh + - -c + - | + set -eu + cp -a /etc/postfix/. /shared-postfix/ + cp /configs/main.cf /shared-postfix/main.cf + cp /configs/master.cf /shared-postfix/master.cf + cp /configs/silver.yaml /shared-postfix/silver.yaml + cp /configs/recipient_access /shared-postfix/recipient_access + postmap /shared-postfix/recipient_access + echo "✓ /etc/postfix prepared, recipient_access compiled." + volumeMounts: + - name: postfix-etc + mountPath: /shared-postfix + - name: configs + mountPath: /configs + readOnly: true + containers: + - name: smtp + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: smtp + containerPort: 25 + protocol: TCP + {{- if .Values.postfix.submission.enabled }} + - name: submission + containerPort: 587 + protocol: TCP + {{- end }} + volumeMounts: + - name: postfix-etc + mountPath: /etc/postfix + - name: spool + mountPath: /var/spool/postfix + {{- if .Values.tlsSecret.name }} + - name: tls + mountPath: /etc/letsencrypt/live/{{ .Values.domain }} + readOnly: true + {{- end }} + readinessProbe: + tcpSocket: + port: smtp + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: smtp + initialDelaySeconds: 30 + periodSeconds: 30 + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: configs + configMap: + name: {{ include "silver-smtp.fullname" . }}-conf + - name: postfix-etc + emptyDir: {} + - name: spool + {{- if .Values.persistence.spool.enabled }} + persistentVolumeClaim: + claimName: {{ include "silver-smtp.spoolClaim" . }} + {{- else }} + emptyDir: {} + {{- end }} + {{- if .Values.tlsSecret.name }} + - name: tls + secret: + secretName: {{ .Values.tlsSecret.name }} + {{- if not .Values.tlsSecret.alreadyRenamed }} + items: + - key: tls.crt + path: fullchain.pem + - key: tls.key + path: privkey.pem + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/mail-infra/helm/smtp-server/templates/pvc.yaml b/mail-infra/helm/smtp-server/templates/pvc.yaml new file mode 100644 index 0000000..f58e16f --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.spool.enabled (not .Values.persistence.spool.existingClaim) -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "silver-smtp.spoolClaim" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.spool.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.spool.size }} + {{- if .Values.persistence.spool.storageClass }} + storageClassName: {{ .Values.persistence.spool.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/mail-infra/helm/smtp-server/templates/service.yaml b/mail-infra/helm/smtp-server/templates/service.yaml new file mode 100644 index 0000000..59e9f73 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/service.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "silver-smtp.fullname" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + {{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP }} + {{- end }} + ports: + - port: {{ .Values.service.smtpPort }} + targetPort: smtp + protocol: TCP + name: smtp + {{- if .Values.postfix.submission.enabled }} + - port: {{ .Values.service.submissionPort }} + targetPort: submission + protocol: TCP + name: submission + {{- end }} + selector: + {{- include "silver-smtp.selectorLabels" . | nindent 4 }} diff --git a/mail-infra/helm/smtp-server/templates/serviceaccount.yaml b/mail-infra/helm/smtp-server/templates/serviceaccount.yaml new file mode 100644 index 0000000..5973533 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "silver-smtp.serviceAccountName" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/mail-infra/helm/smtp-server/values.yaml b/mail-infra/helm/smtp-server/values.yaml new file mode 100644 index 0000000..3018814 --- /dev/null +++ b/mail-infra/helm/smtp-server/values.yaml @@ -0,0 +1,149 @@ +# --------------------------------------------------------------------------- +# silver-smtp values +# +# Sensitive values (mail domain, TLS Secret name) are intentionally empty +# here. Supply them at install/upgrade time: +# +# helm upgrade --install smtp-server ./mail-infra/helm/smtp-server \ +# --namespace pingmailer --create-namespace \ +# -f my-smtp-values.yaml +# --------------------------------------------------------------------------- + +nameOverride: "" +# Keep the Service DNS short so other services (and external MX) can address +# this as `smtp-server:25`. +fullnameOverride: "smtp-server" + +# Postfix queue lives on a single RWO PVC — do not scale. +replicaCount: 1 + +image: + repository: ghcr.io/lsflk/silver-smtp + tag: main + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# Primary mail domain. REQUIRED — chart fail()s if empty. +domain: "" + +# Postfix `myhostname` (default: `mail.`). +hostname: "" + +# --------------------------------------------------------------------------- +# Postfix configuration knobs (rendered into main.cf / master.cf). +# --------------------------------------------------------------------------- +postfix: + # SMTP submission port 587 with enforced STARTTLS + SASL. + submission: + enabled: true + + # Networks allowed to relay without SASL. + myNetworks: + - "127.0.0.0/8" + + # Milters wired to the inbound smtpd (port 25). Address the other charts + # by their Service DNS — defaults match the rest of this repo's charts. + milters: + - "inet:opendkim-server:8891" + + # Milters wired to submission (port 587). Includes rspamd if you deploy it. + submissionMilters: + - "inet:opendkim-server:8891" + # - "inet:rspamd-server:11332" # uncomment once rspamd is deployed + + milterProtocol: 6 + milterDefaultAction: accept + + # SASL — points at silver-raven-sasl. + sasl: + type: dovecot + path: "inet:raven-sasl:12345" + + # TLS knobs (paths are derived from the mounted TLS Secret). + tls: + securityLevel: may # `may` = opportunistic; `encrypt` = required + protocols: "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" + mandatoryProtocols: "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" + ciphers: high + mandatoryCiphers: high + excludeCiphers: "aNULL, DES, 3DES, MD5, DES+MD5, RC4" + + # Per-client rate limits (per Postfix smtpd_client_*). + rateLimits: + connectionPerClient: 10 + messagePerClient: 100 + recipientPerClient: 200 + connectionCount: 20 + + # Recipient access — rejects mail addressed to `@` (this domain + # only sends; it does not receive). Mirrors the docker-compose behaviour. + # Set to false to omit the REJECT rule entirely. + recipientAccess: + rejectAtDomain: true + + # Redirect mail logs to stdout so `kubectl logs` works. + maillogFile: "/dev/stdout" + + # Extra raw lines appended to main.cf. Use sparingly. + extraMainCf: [] + # - "myorigin = $mydomain" + +# --------------------------------------------------------------------------- +# TLS certificate Secret (typically produced by the silver-certificates +# chart with keys `tls.crt` + `tls.key`). The chart remaps them to +# fullchain.pem + privkey.pem under /etc/letsencrypt/live// to +# match the paths Postfix expects. +# --------------------------------------------------------------------------- +tlsSecret: + name: "" + alreadyRenamed: false + +service: + # ClusterIP keeps the chart safe to install anywhere. Set to LoadBalancer + # (and add `loadBalancerIP` / annotations) to expose to the internet, + # or front the ClusterIP with an external L4 load balancer. + type: ClusterIP + loadBalancerIP: "" + annotations: {} + smtpPort: 25 + submissionPort: 587 + +# --------------------------------------------------------------------------- +# Postfix mail queue. Persist this — losing it means dropping in-flight mail. +# --------------------------------------------------------------------------- +persistence: + spool: + enabled: true + existingClaim: "" + storageClass: "" + accessModes: + - ReadWriteOnce + size: 1Gi + +# Postfix needs root to bind :25/:587 and to chroot smtpd children, then +# drops privs internally to the `postfix` user. +podSecurityContext: {} +securityContext: + # NET_BIND_SERVICE alone isn't enough — Postfix uses chroot which needs + # CAP_SYS_CHROOT and CAP_SETUID/CAP_SETGID. Leave defaults (root); the + # postfix master only stays root long enough to spawn workers. + allowPrivilegeEscalation: false + +resources: {} + # requests: + # cpu: 100m + # memory: 128Mi + # limits: + # cpu: 500m + # memory: 512Mi + +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} + +serviceAccount: + create: true + annotations: {} + name: "" From d1ba5a7d9f53b62a237ce4df60fb44e62cf3530c Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 4 Jun 2026 10:42:10 +0530 Subject: [PATCH 15/18] feat: implement silver-smtp Helm chart with Postfix configuration, entrypoint, and security context --- .../helm/smtp-server/templates/configmap.yaml | 44 ++++++----- .../smtp-server/templates/deployment.yaml | 4 + mail-infra/helm/smtp-server/values.yaml | 42 +++++----- mail-infra/images/silver-smtp/Dockerfile | 55 ++++++++++++++ mail-infra/images/silver-smtp/entrypoint.sh | 71 +++++++++++++++++ mail-infra/openshift/silver-smtp-scc.yaml | 76 +++++++++++++++++++ 6 files changed, 254 insertions(+), 38 deletions(-) create mode 100644 mail-infra/images/silver-smtp/Dockerfile create mode 100755 mail-infra/images/silver-smtp/entrypoint.sh create mode 100644 mail-infra/openshift/silver-smtp-scc.yaml diff --git a/mail-infra/helm/smtp-server/templates/configmap.yaml b/mail-infra/helm/smtp-server/templates/configmap.yaml index 4808001..5904bc8 100644 --- a/mail-infra/helm/smtp-server/templates/configmap.yaml +++ b/mail-infra/helm/smtp-server/templates/configmap.yaml @@ -85,35 +85,39 @@ data: master.cf: | # =========================================================== # master.cf — rendered by the silver-smtp Helm chart + # + # chroot column is "n" for every service: chroot inside a container is + # redundant, and disabling it avoids requiring CAP_SYS_CHROOT in the SCC. + # Postfix runs as root and binds the privileged ports 25/587 directly. # =========================================================== - smtp inet n - y - - smtpd - pickup unix n - y 60 1 pickup - cleanup unix n - y - 0 cleanup + smtp inet n - n - - smtpd + pickup unix n - n 60 1 pickup + cleanup unix n - n - 0 cleanup qmgr unix n - n 300 1 qmgr - tlsmgr unix - - y 1000? 1 tlsmgr - rewrite unix - - y - - trivial-rewrite - bounce unix - - y - 0 bounce - defer unix - - y - 0 bounce - trace unix - - y - 0 bounce - verify unix - - y - 1 verify - flush unix n - y 1000? 0 flush + tlsmgr unix - - n 1000? 1 tlsmgr + rewrite unix - - n - - trivial-rewrite + bounce unix - - n - 0 bounce + defer unix - - n - 0 bounce + trace unix - - n - 0 bounce + verify unix - - n - 1 verify + flush unix n - n 1000? 0 flush proxymap unix - - n - - proxymap proxywrite unix - - n - 1 proxymap - smtp unix - - y - - smtp - relay unix - - y - - smtp - showq unix n - y - - showq - error unix - - y - - error - retry unix - - y - - error - discard unix - - y - - discard + smtp unix - - n - - smtp + relay unix - - n - - smtp + showq unix n - n - - showq + error unix - - n - - error + retry unix - - n - - error + discard unix - - n - - discard local unix - n n - - local virtual unix - n n - - virtual - lmtp unix - - y - - lmtp - anvil unix - - y - 1 anvil - scache unix - - y - 1 scache + lmtp unix - - n - - lmtp + anvil unix - - n - 1 anvil + scache unix - - n - 1 scache postlog unix-dgram n - n - 1 postlogd {{- if .Values.postfix.submission.enabled }} - submission inet n - y - - smtpd + submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_tls_security_level=encrypt -o smtpd_sasl_auth_enable=yes diff --git a/mail-infra/helm/smtp-server/templates/deployment.yaml b/mail-infra/helm/smtp-server/templates/deployment.yaml index a0809f7..b27c86a 100644 --- a/mail-infra/helm/smtp-server/templates/deployment.yaml +++ b/mail-infra/helm/smtp-server/templates/deployment.yaml @@ -41,6 +41,10 @@ spec: - name: prepare-postfix image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} command: - /bin/sh - -c diff --git a/mail-infra/helm/smtp-server/values.yaml b/mail-infra/helm/smtp-server/values.yaml index 3018814..4f06dca 100644 --- a/mail-infra/helm/smtp-server/values.yaml +++ b/mail-infra/helm/smtp-server/values.yaml @@ -18,9 +18,12 @@ fullnameOverride: "smtp-server" replicaCount: 1 image: - repository: ghcr.io/lsflk/silver-smtp - tag: main - pullPolicy: IfNotPresent + # Stock (unmodified) silver-smtp rebuilt into our GHCR org from + # mail-infra/images/silver-smtp. Postfix runs as root, so the pod requires + # the dedicated `silver-smtp-scc` SCC bound to the smtp-server SA. + repository: ghcr.io/silver-mail-platform/silver-smtp + tag: latest + pullPolicy: Always imagePullSecrets: [] @@ -62,7 +65,7 @@ postfix: # TLS knobs (paths are derived from the mounted TLS Secret). tls: - securityLevel: may # `may` = opportunistic; `encrypt` = required + securityLevel: may # `may` = opportunistic; `encrypt` = required protocols: "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" mandatoryProtocols: "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" ciphers: high @@ -121,22 +124,25 @@ persistence: - ReadWriteOnce size: 1Gi -# Postfix needs root to bind :25/:587 and to chroot smtpd children, then -# drops privs internally to the `postfix` user. +# Postfix runs as the image's root (uid 0) to bind :25/:587 and spawn workers, +# then drops privileges internally to the `postfix` user. The dedicated +# silver-smtp-scc SCC permits this; nothing is pinned here so OpenShift uses the +# image's USER (root). chroot is disabled in master.cf, so no CAP_SYS_CHROOT. podSecurityContext: {} securityContext: - # NET_BIND_SERVICE alone isn't enough — Postfix uses chroot which needs - # CAP_SYS_CHROOT and CAP_SETUID/CAP_SETGID. Leave defaults (root); the - # postfix master only stays root long enough to spawn workers. - allowPrivilegeEscalation: false - -resources: {} - # requests: - # cpu: 100m - # memory: 128Mi - # limits: - # cpu: 500m - # memory: 512Mi + # Postfix master setuid-drops to the postfix user, so privilege escalation + # must be allowed. The SCC drops ALL caps and re-adds only what Postfix needs. + allowPrivilegeEscalation: true + +# Required: the nsw-infra-* tenant-quota rejects any container without CPU/memory +# requests + limits. Applied to BOTH the init and main containers. +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi nodeSelector: {} tolerations: [] diff --git a/mail-infra/images/silver-smtp/Dockerfile b/mail-infra/images/silver-smtp/Dockerfile new file mode 100644 index 0000000..9e79c74 --- /dev/null +++ b/mail-infra/images/silver-smtp/Dockerfile @@ -0,0 +1,55 @@ +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive + +# ====================================== +# Install Postfix + SQLite + Tools +# ====================================== +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + wget \ + sqlite3 \ + libsqlite3-0 \ + postfix \ + postfix-sqlite \ + mailutils \ + iputils-ping && \ + \ + # Ensure SQLite module registered in Postfix + if ! grep -q "^sqlite" /etc/postfix/dynamicmaps.cf; then \ + echo "sqlite postfix-sqlite.so dict_sqlite_open" >> /etc/postfix/dynamicmaps.cf; \ + fi && \ + \ + # Download yq (for YAML parsing in entrypoint) + wget -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" && \ + chmod +x /usr/local/bin/yq && \ + \ + # Clean up + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# ====================================== +# Prepare app structure +# ====================================== +RUN mkdir -p /app/data /etc/postfix/sqlite + +# Ensure correct permissions (Postfix needs read-only, not writable) +RUN chmod 755 /etc/postfix && chmod 644 /etc/postfix/* || true + +# ====================================== +# Copy entrypoint and set permissions +# ====================================== +COPY ./entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# ====================================== +# Set work directory +# ====================================== +WORKDIR /app + + +# ====================================== +# Entrypoint +# ====================================== +CMD ["/entrypoint.sh"] diff --git a/mail-infra/images/silver-smtp/entrypoint.sh b/mail-infra/images/silver-smtp/entrypoint.sh new file mode 100755 index 0000000..2b557a8 --- /dev/null +++ b/mail-infra/images/silver-smtp/entrypoint.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -e + +CONFIG_FILE="/etc/postfix/silver.yaml" + +# Extract primary (first) domain from the domains list using awk +MAIL_DOMAIN=$(awk '/^[[:space:]]*-[[:space:]]*domain:/ {sub(/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*/, ""); print; exit}' "$CONFIG_FILE") + +# Fallback if extraction failed +if [ -z "$MAIL_DOMAIN" ] || [ "$MAIL_DOMAIN" = "null" ]; then + echo "⚠️ Warning: Could not extract domain from $CONFIG_FILE" + echo "⚠️ Please ensure silver.yaml has domains configured" + MAIL_DOMAIN="example.org" +fi + +# ------------------------------- +# Environment variables +# ------------------------------- +export MAIL_DOMAIN +MAIL_HOSTNAME=${MAIL_HOSTNAME:-mail.$MAIL_DOMAIN} +RELAYHOST=${RELAYHOST:-} + +echo "Using domain: $MAIL_DOMAIN" +echo "$MAIL_DOMAIN" > /etc/mailname + +# ------------------------------- +# Check SQLite database +# ------------------------------- +DB_PATH="/app/data/databases/shared.db" + +echo "=== Checking SQLite database ===" +if [ -f "$DB_PATH" ]; then + echo "✓ SQLite database found at $DB_PATH" + + # Ensure domain exists in database + sqlite3 "$DB_PATH" "INSERT OR IGNORE INTO domains (domain, enabled) VALUES ('${MAIL_DOMAIN}', 1);" 2>/dev/null || echo "Note: Could not insert domain (may already exist)" + + # Set proper permissions + chmod 644 "$DB_PATH" +else + echo "⚠ Warning: SQLite database not found at $DB_PATH" + echo " Database should be created by raven" + echo " Postfix will start but mail delivery may fail until database is available" +fi + +echo "=== Database setup completed ===" + +# ------------------------------- +# Fix for DNS resolution in chroot +# ------------------------------- +mkdir -p /var/spool/postfix/etc +cp /etc/host.conf /etc/resolv.conf /etc/services /var/spool/postfix/etc/ +chmod 644 /var/spool/postfix/etc/* + +# ------------------------------- +# Verify configuration +# ------------------------------- +echo "=== Verifying Postfix configuration ===" +postconf virtual_mailbox_domains +postconf virtual_mailbox_maps +postconf virtual_mailbox_base +postconf virtual_transport + +# ------------------------------- +# Start Postfix +# ------------------------------- +echo "=== Starting Postfix ===" +service postfix start + +# Keep container running +sleep infinity diff --git a/mail-infra/openshift/silver-smtp-scc.yaml b/mail-infra/openshift/silver-smtp-scc.yaml new file mode 100644 index 0000000..80aab9c --- /dev/null +++ b/mail-infra/openshift/silver-smtp-scc.yaml @@ -0,0 +1,76 @@ +# =========================================================================== +# Dedicated least-privilege SecurityContextConstraints for silver-smtp (Postfix) +# on OpenShift. +# +# WHY: Postfix's binaries (postsuper, the queue manager invoked by +# `postfix start`) have a hardcoded getuid()==0 check and refuse to run as a +# non-root UID. So Postfix MUST run as root, which the default restricted-v2 +# SCC forbids. Rather than grant the broad built-in `anyuid`, this dedicated +# SCC permits root + ONLY the capabilities Postfix needs, and is bound to just +# the `smtp-server` service account in nsw-infra-prod. +# +# Capabilities (chroot is disabled in master.cf, so NO CAP_SYS_CHROOT): +# NET_BIND_SERVICE bind privileged ports 25/587 +# SETUID, SETGID master drops privileges to the postfix user +# CHOWN, DAC_OVERRIDE, FOWNER postfix set-permissions fixes the queue at startup +# KILL master signals its child daemons +# +# APPLY (cluster-admin): +# oc apply -f silver-smtp-scc.yaml +# oc -n nsw-infra-prod create rolebinding smtp-server-scc \ +# --clusterrole=system:openshift:scc:silver-smtp-scc \ +# --serviceaccount=nsw-infra-prod:smtp-server +# +# The smtp-server SA must exist first (created by the helm install, or: +# oc -n nsw-infra-prod create sa smtp-server). +# =========================================================================== +apiVersion: security.openshift.io/v1 +kind: SecurityContextConstraints +metadata: + name: silver-smtp-scc + annotations: + kubernetes.io/description: >- + Postfix (silver-smtp): permits root + a minimal capability set, no chroot, + no privileged/host access. Intended for the smtp-server SA only. +allowPrivilegedContainer: false +allowPrivilegeEscalation: true # Postfix master setuid-drops to the postfix user +allowHostNetwork: false +allowHostPorts: false +allowHostPID: false +allowHostIPC: false +allowHostDirVolumePlugin: false +readOnlyRootFilesystem: false +runAsUser: + type: RunAsAny # permit the image's root (uid 0) +seLinuxContext: + type: MustRunAs +fsGroup: + type: RunAsAny +supplementalGroups: + type: RunAsAny +requiredDropCapabilities: + - ALL +defaultAddCapabilities: # auto-added; chart needs no securityContext.capabilities + - NET_BIND_SERVICE + - SETUID + - SETGID + - CHOWN + - DAC_OVERRIDE + - FOWNER + - KILL +allowedCapabilities: + - NET_BIND_SERVICE + - SETUID + - SETGID + - CHOWN + - DAC_OVERRIDE + - FOWNER + - KILL +volumes: + - configMap + - emptyDir + - persistentVolumeClaim + - projected + - secret +users: [] +groups: [] From 036b5afe4731b27ef2fd12377e3e9cb4c76b9b7d Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 4 Jun 2026 14:42:08 +0530 Subject: [PATCH 16/18] feat: add rootless Postfix image and update Helm chart for non-root UID support --- .../helm/smtp-server/templates/configmap.yaml | 11 +- .../smtp-server/templates/deployment.yaml | 8 +- mail-infra/helm/smtp-server/values.yaml | 30 +++-- .../images/silver-smtp-rootless/Dockerfile | 109 ++++++++++++++++++ .../images/silver-smtp-rootless/entrypoint.sh | 57 +++++++++ 5 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 mail-infra/images/silver-smtp-rootless/Dockerfile create mode 100755 mail-infra/images/silver-smtp-rootless/entrypoint.sh diff --git a/mail-infra/helm/smtp-server/templates/configmap.yaml b/mail-infra/helm/smtp-server/templates/configmap.yaml index 5904bc8..ce9c640 100644 --- a/mail-infra/helm/smtp-server/templates/configmap.yaml +++ b/mail-infra/helm/smtp-server/templates/configmap.yaml @@ -86,11 +86,12 @@ data: # =========================================================== # master.cf — rendered by the silver-smtp Helm chart # - # chroot column is "n" for every service: chroot inside a container is - # redundant, and disabling it avoids requiring CAP_SYS_CHROOT in the SCC. - # Postfix runs as root and binds the privileged ports 25/587 directly. + # chroot column is "n" for every service (chroot is redundant in a container + # and needs CAP_SYS_CHROOT). The smtp/submission listeners use HIGH ports + # ({{ .Values.postfix.listen.smtpPort }}/{{ .Values.postfix.listen.submissionPort }}) because the patched-Postfix image runs as a + # non-root UID and cannot bind <1024; the Service remaps 25/587 -> these. # =========================================================== - smtp inet n - n - - smtpd + {{ .Values.postfix.listen.smtpPort }} inet n - n - - smtpd pickup unix n - n 60 1 pickup cleanup unix n - n - 0 cleanup qmgr unix n - n 300 1 qmgr @@ -117,7 +118,7 @@ data: postlog unix-dgram n - n - 1 postlogd {{- if .Values.postfix.submission.enabled }} - submission inet n - n - - smtpd + {{ .Values.postfix.listen.submissionPort }} inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_tls_security_level=encrypt -o smtpd_sasl_auth_enable=yes diff --git a/mail-infra/helm/smtp-server/templates/deployment.yaml b/mail-infra/helm/smtp-server/templates/deployment.yaml index b27c86a..ae84be9 100644 --- a/mail-infra/helm/smtp-server/templates/deployment.yaml +++ b/mail-infra/helm/smtp-server/templates/deployment.yaml @@ -50,7 +50,9 @@ spec: - -c - | set -eu - cp -a /etc/postfix/. /shared-postfix/ + # cp -rL (NOT -a): the pod runs as an arbitrary non-root UID and + # cannot preserve the image's root ownership/timestamps. + cp -rL /etc/postfix/. /shared-postfix/ cp /configs/main.cf /shared-postfix/main.cf cp /configs/master.cf /shared-postfix/master.cf cp /configs/silver.yaml /shared-postfix/silver.yaml @@ -73,11 +75,11 @@ spec: {{- end }} ports: - name: smtp - containerPort: 25 + containerPort: {{ .Values.postfix.listen.smtpPort }} protocol: TCP {{- if .Values.postfix.submission.enabled }} - name: submission - containerPort: 587 + containerPort: {{ .Values.postfix.listen.submissionPort }} protocol: TCP {{- end }} volumeMounts: diff --git a/mail-infra/helm/smtp-server/values.yaml b/mail-infra/helm/smtp-server/values.yaml index 4f06dca..2572ecb 100644 --- a/mail-infra/helm/smtp-server/values.yaml +++ b/mail-infra/helm/smtp-server/values.yaml @@ -18,11 +18,12 @@ fullnameOverride: "smtp-server" replicaCount: 1 image: - # Stock (unmodified) silver-smtp rebuilt into our GHCR org from - # mail-infra/images/silver-smtp. Postfix runs as root, so the pod requires - # the dedicated `silver-smtp-scc` SCC bound to the smtp-server SA. + # Patched-Postfix (rootless) image built from mail-infra/images/silver-smtp-rootless. + # The postfix/master/postsuper getuid()==0 checks are removed so Postfix runs + # as an arbitrary non-root UID under OpenShift's restricted-v2 SCC — no custom + # SCC / anyuid grant required. (The `:latest` stock image needs root.) repository: ghcr.io/silver-mail-platform/silver-smtp - tag: latest + tag: rootless pullPolicy: Always imagePullSecrets: [] @@ -37,6 +38,14 @@ hostname: "" # Postfix configuration knobs (rendered into main.cf / master.cf). # --------------------------------------------------------------------------- postfix: + # Ports Postfix LISTENS on inside the container. The patched-Postfix image + # runs as a non-root UID and cannot bind <1024, so it listens on high ports + # and the Service remaps 25/587 -> these (service.smtpPort/submissionPort + # stay 25/587 for callers). On a root-capable platform set these to 25/587. + listen: + smtpPort: 10025 + submissionPort: 10587 + # SMTP submission port 587 with enforced STARTTLS + SASL. submission: enabled: true @@ -124,15 +133,14 @@ persistence: - ReadWriteOnce size: 1Gi -# Postfix runs as the image's root (uid 0) to bind :25/:587 and spawn workers, -# then drops privileges internally to the `postfix` user. The dedicated -# silver-smtp-scc SCC permits this; nothing is pinned here so OpenShift uses the -# image's USER (root). chroot is disabled in master.cf, so no CAP_SYS_CHROOT. +# The patched-Postfix image runs single-UID as the arbitrary non-root UID that +# OpenShift assigns (restricted-v2). Nothing is pinned here so the SCC assigns +# the UID/fsGroup from the namespace range; chroot is disabled in master.cf. podSecurityContext: {} securityContext: - # Postfix master setuid-drops to the postfix user, so privilege escalation - # must be allowed. The SCC drops ALL caps and re-adds only what Postfix needs. - allowPrivilegeEscalation: true + # Single-UID Postfix never setuids, so no privilege escalation is needed — + # keep it false to stay compatible with the restricted-v2 SCC. + allowPrivilegeEscalation: false # Required: the nsw-infra-* tenant-quota rejects any container without CPU/memory # requests + limits. Applied to BOTH the init and main containers. diff --git a/mail-infra/images/silver-smtp-rootless/Dockerfile b/mail-infra/images/silver-smtp-rootless/Dockerfile new file mode 100644 index 0000000..5e7648e --- /dev/null +++ b/mail-infra/images/silver-smtp-rootless/Dockerfile @@ -0,0 +1,109 @@ +# ============================================================================= +# silver-smtp (rootless / patched Postfix) for OpenShift restricted-v2. +# +# WHY: OpenShift policy here forbids running as root, but stock Postfix's +# `postfix`, `master`, and `postsuper` binaries hard-abort unless getuid()==0 +# (verified in Postfix 3.7 source: postfix.c, master.c, postsuper.c). We +# rebuild Debian's exact Postfix package with those THREE checks neutralized +# so the binaries run as an arbitrary non-root UID. getuid() itself is left +# honest, so master.c's `set_ugid(getuid(), getgid())` keeps the real UID and +# Postfix runs single-UID (no privilege separation, no setuid attempts). +# +# Everything else (config, sqlite/TLS/pcre maps, dynamicmaps) comes from the +# unmodified Debian package build, so module parity with the stock image holds. +# ============================================================================= + +# ---------------------------------------------------------------------------- +# Stage 1 — rebuild Postfix from Debian source with the 3 root checks removed +# ---------------------------------------------------------------------------- +FROM debian:bookworm AS builder +ENV DEBIAN_FRONTEND=noninteractive + +# Enable deb-src so `apt-get source` works (bookworm uses the deb822 format). +RUN sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && \ + apt-get install -y --no-install-recommends dpkg-dev devscripts && \ + apt-get build-dep -y postfix + +WORKDIR /build +RUN apt-get source postfix + +# Neutralize the three getuid() superuser guards. getuid() is left intact +# everywhere else (notably master.c's set_ugid call). +RUN set -eux; \ + src="$(find . -maxdepth 1 -type d -name 'postfix-*')"; \ + cd "$src"; \ + # --- (1) Root-requirement CHECKS: neutralize the getuid()==0 guards --- + # master.c : if (getuid() != 0) msg_fatal("the master command is reserved...") + sed -i 's/if (getuid() != 0)/if (0)/' src/master/master.c; \ + # postfix.c : if (getuid() != 0) { ... msg_fatal("the postfix command is reserved...") } + sed -i 's/if (getuid() != 0)/if (0)/' src/postfix/postfix.c; \ + # postsuper.c : if (getuid()) msg_fatal("use of this command is reserved...") + sed -i 's/if (getuid())/if (0)/' src/postsuper/postsuper.c; \ + grep -q 'if (0)' src/master/master.c; \ + grep -q 'if (0)' src/postfix/postfix.c; \ + grep -q 'if (0)' src/postsuper/postsuper.c; \ + # --- (2) Privilege-SWITCHING: set_eugid()/set_ugid() begin with seteuid(0) + # to regain root before switching users. A non-root process can't do that + # and aborts. We run single-UID (== mail_owner), so no switch is needed: + # make both no-op when the real UID is already non-root. --- + # Insert an early-return guard just before each function's existing + # `if (geteuid() != 0)` line (unique per file, indented with tabs). This + # makes the function a no-op when the real UID is non-root, BEFORE any + # seteuid/setgid/setuid is attempted. + for f in set_eugid set_ugid; do \ + sed -i 's@^\([[:space:]]*\)if (geteuid() != 0)@\1if (getuid() != 0)\n\1 return;\n\1if (geteuid() != 0)@' "src/util/$f.c"; \ + grep -q 'if (getuid() != 0)' "src/util/$f.c"; \ + done; \ + # --- (3) Daemon privilege-drop: chroot_uid() does a direct setgid/setuid to + # the mail user when spawning each daemon. Guard both with getuid()==0 so a + # non-root single-UID run skips them (chroot itself is untouched; root_dir is + # null because every master.cf service has chroot=n). --- + sed -i 's/if (user_name != 0)/if (user_name != 0 \&\& getuid() == 0)/' src/util/chroot_uid.c; \ + test "$(grep -c 'getuid() == 0' src/util/chroot_uid.c)" = "2"; \ + dpkg-buildpackage -us -uc -b + +# ---------------------------------------------------------------------------- +# Stage 2 — slim runtime, mirrors the stock silver-smtp image +# ---------------------------------------------------------------------------- +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive + +COPY --from=builder /build/postfix_*.deb /build/postfix-sqlite_*.deb /tmp/debs/ + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + wget \ + sqlite3 \ + libsqlite3-0 \ + mailutils \ + iputils-ping \ + /tmp/debs/postfix_*.deb \ + /tmp/debs/postfix-sqlite_*.deb && \ + if ! grep -q "^sqlite" /etc/postfix/dynamicmaps.cf; then \ + echo "sqlite postfix-sqlite.so dict_sqlite_open" >> /etc/postfix/dynamicmaps.cf; \ + fi && \ + wget -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" && \ + chmod +x /usr/local/bin/yq && \ + rm -rf /tmp/debs && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /app/data /etc/postfix/sqlite /var/spool/postfix /var/lib/postfix + +COPY ./entrypoint.sh /entrypoint.sh +RUN chmod 0755 /entrypoint.sh + +# OpenShift arbitrary-UID support: the pod runs as a random non-root UID with +# GID 0. Group-own everything Postfix reads/writes by GID 0 with group perms +# mirroring owner, and make /etc/passwd + /etc/group writable so the entrypoint +# can register the runtime UID as Postfix's mail_owner. +RUN chgrp -R 0 /etc/postfix /var/spool/postfix /var/lib/postfix /app /etc/passwd /etc/group && \ + chmod -R g=u /etc/postfix /var/spool/postfix /var/lib/postfix /app && \ + chmod g=u /etc/passwd /etc/group && \ + touch /etc/mailname && chgrp 0 /etc/mailname && chmod g=u /etc/mailname + +WORKDIR /app +ENTRYPOINT ["/entrypoint.sh"] diff --git a/mail-infra/images/silver-smtp-rootless/entrypoint.sh b/mail-infra/images/silver-smtp-rootless/entrypoint.sh new file mode 100755 index 0000000..eab63cc --- /dev/null +++ b/mail-infra/images/silver-smtp-rootless/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +CONFIG_FILE="/etc/postfix/silver.yaml" + +# --- domain from silver.yaml ------------------------------------------------- +MAIL_DOMAIN=$(awk '/^[[:space:]]*-[[:space:]]*domain:/ {sub(/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*/, ""); print; exit}' "$CONFIG_FILE") +if [ -z "$MAIL_DOMAIN" ] || [ "$MAIL_DOMAIN" = "null" ]; then + echo "⚠️ Could not extract domain from $CONFIG_FILE; using example.org" + MAIL_DOMAIN="example.org" +fi +export MAIL_DOMAIN +MAIL_HOSTNAME=${MAIL_HOSTNAME:-mail.$MAIL_DOMAIN} +echo "Using domain: $MAIL_DOMAIN" +echo "$MAIL_DOMAIN" > /etc/mailname 2>/dev/null || true + +# --- single-UID rootless setup ---------------------------------------------- +# OpenShift runs us as a random non-root UID (GID 0) with no /etc/passwd entry. +# Postfix's mail_owner must resolve to a real user whose UID == our UID, and +# whose primary group has no OTHER members (Postfix rejects shared groups). +# We register both in the (group-writable) /etc/passwd + /etc/group. +UID0="$(id -u)" +OWNER_GID=$((UID0 % 60000 + 2000)) # a deterministic, almost-certainly-unique gid +if ! getent group "$OWNER_GID" >/dev/null 2>&1; then + echo "postfixown:x:${OWNER_GID}:" >> /etc/group +fi +if ! getent passwd "$UID0" >/dev/null 2>&1; then + echo "postfixown:x:${UID0}:${OWNER_GID}:Postfix owner:/var/spool/postfix:/usr/sbin/nologin" >> /etc/passwd +fi +RUN_USER="$(getent passwd "$UID0" | cut -d: -f1)" +echo "Running as uid=$UID0 user=$RUN_USER gid=$(id -g) groups=$(id -G)" + +# mail_owner = our UID (its private group has no other members, which Postfix +# requires). setgid_group = the package's dedicated `postdrop` group: Postfix +# rejects a privileged (low) GID like root/0 here. The setgid maildrop/postdrop +# path is unused (mail arrives via smtpd on the network), so we don't need to +# actually belong to it — it only has to pass Postfix's startup validation. +postconf -e "myhostname = ${MAIL_HOSTNAME}" +postconf -e "mail_owner = ${RUN_USER}" +postconf -e "setgid_group = postdrop" +postconf -e "queue_directory = /var/spool/postfix" +postconf -e "data_directory = /var/lib/postfix" + +# Recreate the queue tree (the PVC mounts empty over /var/spool/postfix). +for d in active bounce corrupt defer deferred flush hold incoming saved trace \ + private public maildrop pid; do + mkdir -p "/var/spool/postfix/$d" +done + +# set-permissions now works: chown targets are us / a group we're in. Errors on +# the setgid postdrop/postqueue binaries (need real root) are non-fatal — the +# maildrop/local-submission path is unused; mail arrives via smtpd on :25. +postfix set-permissions 2>&1 || true +postfix check 2>&1 || true + +echo "=== Starting Postfix (foreground, rootless single-UID, no chroot) ===" +exec postfix start-fg From 47aedc354552ddb9270028e595222b64c2e032dc Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 4 Jun 2026 14:44:36 +0530 Subject: [PATCH 17/18] feat: remove deprecated Dockerfile, entrypoint script, and SCC for silver-smtp --- mail-infra/helm/smtp-server/values.yaml | 7 +- mail-infra/images/silver-smtp/Dockerfile | 55 --------------- mail-infra/images/silver-smtp/entrypoint.sh | 71 ------------------- mail-infra/openshift/silver-smtp-scc.yaml | 76 --------------------- 4 files changed, 4 insertions(+), 205 deletions(-) delete mode 100644 mail-infra/images/silver-smtp/Dockerfile delete mode 100755 mail-infra/images/silver-smtp/entrypoint.sh delete mode 100644 mail-infra/openshift/silver-smtp-scc.yaml diff --git a/mail-infra/helm/smtp-server/values.yaml b/mail-infra/helm/smtp-server/values.yaml index 2572ecb..b9e626a 100644 --- a/mail-infra/helm/smtp-server/values.yaml +++ b/mail-infra/helm/smtp-server/values.yaml @@ -19,9 +19,10 @@ replicaCount: 1 image: # Patched-Postfix (rootless) image built from mail-infra/images/silver-smtp-rootless. - # The postfix/master/postsuper getuid()==0 checks are removed so Postfix runs - # as an arbitrary non-root UID under OpenShift's restricted-v2 SCC — no custom - # SCC / anyuid grant required. (The `:latest` stock image needs root.) + # The postfix/master/postsuper getuid()==0 checks are removed and the + # set_eugid/set_ugid/chroot_uid privilege-drops are no-op'd for non-root, so + # Postfix runs single-UID as the arbitrary non-root UID OpenShift assigns + # under restricted-v2 — no custom SCC / anyuid grant required. repository: ghcr.io/silver-mail-platform/silver-smtp tag: rootless pullPolicy: Always diff --git a/mail-infra/images/silver-smtp/Dockerfile b/mail-infra/images/silver-smtp/Dockerfile deleted file mode 100644 index 9e79c74..0000000 --- a/mail-infra/images/silver-smtp/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -FROM debian:bookworm-slim -ENV DEBIAN_FRONTEND=noninteractive - -# ====================================== -# Install Postfix + SQLite + Tools -# ====================================== -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - gnupg \ - wget \ - sqlite3 \ - libsqlite3-0 \ - postfix \ - postfix-sqlite \ - mailutils \ - iputils-ping && \ - \ - # Ensure SQLite module registered in Postfix - if ! grep -q "^sqlite" /etc/postfix/dynamicmaps.cf; then \ - echo "sqlite postfix-sqlite.so dict_sqlite_open" >> /etc/postfix/dynamicmaps.cf; \ - fi && \ - \ - # Download yq (for YAML parsing in entrypoint) - wget -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" && \ - chmod +x /usr/local/bin/yq && \ - \ - # Clean up - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# ====================================== -# Prepare app structure -# ====================================== -RUN mkdir -p /app/data /etc/postfix/sqlite - -# Ensure correct permissions (Postfix needs read-only, not writable) -RUN chmod 755 /etc/postfix && chmod 644 /etc/postfix/* || true - -# ====================================== -# Copy entrypoint and set permissions -# ====================================== -COPY ./entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# ====================================== -# Set work directory -# ====================================== -WORKDIR /app - - -# ====================================== -# Entrypoint -# ====================================== -CMD ["/entrypoint.sh"] diff --git a/mail-infra/images/silver-smtp/entrypoint.sh b/mail-infra/images/silver-smtp/entrypoint.sh deleted file mode 100755 index 2b557a8..0000000 --- a/mail-infra/images/silver-smtp/entrypoint.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/bash -set -e - -CONFIG_FILE="/etc/postfix/silver.yaml" - -# Extract primary (first) domain from the domains list using awk -MAIL_DOMAIN=$(awk '/^[[:space:]]*-[[:space:]]*domain:/ {sub(/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*/, ""); print; exit}' "$CONFIG_FILE") - -# Fallback if extraction failed -if [ -z "$MAIL_DOMAIN" ] || [ "$MAIL_DOMAIN" = "null" ]; then - echo "⚠️ Warning: Could not extract domain from $CONFIG_FILE" - echo "⚠️ Please ensure silver.yaml has domains configured" - MAIL_DOMAIN="example.org" -fi - -# ------------------------------- -# Environment variables -# ------------------------------- -export MAIL_DOMAIN -MAIL_HOSTNAME=${MAIL_HOSTNAME:-mail.$MAIL_DOMAIN} -RELAYHOST=${RELAYHOST:-} - -echo "Using domain: $MAIL_DOMAIN" -echo "$MAIL_DOMAIN" > /etc/mailname - -# ------------------------------- -# Check SQLite database -# ------------------------------- -DB_PATH="/app/data/databases/shared.db" - -echo "=== Checking SQLite database ===" -if [ -f "$DB_PATH" ]; then - echo "✓ SQLite database found at $DB_PATH" - - # Ensure domain exists in database - sqlite3 "$DB_PATH" "INSERT OR IGNORE INTO domains (domain, enabled) VALUES ('${MAIL_DOMAIN}', 1);" 2>/dev/null || echo "Note: Could not insert domain (may already exist)" - - # Set proper permissions - chmod 644 "$DB_PATH" -else - echo "⚠ Warning: SQLite database not found at $DB_PATH" - echo " Database should be created by raven" - echo " Postfix will start but mail delivery may fail until database is available" -fi - -echo "=== Database setup completed ===" - -# ------------------------------- -# Fix for DNS resolution in chroot -# ------------------------------- -mkdir -p /var/spool/postfix/etc -cp /etc/host.conf /etc/resolv.conf /etc/services /var/spool/postfix/etc/ -chmod 644 /var/spool/postfix/etc/* - -# ------------------------------- -# Verify configuration -# ------------------------------- -echo "=== Verifying Postfix configuration ===" -postconf virtual_mailbox_domains -postconf virtual_mailbox_maps -postconf virtual_mailbox_base -postconf virtual_transport - -# ------------------------------- -# Start Postfix -# ------------------------------- -echo "=== Starting Postfix ===" -service postfix start - -# Keep container running -sleep infinity diff --git a/mail-infra/openshift/silver-smtp-scc.yaml b/mail-infra/openshift/silver-smtp-scc.yaml deleted file mode 100644 index 80aab9c..0000000 --- a/mail-infra/openshift/silver-smtp-scc.yaml +++ /dev/null @@ -1,76 +0,0 @@ -# =========================================================================== -# Dedicated least-privilege SecurityContextConstraints for silver-smtp (Postfix) -# on OpenShift. -# -# WHY: Postfix's binaries (postsuper, the queue manager invoked by -# `postfix start`) have a hardcoded getuid()==0 check and refuse to run as a -# non-root UID. So Postfix MUST run as root, which the default restricted-v2 -# SCC forbids. Rather than grant the broad built-in `anyuid`, this dedicated -# SCC permits root + ONLY the capabilities Postfix needs, and is bound to just -# the `smtp-server` service account in nsw-infra-prod. -# -# Capabilities (chroot is disabled in master.cf, so NO CAP_SYS_CHROOT): -# NET_BIND_SERVICE bind privileged ports 25/587 -# SETUID, SETGID master drops privileges to the postfix user -# CHOWN, DAC_OVERRIDE, FOWNER postfix set-permissions fixes the queue at startup -# KILL master signals its child daemons -# -# APPLY (cluster-admin): -# oc apply -f silver-smtp-scc.yaml -# oc -n nsw-infra-prod create rolebinding smtp-server-scc \ -# --clusterrole=system:openshift:scc:silver-smtp-scc \ -# --serviceaccount=nsw-infra-prod:smtp-server -# -# The smtp-server SA must exist first (created by the helm install, or: -# oc -n nsw-infra-prod create sa smtp-server). -# =========================================================================== -apiVersion: security.openshift.io/v1 -kind: SecurityContextConstraints -metadata: - name: silver-smtp-scc - annotations: - kubernetes.io/description: >- - Postfix (silver-smtp): permits root + a minimal capability set, no chroot, - no privileged/host access. Intended for the smtp-server SA only. -allowPrivilegedContainer: false -allowPrivilegeEscalation: true # Postfix master setuid-drops to the postfix user -allowHostNetwork: false -allowHostPorts: false -allowHostPID: false -allowHostIPC: false -allowHostDirVolumePlugin: false -readOnlyRootFilesystem: false -runAsUser: - type: RunAsAny # permit the image's root (uid 0) -seLinuxContext: - type: MustRunAs -fsGroup: - type: RunAsAny -supplementalGroups: - type: RunAsAny -requiredDropCapabilities: - - ALL -defaultAddCapabilities: # auto-added; chart needs no securityContext.capabilities - - NET_BIND_SERVICE - - SETUID - - SETGID - - CHOWN - - DAC_OVERRIDE - - FOWNER - - KILL -allowedCapabilities: - - NET_BIND_SERVICE - - SETUID - - SETGID - - CHOWN - - DAC_OVERRIDE - - FOWNER - - KILL -volumes: - - configMap - - emptyDir - - persistentVolumeClaim - - projected - - secret -users: [] -groups: [] From 5638d532fb1bd22a5c0d0f8b2b69ff248199e19a Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Fri, 7 Aug 2026 09:02:38 +0530 Subject: [PATCH 18/18] feat: enhance service exposure options in README and NOTES, add load balancer configurations in service.yaml and values.yaml --- mail-infra/helm/smtp-server/README.md | 41 ++++++++++++++++--- .../helm/smtp-server/templates/NOTES.txt | 24 ++++++++++- .../helm/smtp-server/templates/service.yaml | 13 ++++++ mail-infra/helm/smtp-server/values.yaml | 24 +++++++++-- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/mail-infra/helm/smtp-server/README.md b/mail-infra/helm/smtp-server/README.md index 81e5906..840798a 100644 --- a/mail-infra/helm/smtp-server/README.md +++ b/mail-infra/helm/smtp-server/README.md @@ -76,23 +76,49 @@ This chart defaults to **`service.type: ClusterIP`** because: - Mail can be fronted by an external TCP LB or HAProxy you already manage. - ClusterIP installs cleanly in any cluster without extra requirements. -To expose externally: +> **A Route / Ingress will not work for SMTP.** OpenShift Routes and k8s +> Ingress only carry HTTP/HTTPS/TLS-SNI on 80/443. Submission (587) starts in +> plaintext and upgrades with STARTTLS — there is no SNI for the router to key +> on — so raw 25/587 must be exposed with a `LoadBalancer` or `NodePort`. + +**Option A — LoadBalancer** (real `:25`/`:587` on a dedicated IP; needs a cloud +L4 LB or MetalLB — stays `` on bare clusters with no provider): ```yaml service: type: LoadBalancer - loadBalancerIP: 203.0.113.5 # optional, depends on cloud provider - annotations: - # e.g. for GKE / EKS / AKS L4 LB annotations + loadBalancerIP: 203.0.113.5 # optional, depends on provider + loadBalancerSourceRanges: [] # optional CIDR allow-list + externalTrafficPolicy: Local # preserve client IP for rate limits + annotations: {} # e.g. GKE / EKS / AKS L4 LB annotations +``` + +**Option B — NodePort** (fallback when no LB provider exists; exposes high +ports on every node IP — have the platform team open the firewall / NAT +`587 -> nodePort`): + +```yaml +service: + type: NodePort + externalTrafficPolicy: Local + nodePorts: + smtp: 30025 + submission: 30587 ``` -Then publish DNS: +Then publish DNS (point at the LB IP, or a node/edge IP for NodePort): ``` mail.example.com. A 203.0.113.5 example.com. MX 10 mail.example.com. ``` +Test the external listener: + +```bash +openssl s_client -starttls smtp -connect :587 +``` + Outbound port 25 is blocked by most cloud providers and ISPs by default — plan to use a relay (Mailgun, SES, SendGrid) for outbound, or open a support case to unblock 25. @@ -114,7 +140,10 @@ support case to unblock 25. | `postfix.maillogFile` | `/dev/stdout` | Set to `/var/log/mail.log` for legacy file logging | | `persistence.spool.size` | `1Gi` | Postfix queue | | `replicaCount` | `1` (**do not change**) | Singleton — PVC is RWO and the queue is single-writer | -| `service.type` | `ClusterIP` | Switch to `LoadBalancer` to expose externally | +| `service.type` | `ClusterIP` | `LoadBalancer` or `NodePort` to expose externally | +| `service.loadBalancerSourceRanges` | `[]` | CIDR allow-list for a LoadBalancer (empty = open) | +| `service.externalTrafficPolicy` | `""` | `Local` preserves client source IP (LB/NodePort) | +| `service.nodePorts.smtp` / `.submission` | `""` | Pin NodePort numbers (30000-32767) when `type=NodePort`| ## Uninstall diff --git a/mail-infra/helm/smtp-server/templates/NOTES.txt b/mail-infra/helm/smtp-server/templates/NOTES.txt index dfd791c..d1ea5e5 100644 --- a/mail-infra/helm/smtp-server/templates/NOTES.txt +++ b/mail-infra/helm/smtp-server/templates/NOTES.txt @@ -19,21 +19,41 @@ Service: {{ include "silver-smtp.fullname" . }} ----- 2. Service exposure ----- Type: {{ .Values.service.type }} + + NOTE: an OpenShift Route / k8s Ingress cannot expose SMTP — they only carry + HTTP/HTTPS/TLS-SNI on 80/443. External 587 access requires LoadBalancer or + NodePort (below). {{- if eq .Values.service.type "ClusterIP" }} ClusterIP only — reachable from inside the cluster but NOT from the public internet. To accept external mail, either: a) Switch service.type to LoadBalancer (if your cloud provides L4 LB), OR - b) Front this ClusterIP with an external TCP load balancer / firewall NAT. + b) Switch service.type to NodePort and open the firewall to a node IP, OR + c) Front this ClusterIP with an external TCP load balancer / firewall NAT. {{- else if eq .Values.service.type "LoadBalancer" }} - Watch the LoadBalancer get its address: + Watch the LoadBalancer get its address (stays if the cluster has + no L4 LB provider — fall back to NodePort in that case): kubectl -n {{ .Release.Namespace }} get svc {{ include "silver-smtp.fullname" . }} -w Then publish DNS: MX {{ .Values.domain }}. 10 {{ include "silver-smtp.hostname" . }}. A {{ include "silver-smtp.hostname" . }}. + + Test from outside: + openssl s_client -starttls smtp -connect :{{ .Values.service.submissionPort }} + +{{- else if eq .Values.service.type "NodePort" }} + + Find the assigned node ports: + kubectl -n {{ .Release.Namespace }} get svc {{ include "silver-smtp.fullname" . }} \ + -o jsonpath='{range .spec.ports[*]}{.name}={.nodePort}{"\n"}{end}' + + Connect from outside to :. Have your + platform team open the firewall (and optionally NAT 587 -> the nodePort). + Test: + openssl s_client -starttls smtp -connect : {{- end }} ----- 3. Dependencies (charts you should already have installed) ----- diff --git a/mail-infra/helm/smtp-server/templates/service.yaml b/mail-infra/helm/smtp-server/templates/service.yaml index 59e9f73..453aa68 100644 --- a/mail-infra/helm/smtp-server/templates/service.yaml +++ b/mail-infra/helm/smtp-server/templates/service.yaml @@ -13,16 +13,29 @@ spec: {{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerIP }} loadBalancerIP: {{ .Values.service.loadBalancerIP }} {{- end }} + {{- with .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if and (or (eq .Values.service.type "LoadBalancer") (eq .Values.service.type "NodePort")) .Values.service.externalTrafficPolicy }} + externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }} + {{- end }} ports: - port: {{ .Values.service.smtpPort }} targetPort: smtp protocol: TCP name: smtp + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePorts.smtp }} + nodePort: {{ .Values.service.nodePorts.smtp }} + {{- end }} {{- if .Values.postfix.submission.enabled }} - port: {{ .Values.service.submissionPort }} targetPort: submission protocol: TCP name: submission + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePorts.submission }} + nodePort: {{ .Values.service.nodePorts.submission }} + {{- end }} {{- end }} selector: {{- include "silver-smtp.selectorLabels" . | nindent 4 }} diff --git a/mail-infra/helm/smtp-server/values.yaml b/mail-infra/helm/smtp-server/values.yaml index b9e626a..309ccb2 100644 --- a/mail-infra/helm/smtp-server/values.yaml +++ b/mail-infra/helm/smtp-server/values.yaml @@ -113,11 +113,29 @@ tlsSecret: alreadyRenamed: false service: - # ClusterIP keeps the chart safe to install anywhere. Set to LoadBalancer - # (and add `loadBalancerIP` / annotations) to expose to the internet, - # or front the ClusterIP with an external L4 load balancer. + # How clients reach SMTP/submission. NOTE: an OpenShift Route / Kubernetes + # Ingress CANNOT carry SMTP — they only route HTTP/HTTPS/TLS-SNI on 80/443, + # and submission (587) begins in plaintext with STARTTLS (no SNI to route on). + # So to accept mail from OUTSIDE the cluster you must use one of: + # - LoadBalancer : real :25/:587 on a dedicated external IP (needs a cloud + # L4 LB or MetalLB; stays on bare clusters). + # - NodePort : exposes high ports (30000-32767) on every node IP; pin + # them via service.nodePorts below and have the platform + # team open the firewall / NAT 587 -> the nodePort. + # - ClusterIP : in-cluster only (default; safe to install anywhere). type: ClusterIP loadBalancerIP: "" + # Restrict who may reach a LoadBalancer (CIDRs). Empty = open to all. + loadBalancerSourceRanges: [] + # "Local" preserves the real client source IP (so Postfix smtpd_client_* + # rate limits and SASL logging see the sender, not a node/SNAT address). + # Applies to LoadBalancer and NodePort only. "" leaves the cluster default. + externalTrafficPolicy: "" + # Pin the NodePort numbers when service.type=NodePort (else they're random). + # Must fall within the cluster's node-port range (default 30000-32767). + nodePorts: + smtp: "" + submission: "" annotations: {} smtpPort: 25 submissionPort: 587