From d34a4bfda348dcc126867e9ab61bdc986cc5461a Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Thu, 23 Apr 2026 16:00:56 +0530 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 f1fe55a47f98438fe46e93b6e637c105ae93d718 Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Sat, 23 May 2026 23:19:50 +0530 Subject: [PATCH 14/16] feat: add initial Helm chart for OpenDKIM with configuration and templates --- mail-infra/helm/opendkim-server/.helmignore | 10 ++ mail-infra/helm/opendkim-server/Chart.yaml | 21 +++ mail-infra/helm/opendkim-server/README.md | 123 ++++++++++++++++++ .../helm/opendkim-server/templates/NOTES.txt | 43 ++++++ .../opendkim-server/templates/_helpers.tpl | 85 ++++++++++++ .../opendkim-server/templates/configmap.yaml | 63 +++++++++ .../opendkim-server/templates/deployment.yaml | 112 ++++++++++++++++ .../helm/opendkim-server/templates/pvc.yaml | 17 +++ .../opendkim-server/templates/service.yaml | 15 +++ .../templates/serviceaccount.yaml | 12 ++ mail-infra/helm/opendkim-server/values.yaml | 121 +++++++++++++++++ 11 files changed, 622 insertions(+) create mode 100644 mail-infra/helm/opendkim-server/.helmignore create mode 100644 mail-infra/helm/opendkim-server/Chart.yaml create mode 100644 mail-infra/helm/opendkim-server/README.md create mode 100644 mail-infra/helm/opendkim-server/templates/NOTES.txt create mode 100644 mail-infra/helm/opendkim-server/templates/_helpers.tpl create mode 100644 mail-infra/helm/opendkim-server/templates/configmap.yaml create mode 100644 mail-infra/helm/opendkim-server/templates/deployment.yaml create mode 100644 mail-infra/helm/opendkim-server/templates/pvc.yaml create mode 100644 mail-infra/helm/opendkim-server/templates/service.yaml create mode 100644 mail-infra/helm/opendkim-server/templates/serviceaccount.yaml create mode 100644 mail-infra/helm/opendkim-server/values.yaml diff --git a/mail-infra/helm/opendkim-server/.helmignore b/mail-infra/helm/opendkim-server/.helmignore new file mode 100644 index 0000000..5d840fd --- /dev/null +++ b/mail-infra/helm/opendkim-server/.helmignore @@ -0,0 +1,10 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +.idea/ +.vscode/ +*.tmproj +*.swp +*.bak +*.tgz diff --git a/mail-infra/helm/opendkim-server/Chart.yaml b/mail-infra/helm/opendkim-server/Chart.yaml new file mode 100644 index 0000000..7bab1e3 --- /dev/null +++ b/mail-infra/helm/opendkim-server/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: silver-opendkim +description: | + Helm chart for the OpenDKIM milter (ghcr.io/lsflk/silver-dkim) used by the + Pingmailer / Silver Mail stack. Generates per-domain DKIM signing keys on + first start, persists them in a PVC, and exposes the Milter socket on + :8891 for Postfix to consume. +type: application +version: 0.1.0 +appVersion: "main" +keywords: + - opendkim + - dkim + - milter + - email + - pingmailer +maintainers: + - name: LSFLK + url: https://github.com/lsflk +sources: + - https://github.com/LSFLK/silver diff --git a/mail-infra/helm/opendkim-server/README.md b/mail-infra/helm/opendkim-server/README.md new file mode 100644 index 0000000..790fd1d --- /dev/null +++ b/mail-infra/helm/opendkim-server/README.md @@ -0,0 +1,123 @@ +# silver-opendkim Helm chart + +Helm chart for the OpenDKIM milter (`ghcr.io/lsflk/silver-dkim`) — the +Kubernetes counterpart of the `opendkim-server` service in this repo's +[docker-compose.yml](../../../docker-compose.yml). + +OpenDKIM signs (and verifies) outbound SMTP traffic from Postfix. This chart: + +1. Generates `opendkim.conf`, `KeyTable`, `SigningTable`, `TrustedHosts`, and + `silver.yaml` deterministically from your `domains` list and packs them + into a `ConfigMap`. +2. Runs the `silver-dkim` image, which inspects `silver.yaml` on startup and + calls `opendkim-genkey` for any domain whose private key is not yet on + disk at `/etc/dkimkeys//.private`. +3. Persists those generated keys on a `PersistentVolumeClaim` so subsequent + pod restarts reuse the same keys (and therefore the same published DNS + TXT records). +4. Exposes the Milter protocol on `ClusterIP :8891` so Postfix + (`smtpd_milters=inet:opendkim-server:8891`) can reach it. + +## DKIM key management — the trade-off you're picking + +The two mainstream patterns in Kubernetes are: + +| Pattern | How | Pros | Cons | +|---|---|---|---| +| **Secret-mount** | Pre-generate the private keys, `kubectl create secret`, mount it at `/etc/dkimkeys` | etcd encryption-at-rest, RBAC-gated, easy to mirror across clusters | Manual bootstrap + manual rotation | +| **PVC-mount (this chart)** | Container self-bootstraps via `opendkim-genkey` on a PVC | Zero-touch — install and forget | Encryption depends on your `StorageClass`; you own backup | + +The `silver-dkim` image is **designed for the PVC pattern** — its entrypoint +runs `opendkim-genkey` for any missing key and writes into `/etc/dkimkeys/`. +If you want the Secret pattern instead, point `persistence.existingClaim` at +a CSI Secret-backed PVC (or kustomize-patch the Deployment to mount a +Secret at `/etc/dkimkeys` and skip the PVC altogether). + +## Install + +Sensitive value (the domain list) is intentionally absent from `values.yaml`. +Supply it on the command line or via a private values file: + +```bash +# my-domains.yaml (do not commit) +domains: + - domain: example.com + selector: mail + keySize: 2048 + - domain: another.example.com + selector: mail-2026 +``` + +```bash +helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ + --namespace pingmailer --create-namespace \ + -f my-domains.yaml +``` + +Or inline: + +```bash +helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ + --namespace pingmailer --create-namespace \ + --set 'domains[0].domain=example.com' +``` + +The chart `fail`s loudly when `domains` is empty. + +## After install: get the DNS TXT records + +`opendkim-genkey` writes the public-key TXT record alongside the private key. +The pod prints them on startup; you can also grab them on demand: + +```bash +kubectl -n pingmailer exec deploy/opendkim-server -- \ + sh -c 'for d in /etc/dkimkeys/*; do echo "--- $(basename "$d") ---"; cat "$d"/*.txt; done' +``` + +Publish each at `._domainkey.` in your authoritative DNS. + +## Wire Postfix to OpenDKIM + +In the smtp-server (Postfix) chart / config: + +``` +smtpd_milters = inet:opendkim-server:8891 +non_smtpd_milters = inet:opendkim-server:8891 +milter_default_action = accept +``` + +The default `fullnameOverride: opendkim-server` in [values.yaml](values.yaml) +keeps the Service DNS short so the docker-compose Postfix `master.cf` +(`-o smtpd_milters=inet:opendkim-server:8891`) works without edits when both +charts share a namespace. + +## Values + +| Key | Default | Notes | +|------------------------------|--------------------------|-----------------------------------------------------------------------| +| `domains` | `[]` (**required**) | List of `{domain, selector?, keySize?}`. `selector` defaults to `mail`, `keySize` to `2048` | +| `image.repository` | `ghcr.io/lsflk/silver-dkim` | | +| `image.tag` | `main` | | +| `opendkim.mode` | `sv` | `s` = sign only, `v` = verify only, `sv` = both | +| `opendkim.trustedHosts` | RFC1918 + loopback | Chart appends each configured domain + `*.` automatically | +| `service.port` | `8891` | | +| `persistence.enabled` | `true` | Set `existingClaim` to bring your own PVC | +| `persistence.size` | `256Mi` | RSA-2048 keys are tiny; this is a generous default | +| `podSecurityContext.fsGroup` | `999` | `opendkim` GID inside the image — needed for PVC writes | +| `replicaCount` | `1` (**do not change**) | DKIM is intrinsically singleton + PVC is RWO | + +## Rotating a DKIM key + +1. Pick a new `selector` (the old DNS record stays live so in-flight mail is still verifiable). +2. Update `domains[i].selector` in your values file. +3. `helm upgrade …` — the new selector triggers `opendkim-genkey` for a fresh key. +4. Publish the new TXT record alongside the old. +5. After your DKIM TTL has lapsed across the internet, delete the old TXT record. + +## Uninstall + +```bash +helm uninstall opendkim-server -n pingmailer +``` + +PVC stays behind — see NOTES.txt to delete it explicitly. diff --git a/mail-infra/helm/opendkim-server/templates/NOTES.txt b/mail-infra/helm/opendkim-server/templates/NOTES.txt new file mode 100644 index 0000000..efc894f --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/NOTES.txt @@ -0,0 +1,43 @@ +silver-opendkim has been installed. + +Release: {{ .Release.Name }} +Namespace: {{ .Release.Namespace }} +Service: {{ include "silver-opendkim.fullname" . }}:{{ .Values.service.port }} (Milter) + +Domains configured: +{{- range .Values.domains }} + • {{ .domain }} (selector={{ default "mail" .selector }}, keySize={{ default 2048 .keySize }}) +{{- end }} + +----- 1. Watch first-time key generation ----- + + kubectl -n {{ .Release.Namespace }} logs -f deploy/{{ include "silver-opendkim.fullname" . }} + +The pod prints DKIM DNS TXT records on first start. Capture them with: + + kubectl -n {{ .Release.Namespace }} exec deploy/{{ include "silver-opendkim.fullname" . }} \ + -- sh -c 'for d in /etc/dkimkeys/*; do echo "--- $(basename "$d") ---"; cat "$d"/*.txt; done' + +Publish each TXT record at `._domainkey.` in your DNS. + +----- 2. Wire Postfix to opendkim ----- + +In your Postfix smtp-server chart / configmap, add: + + smtpd_milters = inet:{{ include "silver-opendkim.fullname" . }}:{{ .Values.service.port }} + non_smtpd_milters = inet:{{ include "silver-opendkim.fullname" . }}:{{ .Values.service.port }} + milter_default_action = accept + +(In docker-compose terms: replace `opendkim-server:8891` with the K8s Service +DNS — by default the fullnameOverride is `opendkim-server`, so the existing +hostname will Just Work as long as both charts share a namespace.) + +----- 3. Persistent storage ----- + +DKIM private keys live on the PVC `{{ include "silver-opendkim.claimName" . }}` +({{ .Values.persistence.size }}). The PVC survives `helm uninstall` — delete it +explicitly to force key regeneration: + + kubectl -n {{ .Release.Namespace }} delete pvc {{ include "silver-opendkim.claimName" . }} + +Back this PVC up: losing it means publishing new TXT records. diff --git a/mail-infra/helm/opendkim-server/templates/_helpers.tpl b/mail-infra/helm/opendkim-server/templates/_helpers.tpl new file mode 100644 index 0000000..013bc63 --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/_helpers.tpl @@ -0,0 +1,85 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "silver-opendkim.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Fully qualified app name. +*/}} +{{- define "silver-opendkim.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label. +*/}} +{{- define "silver-opendkim.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "silver-opendkim.labels" -}} +helm.sh/chart: {{ include "silver-opendkim.chart" . }} +{{ include "silver-opendkim.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/component: opendkim +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "silver-opendkim.selectorLabels" -}} +app.kubernetes.io/name: {{ include "silver-opendkim.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Service account name. +*/}} +{{- define "silver-opendkim.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "silver-opendkim.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +PVC claim name (existing or chart-managed). +*/}} +{{- define "silver-opendkim.claimName" -}} +{{- if .Values.persistence.existingClaim }} +{{- .Values.persistence.existingClaim }} +{{- else }} +{{- printf "%s-keys" (include "silver-opendkim.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Normalize a single domain entry to a dict with domain/selector/keySize. +Pass the entry as the context. +*/}} +{{- define "silver-opendkim.domain" -}} +{{- $entry := . -}} +{{- $selector := default "mail" $entry.selector -}} +{{- $keySize := default 2048 $entry.keySize -}} +domain: {{ $entry.domain }} +selector: {{ $selector }} +keySize: {{ $keySize }} +{{- end }} diff --git a/mail-infra/helm/opendkim-server/templates/configmap.yaml b/mail-infra/helm/opendkim-server/templates/configmap.yaml new file mode 100644 index 0000000..e0e8ac6 --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/configmap.yaml @@ -0,0 +1,63 @@ +{{- if not .Values.domains }} +{{- fail "domains is required — pass via -f my-values.yaml or --set 'domains[0].domain=example.com'. Each entry needs at least a `domain` field; `selector` defaults to 'mail' and `keySize` to 2048." }} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "silver-opendkim.fullname" . }}-conf + labels: + {{- include "silver-opendkim.labels" . | nindent 4 }} +data: + opendkim.conf: | + AutoRestart {{ .Values.opendkim.autoRestart }} + AutoRestartRate {{ .Values.opendkim.autoRestartRate }} + UMask {{ .Values.opendkim.umask }} + Syslog {{ .Values.opendkim.syslog }} + SyslogSuccess {{ .Values.opendkim.syslogSuccess }} + LogWhy {{ .Values.opendkim.logWhy }} + + Canonicalization {{ .Values.opendkim.canonicalization }} + + ExternalIgnoreList refile:/etc/opendkim/TrustedHosts + InternalHosts refile:/etc/opendkim/TrustedHosts + KeyTable refile:/etc/opendkim/KeyTable + SigningTable refile:/etc/opendkim/SigningTable + + Mode {{ .Values.opendkim.mode }} + PidFile /var/run/opendkim/opendkim.pid + SignatureAlgorithm {{ .Values.opendkim.signatureAlgorithm }} + + UserID {{ .Values.opendkim.userID }} + + Socket {{ .Values.opendkim.socket }} + + TrustedHosts: | + {{- range .Values.opendkim.trustedHosts }} + {{ . }} + {{- end }} + {{- range .Values.domains }} + {{ .domain }} + *.{{ .domain }} + {{- end }} + + SigningTable: | + {{- range .Values.domains }} + {{- $selector := default "mail" .selector }} + *@{{ .domain }} {{ $selector }}._domainkey.{{ .domain }} + *@*.{{ .domain }} {{ $selector }}._domainkey.{{ .domain }} + {{- end }} + + KeyTable: | + {{- range .Values.domains }} + {{- $selector := default "mail" .selector }} + {{ $selector }}._domainkey.{{ .domain }} {{ .domain }}:{{ $selector }}:/etc/dkimkeys/{{ .domain }}/{{ $selector }}.private + {{- end }} + + silver.yaml: | + domains: + {{- range .Values.domains }} + - domain: {{ .domain }} + dkim-selector: {{ default "mail" .selector }} + dkim-key-size: {{ default 2048 .keySize }} + {{- end }} diff --git a/mail-infra/helm/opendkim-server/templates/deployment.yaml b/mail-infra/helm/opendkim-server/templates/deployment.yaml new file mode 100644 index 0000000..6afe198 --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/deployment.yaml @@ -0,0 +1,112 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "silver-opendkim.fullname" . }} + labels: + {{- include "silver-opendkim.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate # PVC is RWO and DKIM is singleton — avoid rolling. + selector: + matchLabels: + {{- include "silver-opendkim.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "silver-opendkim.selectorLabels" . | nindent 8 }} + annotations: + # Roll the pod when the ConfigMap (and therefore opendkim.conf / + # KeyTable / SigningTable / TrustedHosts / silver.yaml) changes. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "silver-opendkim.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: opendkim + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: milter + containerPort: 8891 + protocol: TCP + volumeMounts: + - name: config + mountPath: /etc/opendkim/opendkim.conf + subPath: opendkim.conf + readOnly: true + - name: config + mountPath: /etc/opendkim/KeyTable + subPath: KeyTable + readOnly: true + - name: config + mountPath: /etc/opendkim/SigningTable + subPath: SigningTable + readOnly: true + - name: config + mountPath: /etc/opendkim/TrustedHosts + subPath: TrustedHosts + readOnly: true + - name: config + mountPath: /etc/opendkim/silver.yaml + subPath: silver.yaml + readOnly: true + - name: keys + mountPath: /etc/dkimkeys + - name: run + mountPath: /var/run/opendkim + # The entrypoint listens on :8891 once keys are present and config + # files have been loaded — use a TCP probe. + readinessProbe: + tcpSocket: + port: milter + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: milter + initialDelaySeconds: 30 + periodSeconds: 30 + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "silver-opendkim.fullname" . }}-conf + - name: keys + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "silver-opendkim.claimName" . }} + {{- else }} + emptyDir: {} + {{- end }} + - name: run + emptyDir: {} + {{- 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/opendkim-server/templates/pvc.yaml b/mail-infra/helm/opendkim-server/templates/pvc.yaml new file mode 100644 index 0000000..16f1052 --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "silver-opendkim.claimName" . }} + labels: + {{- include "silver-opendkim.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/mail-infra/helm/opendkim-server/templates/service.yaml b/mail-infra/helm/opendkim-server/templates/service.yaml new file mode 100644 index 0000000..c451fff --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "silver-opendkim.fullname" . }} + labels: + {{- include "silver-opendkim.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: milter + protocol: TCP + name: milter + selector: + {{- include "silver-opendkim.selectorLabels" . | nindent 4 }} diff --git a/mail-infra/helm/opendkim-server/templates/serviceaccount.yaml b/mail-infra/helm/opendkim-server/templates/serviceaccount.yaml new file mode 100644 index 0000000..9d829f3 --- /dev/null +++ b/mail-infra/helm/opendkim-server/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "silver-opendkim.serviceAccountName" . }} + labels: + {{- include "silver-opendkim.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/mail-infra/helm/opendkim-server/values.yaml b/mail-infra/helm/opendkim-server/values.yaml new file mode 100644 index 0000000..1758c4f --- /dev/null +++ b/mail-infra/helm/opendkim-server/values.yaml @@ -0,0 +1,121 @@ +# --------------------------------------------------------------------------- +# silver-opendkim values +# +# Sensitive values (the domain list) are intentionally empty here. Provide +# them at install/upgrade time, e.g.: +# +# helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ +# --namespace pingmailer --create-namespace \ +# -f my-domains.yaml +# +# Where my-domains.yaml contains: +# domains: +# - domain: example.com +# selector: mail +# keySize: 2048 +# --------------------------------------------------------------------------- + +nameOverride: "" +# fullnameOverride keeps the Service DNS short so Postfix can use +# `inet:opendkim-server:8891` directly. Override only if you must. +fullnameOverride: "opendkim-server" + +replicaCount: 1 # MUST stay 1 — DKIM keys live on a single-writer PVC. + +image: + repository: ghcr.io/lsflk/silver-dkim + tag: main + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# --------------------------------------------------------------------------- +# Domains. REQUIRED — leave empty here and supply via --set / -f at upgrade +# time. The chart fail()s loudly if this is empty. +# --------------------------------------------------------------------------- +domains: [] +# - domain: example.com +# selector: mail # optional, default "mail" +# keySize: 2048 # optional, default 2048 +# - domain: another.example.com + +# --------------------------------------------------------------------------- +# opendkim.conf knobs. Defaults mirror the docker-compose config. +# --------------------------------------------------------------------------- +opendkim: + mode: sv # s = sign, v = verify, sv = both + canonicalization: relaxed/simple + signatureAlgorithm: rsa-sha256 + socket: "inet:8891@0.0.0.0" + autoRestart: "Yes" + autoRestartRate: "10/1h" + umask: "002" + syslog: "yes" + syslogSuccess: "Yes" + logWhy: "Yes" + userID: "opendkim:opendkim" + # Networks/hosts trusted to be signed (in addition to your configured + # domains, which the chart appends automatically). + trustedHosts: + - 127.0.0.1 + - localhost + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + +service: + type: ClusterIP + port: 8891 + +# --------------------------------------------------------------------------- +# Persistent storage for /etc/dkimkeys. silver-dkim generates per-domain +# private keys here on first start and reuses them across restarts. +# +# Trade-off vs storing keys in a Secret: +# + Simpler — the container self-bootstraps. +# - Keys live on a PV; encryption at rest depends on your storage class. +# - Treat the PVC as sensitive and back it up out-of-band. +# +# If you prefer to manage keys as a Secret yourself, set +# persistence.existingClaim to a CSI Secret-backed claim or hostPath you +# control. +# --------------------------------------------------------------------------- +persistence: + enabled: true + existingClaim: "" + storageClass: "" + accessModes: + - ReadWriteOnce + size: 256Mi + +# silver-dkim's opendkim user is UID/GID 999. fsGroup ensures the mounted +# PVC is writable by that user so `opendkim-genkey` can persist keys. +podSecurityContext: + fsGroup: 999 + +securityContext: + runAsUser: 999 + runAsGroup: 999 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + +resources: {} + # requests: + # cpu: 50m + # memory: 64Mi + # limits: + # cpu: 200m + # memory: 128Mi + +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} + +serviceAccount: + create: true + annotations: {} + name: "" From 65adc1248d618759853cf309b9528d714c071eda Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Wed, 3 Jun 2026 14:54:35 +0530 Subject: [PATCH 15/16] feat: add Dockerfile and entrypoint script for OpenDKIM setup; update values for OpenShift compatibility --- .../opendkim-server/templates/configmap.yaml | 4 +- mail-infra/helm/opendkim-server/values.yaml | 46 ++++++---- mail-infra/images/silver-dkim/Dockerfile | 43 +++++++++ mail-infra/images/silver-dkim/entrypoint.sh | 92 +++++++++++++++++++ 4 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 mail-infra/images/silver-dkim/Dockerfile create mode 100755 mail-infra/images/silver-dkim/entrypoint.sh diff --git a/mail-infra/helm/opendkim-server/templates/configmap.yaml b/mail-infra/helm/opendkim-server/templates/configmap.yaml index e0e8ac6..15daa7a 100644 --- a/mail-infra/helm/opendkim-server/templates/configmap.yaml +++ b/mail-infra/helm/opendkim-server/templates/configmap.yaml @@ -27,8 +27,10 @@ data: Mode {{ .Values.opendkim.mode }} PidFile /var/run/opendkim/opendkim.pid SignatureAlgorithm {{ .Values.opendkim.signatureAlgorithm }} + {{- with .Values.opendkim.userID }} - UserID {{ .Values.opendkim.userID }} + UserID {{ . }} + {{- end }} Socket {{ .Values.opendkim.socket }} diff --git a/mail-infra/helm/opendkim-server/values.yaml b/mail-infra/helm/opendkim-server/values.yaml index 1758c4f..64f5059 100644 --- a/mail-infra/helm/opendkim-server/values.yaml +++ b/mail-infra/helm/opendkim-server/values.yaml @@ -23,9 +23,12 @@ fullnameOverride: "opendkim-server" replicaCount: 1 # MUST stay 1 — DKIM keys live on a single-writer PVC. image: - repository: ghcr.io/lsflk/silver-dkim - tag: main - pullPolicy: IfNotPresent + # OpenShift-compatible image: entrypoint is world-executable and writable + # dirs are group-owned by GID 0, so it runs under an arbitrary non-root UID. + # Built from mail-infra/images/silver-dkim. + repository: ghcr.io/silver-mail-platform/silver-dkim + tag: latest + pullPolicy: Always imagePullSecrets: [] @@ -53,7 +56,12 @@ opendkim: syslog: "yes" syslogSuccess: "Yes" logWhy: "Yes" - userID: "opendkim:opendkim" + # Empty omits the UserID directive so opendkim does not attempt + # setuid(opendkim) — which an arbitrary non-root UID (OpenShift) cannot do. + # The container already runs as the opendkim user on Docker/compose, so + # dropping this directive is safe on every platform. Set it back to + # "opendkim:opendkim" only if you specifically need opendkim to drop privs. + userID: "" # Networks/hosts trusted to be signed (in addition to your configured # domains, which the chart appends automatically). trustedHosts: @@ -88,27 +96,31 @@ persistence: - ReadWriteOnce size: 256Mi -# silver-dkim's opendkim user is UID/GID 999. fsGroup ensures the mounted -# PVC is writable by that user so `opendkim-genkey` can persist keys. -podSecurityContext: - fsGroup: 999 +# Left empty so OpenShift's restricted-v2 SCC can assign an fsGroup from the +# namespace's allowed range, which makes the mounted PVC group-writable by the +# arbitrary UID the pod runs as. On vanilla Kubernetes, set fsGroup: 999 here +# so the image's opendkim user can write the PVC. +podSecurityContext: {} +# runAsUser/runAsGroup are intentionally omitted: OpenShift assigns an +# arbitrary UID, and the OpenShift-compatible image runs fine under it. On +# vanilla Kubernetes the image's own USER (opendkim/999) applies. securityContext: - runAsUser: 999 - runAsGroup: 999 allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL -resources: {} - # requests: - # cpu: 50m - # memory: 64Mi - # limits: - # cpu: 200m - # memory: 128Mi +# Required: the nsw-infra-* namespaces enforce a ResourceQuota that rejects any +# container without CPU/memory requests and limits. opendkim is tiny. +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi nodeSelector: {} tolerations: [] diff --git a/mail-infra/images/silver-dkim/Dockerfile b/mail-infra/images/silver-dkim/Dockerfile new file mode 100644 index 0000000..6281637 --- /dev/null +++ b/mail-infra/images/silver-dkim/Dockerfile @@ -0,0 +1,43 @@ +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive +EXPOSE 8891 + +# non root opendkim user +RUN groupadd -r opendkim && useradd -r -g opendkim -s /usr/sbin/nologin opendkim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + opendkim \ + opendkim-tools \ + openssl \ + ca-certificates \ + curl && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Install yq for YAML parsing +RUN curl -L -o /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" && \ + chmod +x /usr/local/bin/yq + +# Directories opendkim writes to at runtime. /etc/dkimkeys is also the PVC +# mountpoint; pre-creating it here is harmless and keeps non-PVC runs working. +RUN mkdir -p /etc/opendkim /var/run/opendkim /etc/dkimkeys + +COPY ./entrypoint.sh /entrypoint.sh + +# --------------------------------------------------------------------------- +# OpenShift compatibility: the platform runs the container as an ARBITRARY, +# non-root UID, but always with GID 0 (the root group). So: +# * group-own everything by GID 0 and make writable dirs group-writable, and +# * make the entrypoint world-executable (0755) so any UID can exec it +# (the upstream image used 0750, which only UID 999 could exec -> the +# "exec /entrypoint.sh: Permission denied" CrashLoopBackOff on OpenShift). +# This keeps the image runnable both as the opendkim user (Docker/compose) +# and as an arbitrary UID (OpenShift restricted-v2 SCC). +# --------------------------------------------------------------------------- +RUN chown -R opendkim:0 /etc/opendkim /var/run/opendkim /etc/dkimkeys /entrypoint.sh && \ + chmod -R g+rwX /etc/opendkim /var/run/opendkim /etc/dkimkeys && \ + chmod 0755 /entrypoint.sh + +USER opendkim +ENTRYPOINT ["/entrypoint.sh"] diff --git a/mail-infra/images/silver-dkim/entrypoint.sh b/mail-infra/images/silver-dkim/entrypoint.sh new file mode 100755 index 0000000..ab24d5c --- /dev/null +++ b/mail-infra/images/silver-dkim/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +CONFIG_FILE="/etc/opendkim/silver.yaml" + +echo "=== Extracting domains from $CONFIG_FILE ===" + +# Extract all domains from the YAML configuration +DOMAINS=$(yq eval '.domains[].domain' "$CONFIG_FILE" 2>/dev/null | grep -v '^null$' | grep -v '^$' || echo "") + +# Fallback if no domains found +if [ -z "$DOMAINS" ]; then + echo "⚠️ Warning: Could not extract domains from $CONFIG_FILE" + echo "⚠️ Please ensure silver.yaml has domains configured" + DOMAINS="example.org" +fi + +# Get the primary domain (first one) +PRIMARY_DOMAIN=$(echo "$DOMAINS" | head -n 1) +export MAIL_DOMAIN="$PRIMARY_DOMAIN" + +echo "Primary domain: $PRIMARY_DOMAIN" +echo "Total domains to process: $(echo "$DOMAINS" | wc -l | xargs)" +echo "" + +# Process each domain +echo "=== Processing domains and generating DKIM keys ===" +while IFS= read -r DOMAIN; do + # Skip empty domains + if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then + continue + fi + + # Extract domain-specific settings or use defaults + DKIM_SELECTOR=$(yq eval ".domains[] | select(.domain == \"$DOMAIN\") | .dkim-selector" "$CONFIG_FILE" 2>/dev/null | grep -v '^null$' || echo "mail") + DKIM_KEY_SIZE=$(yq eval ".domains[] | select(.domain == \"$DOMAIN\") | .dkim-key-size" "$CONFIG_FILE" 2>/dev/null | grep -v '^null$' || echo "2048") + + # Use defaults if extraction failed + DKIM_SELECTOR=${DKIM_SELECTOR:-mail} + DKIM_KEY_SIZE=${DKIM_KEY_SIZE:-2048} + + echo "Processing domain: $DOMAIN" + echo " Selector: $DKIM_SELECTOR" + echo " Key size: $DKIM_KEY_SIZE" + + # Create domain directory + mkdir -p /etc/dkimkeys/$DOMAIN + + # Generate DKIM keys if missing + if [ ! -f /etc/dkimkeys/$DOMAIN/$DKIM_SELECTOR.private ]; then + echo " Generating DKIM keys..." + opendkim-genkey -b $DKIM_KEY_SIZE -s $DKIM_SELECTOR -d $DOMAIN -D /etc/dkimkeys/$DOMAIN/ + chmod 600 /etc/dkimkeys/$DOMAIN/$DKIM_SELECTOR.private + chmod 644 /etc/dkimkeys/$DOMAIN/$DKIM_SELECTOR.txt + echo " ✓ DKIM keys generated" + else + echo " ✓ DKIM keys already exist" + fi + echo "" +done <<< "$DOMAINS" + +# Output DKIM DNS records for all domains +echo "========== DKIM DNS Records ==========" +while IFS= read -r DOMAIN; do + if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then + continue + fi + + DKIM_SELECTOR=$(yq eval ".domains[] | select(.domain == \"$DOMAIN\") | .dkim-selector" "$CONFIG_FILE" 2>/dev/null | grep -v '^null$' || echo "mail") + DKIM_SELECTOR=${DKIM_SELECTOR:-mail} + + if [ -f /etc/dkimkeys/$DOMAIN/$DKIM_SELECTOR.txt ]; then + echo "" + echo "Domain: $DOMAIN" + echo "Record name: $DKIM_SELECTOR._domainkey.$DOMAIN" + echo "Record value:" + cat /etc/dkimkeys/$DOMAIN/$DKIM_SELECTOR.txt + echo "---" + fi +done <<< "$DOMAINS" +echo "======================================" +echo "" + +echo "=== Starting OpenDKIM ===" +echo "Configuration files:" +echo " - KeyTable: $(wc -l < /etc/opendkim/KeyTable | xargs) entries" +echo " - SigningTable: $(wc -l < /etc/opendkim/SigningTable | xargs) entries" +echo " - TrustedHosts: $(wc -l < /etc/opendkim/TrustedHosts | xargs) entries" +echo "" + +# Start OpenDKIM +exec opendkim -f -x /etc/opendkim/opendkim.conf From 8bebfa3e4aa1f5a3688fabbcbcb76525b6a490fc Mon Sep 17 00:00:00 2001 From: Aravinda-HWK Date: Sun, 7 Jun 2026 08:11:58 +0530 Subject: [PATCH 16/16] feat: add RequireSafeKeys configuration for OpenDKIM to support OpenShift compatibility --- mail-infra/helm/opendkim-server/templates/configmap.yaml | 4 ++++ mail-infra/helm/opendkim-server/values.yaml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/mail-infra/helm/opendkim-server/templates/configmap.yaml b/mail-infra/helm/opendkim-server/templates/configmap.yaml index 15daa7a..4d3ced9 100644 --- a/mail-infra/helm/opendkim-server/templates/configmap.yaml +++ b/mail-infra/helm/opendkim-server/templates/configmap.yaml @@ -27,6 +27,10 @@ data: Mode {{ .Values.opendkim.mode }} PidFile /var/run/opendkim/opendkim.pid SignatureAlgorithm {{ .Values.opendkim.signatureAlgorithm }} + {{- with .Values.opendkim.requireSafeKeys }} + + RequireSafeKeys {{ . }} + {{- end }} {{- with .Values.opendkim.userID }} UserID {{ . }} diff --git a/mail-infra/helm/opendkim-server/values.yaml b/mail-infra/helm/opendkim-server/values.yaml index 64f5059..ab263ef 100644 --- a/mail-infra/helm/opendkim-server/values.yaml +++ b/mail-infra/helm/opendkim-server/values.yaml @@ -50,6 +50,11 @@ opendkim: canonicalization: relaxed/simple signatureAlgorithm: rsa-sha256 socket: "inet:8891@0.0.0.0" + # The OpenShift-friendly image stores keys on a PVC under dirs that are + # group-writable (GID 0) so the arbitrary non-root UID can write them. + # OpenDKIM's default RequireSafeKeys=yes then refuses the key as "not + # secure" and TEMPFAILs signing (milter-reject 4.7.1). Disable the check. + requireSafeKeys: "no" autoRestart: "Yes" autoRestartRate: "10/1h" umask: "002"