diff --git a/conf/silver.yaml b/conf/silver.yaml index 6a9741a..f6733b5 100644 --- a/conf/silver.yaml +++ b/conf/silver.yaml @@ -30,3 +30,35 @@ domains: - domain: dkim-selector: dkim-key-size: + +# =============================================================== +# Identity Provider Configuration +# =============================================================== +# +# Configure which Identity Provider (IdP) to use for user authentication +# and management. Silver supports multiple IdP backends using a pluggable +# architecture. +# +# Supported Providers: +# - thunder: Thunder (WSO2) Identity Server +# - keycloak: Keycloak Identity and Access Management +# +# Example configurations: + +# Option 1: Thunder (WSO2) - Default +identity: + provider: thunder + thunder: + host: ${MAIL_DOMAIN} # Will use the primary domain + port: 8090 + use_https: true +# Option 2: Keycloak +# identity: +# provider: keycloak +# keycloak: +# host: ${MAIL_DOMAIN} +# port: 8080 +# admin_user: +# admin_password: +# realm: silver-mail +# client_id: silver-mail-client diff --git a/scripts/service/cleanup-docker.sh b/scripts/service/cleanup-docker.sh index 9e2ad3b..50932c7 100755 --- a/scripts/service/cleanup-docker.sh +++ b/scripts/service/cleanup-docker.sh @@ -33,7 +33,7 @@ if [ "$CONFIRM" != "y" ]; then fi # Step 1: Stop all containers using docker compose -echo -e "\n${YELLOW}Step 1/3: Stopping Docker containers${NC}" +echo -e "\n${YELLOW}Step 1/4: Stopping Docker containers${NC}" echo " - Stopping Silver mail services..." (cd "${SERVICES_DIR}" && docker compose down) if [ $? -eq 0 ]; then @@ -50,8 +50,20 @@ else echo -e "${RED} ✗ Failed to stop SeaweedFS services${NC}" fi +# Stop Identity Provider services +IDP_DIR="$(cd "${SCRIPT_DIR}/../idp/docker" && pwd)" +echo " - Stopping Identity Provider services..." +for idp in thunder keycloak; do + echo " - Stopping ${idp}..." + if [ -f "${IDP_DIR}/docker-compose.${idp}.yaml" ]; then + (cd "${IDP_DIR}" && docker compose -f "docker-compose.${idp}.yaml" down) + fi +done +echo -e "${GREEN} ✓ Identity Provider services stopped${NC}" + + # Step 2: Remove all volumes -echo -e "\n${YELLOW}Step 2/3: Removing all Docker volumes${NC}" +echo -e "\n${YELLOW}Step 2/4: Removing all Docker volumes${NC}" VOLUMES=$(docker volume ls -q) if [ -n "$VOLUMES" ]; then docker volume rm $VOLUMES @@ -65,7 +77,7 @@ else fi # Step 3: Remove all images -echo -e "\n${YELLOW}Step 3/3: Removing all Docker images${NC}" +echo -e "\n${YELLOW}Step 3/4: Removing all Docker images${NC}" IMAGES=$(docker images -q) if [ -n "$IMAGES" ]; then docker rmi -f $IMAGES @@ -78,4 +90,18 @@ else echo -e "${CYAN}No images to remove${NC}" fi -echo -e "\n${GREEN}Cleanup complete!${NC}" +# Step 4: Clean up any remaining IdP containers +echo -e "\n${YELLOW}Step 4/4: Cleaning up Identity Provider containers${NC}" +IDP_CONTAINERS=$(docker ps -a --filter "name=thunder" --filter "name=keycloak" -q) +if [ -n "$IDP_CONTAINERS" ]; then + docker rm -f $IDP_CONTAINERS + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ All IdP containers removed${NC}" + else + echo -e "${RED}✗ Some IdP containers could not be removed${NC}" + fi +else + echo -e "${CYAN}No IdP containers to remove${NC}" +fi + +echo -e "\n${GREEN}Cleanup complete!${NC}" \ No newline at end of file diff --git a/scripts/service/start-silver-unified.sh b/scripts/service/start-silver-unified.sh new file mode 100644 index 0000000..0abecda --- /dev/null +++ b/scripts/service/start-silver-unified.sh @@ -0,0 +1,237 @@ +#!/bin/bash + +# ============================================ +# Silver Mail Setup Wizard (Unified) +# ============================================ +# +# This script supports pluggable Identity Providers through +# the Strategy pattern with Factory. +# +# The Identity Provider is selected from silver.yaml configuration. + +# Colors +CYAN="\033[0;36m" +GREEN="\033[0;32m" +YELLOW="\033[1;33m" +RED="\033[0;31m" +NC="\033[0m" # No Color + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Services directory contains docker-compose.yaml +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" +# IdP directory +IDP_DIR="$(cd "${SCRIPT_DIR}/../idp" && pwd)" + +# ================================ +# Helper: Get IdP Port from Config +# ================================ +get_idp_port_from_config() { + local config_file="$1" + local provider_name="$2" + + # Extract port from YAML based on provider + case "$provider_name" in + thunder) + echo "8090" + ;; + keycloak) + echo "8080" + ;; + *) + echo "8080" # Default + ;; + esac +} + +# ASCII Banner +echo -e "${CYAN}" +cat <<'EOF' + + + SSSSSSSSSSSSSSS iiii lllllll + SS:::::::::::::::S i::::i l:::::l +S:::::SSSSSS::::::S iiii l:::::l +S:::::S SSSSSSS l:::::l +S:::::S iiiiiii l::::lvvvvvvv vvvvvvv eeeeeeeeeeee rrrrr rrrrrrrrr +S:::::S i::::i l::::l v:::::v v:::::vee::::::::::::ee r::::rrr:::::::::r + S::::SSSS i::::i l::::l v:::::v v:::::ve::::::eeeee:::::eer:::::::::::::::::r + SS::::::SSSSS i::::i l::::l v:::::v v:::::ve::::::e e:::::err::::::rrrrr::::::r + SSS::::::::SS i::::i l::::l v:::::v v:::::v e:::::::eeeee::::::e r:::::r r:::::r + SSSSSS::::S i::::i l::::l v:::::v v:::::v e:::::::::::::::::e r:::::r rrrrrrr + S:::::S i::::i l::::l v:::::v:::::v e::::::eeeeeeeeeee r:::::r + S:::::S i::::i l::::l v:::::::::v e:::::::e r:::::r +SSSSSSS S:::::Si::::::il::::::l v:::::::v e::::::::e r:::::r +S::::::SSSSSS:::::Si::::::il::::::l v:::::v e::::::::eeeeeeee r:::::r +S:::::::::::::::SS i::::::il::::::l v:::v ee:::::::::::::e r:::::r + SSSSSSSSSSSSSSS iiiiiiiillllllll vvv eeeeeeeeeeeeee rrrrrrr + +EOF +echo -e "${NC}" + +echo "" +echo -e " 🚀 ${GREEN}Welcome to Silver Mail System Setup${NC}" +echo "---------------------------------------------" + +MAIL_DOMAIN="" + +# ================================ +# Step 1: Domain Configuration +# ================================ +echo -e "\n${YELLOW}Step 1/5: Configure domain name${NC}" + +# Extract primary (first) domain from the domains list in silver.yaml +MAIL_DOMAIN=$(grep -m 1 '^\s*-\s*domain:' "$CONFIG_FILE" | sed 's/.*domain:\s*//' | xargs) + +# Validate if MAIL_DOMAIN is empty +if [ -z "$MAIL_DOMAIN" ]; then + echo -e "${RED}Error: Domain name is not configured or is empty. Please set it in '$CONFIG_FILE'.${NC}" + exit 1 +else + echo "Domain name found: $MAIL_DOMAIN" +fi + +if ! [[ "$MAIL_DOMAIN" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + echo -e "${RED}✗ Warning: '${MAIL_DOMAIN}' does not look like a valid domain name.${NC}" + exit 1 +fi + +# ================================ +# Step 2: Load Identity Provider +# ================================ +echo -e "\n${YELLOW}Step 2/5: Loading Identity Provider${NC}" + +# Source the IdP factory +source "${IDP_DIR}/idp-factory.sh" + +# Get provider from config +IDP_PROVIDER_NAME=$(get_provider_from_config "$CONFIG_FILE") + +if [ -z "$IDP_PROVIDER_NAME" ]; then + echo -e "${RED}✗ Failed to determine Identity Provider from configuration${NC}" + exit 1 +fi + +echo "Identity Provider: $IDP_PROVIDER_NAME" + +# Create the provider instance +if ! create_idp_provider "$IDP_PROVIDER_NAME"; then + echo -e "${RED}✗ Failed to load Identity Provider: $IDP_PROVIDER_NAME${NC}" + exit 1 +fi + +# ================================ +# Step 3: Update /etc/hosts +# ================================ +echo -e "\n${YELLOW}Step 3/5: Updating ${MAIL_DOMAIN} mapping in /etc/hosts${NC}" + +if grep -q "[[:space:]]${MAIL_DOMAIN}" /etc/hosts; then + # Replace existing entry + sudo sed -i "/^[^#]*[[:space:]]${MAIL_DOMAIN}\([[:space:]]\|$\)/s/^.*[[:space:]]${MAIL_DOMAIN}\([[:space:]]\|$\).*/127.0.0.1 ${MAIL_DOMAIN}/" /etc/hosts + echo -e "${GREEN}✓ Updated existing ${MAIL_DOMAIN} entry to 127.0.0.1${NC}" +else + # Add new if not present + echo "127.0.0.1 ${MAIL_DOMAIN}" | sudo tee -a /etc/hosts >/dev/null + echo -e "${GREEN}✓ Added ${MAIL_DOMAIN} entry to /etc/hosts${NC}" +fi + +# ================================ +# Step 4: Docker Setup +# ================================ +echo -e "\n${YELLOW}Step 4/5: Starting Docker services${NC}" + +# Check and setup SeaweedFS S3 configuration +SEAWEEDFS_CONFIG="${SERVICES_DIR}/seaweedfs/s3-config.json" +SEAWEEDFS_EXAMPLE="${SERVICES_DIR}/seaweedfs/s3-config.json.example" + +if [ ! -f "$SEAWEEDFS_CONFIG" ]; then + echo " - SeaweedFS S3 configuration not found. Creating from example..." + if [ -f "$SEAWEEDFS_EXAMPLE" ]; then + cp "$SEAWEEDFS_EXAMPLE" "$SEAWEEDFS_CONFIG" + echo -e "${YELLOW} ⚠ WARNING: Using example S3 credentials. Update ${SEAWEEDFS_CONFIG} with secure credentials!${NC}" + else + echo -e "${RED}✗ SeaweedFS example configuration not found at ${SEAWEEDFS_EXAMPLE}${NC}" + exit 1 + fi +fi + +# Start SeaweedFS services first +echo " - Starting SeaweedFS blob storage..." +(cd "${SERVICES_DIR}" && docker compose -f docker-compose.seaweedfs.yaml up -d) +if [ $? -ne 0 ]; then + echo -e "${RED}✗ SeaweedFS docker compose failed. Please check the logs.${NC}" + exit 1 +fi +echo -e "${GREEN} ✓ SeaweedFS services started${NC}" + +# Start Identity Provider using the loaded provider +if ! $IDP_INITIALIZE "$MAIL_DOMAIN"; then + echo -e "${RED}✗ Failed to initialize Identity Provider${NC}" + exit 1 +fi + +# Wait for Identity Provider to be ready +IDP_HOST="$MAIL_DOMAIN" +IDP_PORT=$(get_idp_port_from_config "$CONFIG_FILE" "$IDP_PROVIDER_NAME") + +if ! $IDP_WAIT_FOR_READY "$IDP_HOST" "$IDP_PORT"; then + echo -e "${RED}✗ Identity Provider failed to become ready${NC}" + exit 1 +fi + +# Start main Silver mail services +echo " - Starting Silver mail services..." +(cd "${SERVICES_DIR}" && docker compose up -d) +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Docker compose failed. Please check the logs.${NC}" + exit 1 +fi +echo -e "${GREEN} ✓ Silver mail services started${NC}" + +sleep 1 # Wait a bit for services to initialize + +# ================================ +# Step 5: Configure Identity Provider +# ================================ +echo -e "\n${YELLOW}Step 5/5: Configuring Identity Provider${NC}" + +if ! $IDP_CONFIGURE "$MAIL_DOMAIN"; then + echo -e "${RED}✗ Failed to configure Identity Provider${NC}" + exit 1 +fi + +# ================================ +# Public DKIM Key Instructions +# ================================ +chmod +x "${SCRIPT_DIR}/../utils/get-dkim.sh" +(cd "${SCRIPT_DIR}/../utils" && ./get-dkim.sh) + +# ================================ +# Generate RSPAMD worker-controller.inc +# ================================ +chmod +x "${SCRIPT_DIR}/../utils/generate-rspamd-worker-controller.sh" +(cd "${SCRIPT_DIR}/../utils" && ./generate-rspamd-worker-controller.sh) + +# ================================ +# Final Success Message +# ================================ +echo "" +echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN}✓ Silver Mail System is now running!${NC}" +echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" +echo "" +echo -e "${CYAN}Configuration:${NC}" +echo -e " Domain: ${MAIL_DOMAIN}" +echo -e " Identity Provider: ${IDP_PROVIDER_NAME}" +echo "" +echo -e "Next steps:" +echo -e " 1. Access your IdP admin console to manage users" +echo -e " 2. Configure your mail client to connect to ${MAIL_DOMAIN}" +echo -e " 3. Create email users through IdP admin interface" +echo -e " 4. Check service logs: ${YELLOW}docker compose logs -f${NC}" +echo "" +echo -e "${CYAN}For more information, check the documentation.${NC}" +echo "" \ No newline at end of file diff --git a/scripts/utils/keycloak-auth.sh b/scripts/utils/keycloak-auth.sh new file mode 100644 index 0000000..914df8e --- /dev/null +++ b/scripts/utils/keycloak-auth.sh @@ -0,0 +1,371 @@ +#!/bin/bash + +# ============================================ +# Keycloak Authentication Utility +# ============================================ +# +# This utility provides shared authentication functions for Keycloak API. +# Source this file in your scripts to use the authentication functions. +# +# Usage: +# source "$(dirname "$0")/../utils/keycloak-auth.sh" +# keycloak_authenticate "$KEYCLOAK_HOST" "$KEYCLOAK_PORT" "$KEYCLOAK_REALM" +# # Now you can use: $KEYCLOAK_ACCESS_TOKEN +# +# keycloak_create_client "$KEYCLOAK_HOST" "$KEYCLOAK_PORT" "$KEYCLOAK_REALM" "$KEYCLOAK_ACCESS_TOKEN" "silver-mail" +# # Now you can use: $CLIENT_ID +# + +# Colors +CYAN="\033[0;36m" +GREEN="\033[0;32m" +YELLOW="\033[1;33m" +RED="\033[0;31m" +NC="\033[0m" # No Color + + +# ============================================ +# Function: Authenticate with Keycloak and get access token +# ============================================ +# Arguments: +# $1 - Keycloak host (e.g., "example.com") +# $2 - Keycloak port (e.g., "8080" or "8443") +# $3 - Keycloak realm (e.g., "master") +# $4 - Admin username (optional, defaults to "admin") +# $5 - Admin password (optional, defaults to "admin") +# Environment Variables: +# KEYCLOAK_USE_HTTPS - Set to "true" to use HTTPS (default: auto-detect based on port) +# KEYCLOAK_INSECURE - Set to "true" to skip SSL verification (for self-signed certs) +# Returns: +# 0 on success, 1 on failure +# Exports: +# KEYCLOAK_ACCESS_TOKEN - The authentication token +# ============================================ +keycloak_authenticate() { + local keycloak_host="$1" + local keycloak_port="$2" + local keycloak_realm="${3:-master}" + local admin_username="${4:-admin}" + local admin_password="${5:-admin}" + + if [ -z "$keycloak_host" ] || [ -z "$keycloak_port" ]; then + echo -e "${RED}✗ Keycloak host and port are required${NC}" >&2 + return 1 + fi + + # Auto-detect HTTPS based on port or environment variable + local protocol="http" + local curl_opts="" + + if [ "$keycloak_port" = "8443" ] || [ "$keycloak_port" = "443" ] || [ "${KEYCLOAK_USE_HTTPS}" = "true" ]; then + protocol="https" + # Add insecure flag for self-signed certificates if requested + if [ "${KEYCLOAK_INSECURE}" = "true" ]; then + curl_opts="-k" + fi + fi + + echo -e "${YELLOW}Authenticating with Keycloak...${NC}" + echo " - Protocol: ${protocol}" + echo " - Requesting access token from Keycloak..." + + local auth_response + auth_response=$(curl -s ${curl_opts} -w "\n%{http_code}" -X POST \ + "${protocol}://${keycloak_host}:${keycloak_port}/realms/${keycloak_realm}/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=admin-cli" \ + -d "username=${admin_username}" \ + -d "password=${admin_password}" \ + -d "grant_type=password") + + 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 Keycloak (HTTP $auth_status)${NC}" >&2 + echo "Response: $auth_body" >&2 + echo -e "${YELLOW}Note: Make sure Keycloak is running and accessible${NC}" >&2 + return 1 + fi + + # Extract access token + KEYCLOAK_ACCESS_TOKEN=$(echo "$auth_body" | grep -o '"access_token":"[^"]*' | sed 's/"access_token":"//') + + if [ -z "$KEYCLOAK_ACCESS_TOKEN" ]; then + echo -e "${RED}✗ Failed to extract access token from authentication response${NC}" >&2 + return 1 + fi + + echo -e "${GREEN} ✓ Authentication successful${NC}" + + # Export variables for use in calling script + export KEYCLOAK_ACCESS_TOKEN + export KEYCLOAK_PROTOCOL="${protocol}" + export KEYCLOAK_CURL_OPTS="${curl_opts}" + + return 0 +} + +# ============================================ +# Function: Create or get Keycloak realm +# ============================================ +# Arguments: +# $1 - Keycloak host +# $2 - Keycloak port +# $3 - Access token +# $4 - Realm name +# $5 - Realm display name +# Returns: +# 0 on success, 1 on failure +# Exports: +# REALM_NAME - The realm name +# ============================================ +keycloak_create_realm() { + local keycloak_host="$1" + local keycloak_port="$2" + local access_token="$3" + local realm_name="$4" + local realm_display_name="${5:-$realm_name}" + + if [ -z "$keycloak_host" ] || [ -z "$keycloak_port" ] || [ -z "$access_token" ] || [ -z "$realm_name" ]; then + echo -e "${RED}✗ Required parameters missing (host, port, token, realm name)${NC}" >&2 + return 1 + fi + + echo " - Creating/checking realm '${realm_name}'..." + + # Use protocol and curl options from authentication + local protocol="${KEYCLOAK_PROTOCOL:-http}" + local curl_opts="${KEYCLOAK_CURL_OPTS:-}" + + # Check if realm exists + local check_response + check_response=$(curl -s -w ${curl_opts} "\n%{http_code}" ${curl_opts} -X GET \ + "${protocol}://${keycloak_host}:${keycloak_port}/admin/realms/${realm_name}" \ + -H "Authorization: Bearer ${access_token}") + + local check_status + check_status=$(echo "$check_response" | tail -n1) + + if [ "$check_status" -eq 200 ]; then + echo -e "${GREEN} ✓ Realm '${realm_name}' already exists${NC}" + export REALM_NAME="$realm_name" + return 0 + fi + + # Create realm + local realm_response + realm_response=$(curl -s -w ${curl_opts} "\n%{http_code}" ${curl_opts} -X POST \ + "${protocol}://${keycloak_host}:${keycloak_port}/admin/realms" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${access_token}" \ + -d "{ + \"realm\": \"${realm_name}\", + \"displayName\": \"${realm_display_name}\", + \"enabled\": true, + \"registrationAllowed\": false, + \"loginWithEmailAllowed\": true, + \"duplicateEmailsAllowed\": false + }") + + local realm_body + local realm_status + realm_body=$(echo "$realm_response" | head -n -1) + realm_status=$(echo "$realm_response" | tail -n1) + + if [ "$realm_status" -ne 201 ] && [ "$realm_status" -ne 204 ]; then + echo -e "${RED}✗ Failed to create realm (HTTP $realm_status)${NC}" >&2 + echo "Response: $realm_body" >&2 + return 1 + fi + + echo -e "${GREEN} ✓ Realm '${realm_name}' created successfully${NC}" + + export REALM_NAME="$realm_name" + return 0 +} + +# ============================================ +# Function: Create Keycloak client +# ============================================ +# Arguments: +# $1 - Keycloak host +# $2 - Keycloak port +# $3 - Realm name +# $4 - Access token +# $5 - Client ID +# $6 - Client name +# Returns: +# 0 on success, 1 on failure +# Exports: +# CLIENT_UUID - The created client's UUID +# CLIENT_ID - The client ID +# ============================================ +keycloak_create_client() { + local keycloak_host="$1" + local keycloak_port="$2" + local realm_name="$3" + local access_token="$4" + local client_id="$5" + local client_name="${6:-$client_id}" + + if [ -z "$keycloak_host" ] || [ -z "$keycloak_port" ] || [ -z "$realm_name" ] || [ -z "$access_token" ] || [ -z "$client_id" ]; then + echo -e "${RED}✗ Required parameters missing (host, port, realm, token, client_id)${NC}" >&2 + return 1 + fi + + echo " - Creating client '${client_id}'..." + + # Use protocol and curl options from authentication + local protocol="${KEYCLOAK_PROTOCOL:-http}" + local curl_opts="${KEYCLOAK_CURL_OPTS:-}" + + # Check if client already exists + local check_response + check_response=$(curl -s -w "\n%{http_code}" ${curl_opts} -X GET \ + "${protocol}://${keycloak_host}:${keycloak_port}/admin/realms/${realm_name}/clients?clientId=${client_id}" \ + -H "Authorization: Bearer ${access_token}") + + local check_body + local check_status + check_body=$(echo "$check_response" | head -n -1) + check_status=$(echo "$check_response" | tail -n1) + + if [ "$check_status" -eq 200 ]; then + # Check if client exists in response + local existing_uuid + existing_uuid=$(echo "$check_body" | grep -o '"id":"[^"]*"' | head -n1 | sed 's/"id":"//;s/"//') + + if [ -n "$existing_uuid" ]; then + echo -e "${GREEN} ✓ Client '${client_id}' already exists${NC}" + export CLIENT_UUID="$existing_uuid" + export CLIENT_ID="$client_id" + return 0 + fi + fi + + # Create client + local client_response + client_response=$(curl -s -w "\n%{http_code}" ${curl_opts} -X POST \ + "${protocol}://${keycloak_host}:${keycloak_port}/admin/realms/${realm_name}/clients" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${access_token}" \ + -d "{ + \"clientId\": \"${client_id}\", + \"name\": \"${client_name}\", + \"description\": \"Silver Mail Client\", + \"enabled\": true, + \"publicClient\": false, + \"serviceAccountsEnabled\": true, + \"directAccessGrantsEnabled\": true, + \"standardFlowEnabled\": true, + \"protocol\": \"openid-connect\" + }") + + local client_body + local client_status + client_body=$(echo "$client_response" | head -n -1) + client_status=$(echo "$client_response" | tail -n1) + + if [ "$client_status" -ne 201 ] && [ "$client_status" -ne 204 ]; then + echo -e "${RED}✗ Failed to create client (HTTP $client_status)${NC}" >&2 + echo "Response: $client_body" >&2 + return 1 + fi + + # Get the created client UUID + local get_client_response + get_client_response=$(curl -s -w "\n%{http_code}" ${curl_opts} -X GET \ + "${protocol}://${keycloak_host}:${keycloak_port}/admin/realms/${realm_name}/clients?clientId=${client_id}" \ + -H "Authorization: Bearer ${access_token}") + + local get_client_body + get_client_body=$(echo "$get_client_response" | head -n -1) + + CLIENT_UUID=$(echo "$get_client_body" | grep -o '"id":"[^"]*"' | head -n1 | sed 's/"id":"//;s/"//') + + if [ -z "$CLIENT_UUID" ]; then + echo -e "${RED}✗ Failed to get client UUID${NC}" >&2 + return 1 + fi + + echo -e "${GREEN} ✓ Client '${client_id}' created successfully (UUID: $CLIENT_UUID)${NC}" + + export CLIENT_UUID + export CLIENT_ID="$client_id" + return 0 +} + +# ============================================ +# Function: Create user in Keycloak realm +# ============================================ +# Arguments: +# $1 - Keycloak host +# $2 - Keycloak port +# $3 - Realm name +# $4 - Access token +# $5 - Username +# $6 - Email +# $7 - First name (optional) +# $8 - Last name (optional) +# Returns: +# 0 on success, 1 on failure +# Exports: +# USER_ID - The created user's ID +# ============================================ +keycloak_create_user() { + local keycloak_host="$1" + local keycloak_port="$2" + local realm_name="$3" + local access_token="$4" + local username="$5" + local email="$6" + local first_name="${7:-}" + local last_name="${8:-}" + + if [ -z "$keycloak_host" ] || [ -z "$keycloak_port" ] || [ -z "$realm_name" ] || [ -z "$access_token" ] || [ -z "$username" ] || [ -z "$email" ]; then + echo -e "${RED}✗ Required parameters missing (host, port, realm, token, username, email)${NC}" >&2 + return 1 + fi + + echo " - Creating user '${username}'..." + + # Use protocol and curl options from authentication + local protocol="${KEYCLOAK_PROTOCOL:-http}" + local curl_opts="${KEYCLOAK_CURL_OPTS:-}" + + local user_response + user_response=$(curl -s -w "\n%{http_code}" ${curl_opts} -X POST \ + "${protocol}://${keycloak_host}:${keycloak_port}/admin/realms/${realm_name}/users" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${access_token}" \ + -d "{ + \"username\": \"${username}\", + \"email\": \"${email}\", + \"firstName\": \"${first_name}\", + \"lastName\": \"${last_name}\", + \"enabled\": true, + \"emailVerified\": true + }") + + local user_body + local user_status + user_body=$(echo "$user_response" | head -n -1) + user_status=$(echo "$user_response" | tail -n1) + + if [ "$user_status" -ne 201 ] && [ "$user_status" -ne 204 ]; then + # Check if user already exists + if echo "$user_body" | grep -q "User exists"; then + echo -e "${YELLOW} ⚠ User '${username}' already exists${NC}" + return 0 + fi + echo -e "${RED}✗ Failed to create user (HTTP $user_status)${NC}" >&2 + echo "Response: $user_body" >&2 + return 1 + fi + + echo -e "${GREEN} ✓ User '${username}' created successfully${NC}" + return 0 +} diff --git a/scripts/utils/shared-db-sync.sh b/scripts/utils/shared-db-sync.sh new file mode 100644 index 0000000..4ac5c77 --- /dev/null +++ b/scripts/utils/shared-db-sync.sh @@ -0,0 +1,285 @@ +#!/bin/bash + +# ============================================ +# Shared Database Utility for Keycloak +# ============================================ +# +# This utility synchronizes users between Keycloak and the shared.db +# used by Raven (IMAP/SMTP server) +# + +# Colors +CYAN="\033[0;36m" +GREEN="\033[0;32m" +YELLOW="\033[1;33m" +RED="\033[0;31m" +NC="\033[0m" # No Color + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICES_DIR="$(cd "${SCRIPT_DIR}/../../services" && pwd)" +CONF_DIR="$(cd "${SCRIPT_DIR}/../../conf" && pwd)" +CONFIG_FILE="${CONF_DIR}/silver.yaml" + +# Database path inside container +DB_PATH='/app/data/databases/shared.db' + +# SMTP container name +SMTP_CONTAINER="smtp-server-container" + +# ============================================ +# Function: Check if SMTP container is running +# ============================================ +check_smtp_container() { + if ! docker ps | grep -q "$SMTP_CONTAINER"; then + echo -e "${RED}✗ SMTP container is not running${NC}" >&2 + echo "Please start the services first" >&2 + return 1 + fi + return 0 +} + +# ============================================ +# Function: Add user to shared.db +# ============================================ +# Arguments: +# $1 - Username +# $2 - Domain +# Returns: +# 0 on success, 1 on failure +# ============================================ +db_add_user() { + local username="$1" + local domain="$2" + + if [ -z "$username" ] || [ -z "$domain" ]; then + echo -e "${RED}✗ Username and domain are required${NC}" >&2 + return 1 + fi + + if ! check_smtp_container; then + return 1 + fi + + echo " - Adding user to shared.db: ${username}@${domain}" + + local result + result=$(docker exec "$SMTP_CONTAINER" bash -c " + # Get domain_id + domain_id=\$(sqlite3 '$DB_PATH' \"SELECT id FROM domains WHERE domain='$domain' AND enabled=1;\") + + if [ -z \"\$domain_id\" ]; then + echo 'ERROR: Domain $domain not found in database' + exit 1 + fi + + # Check if user already exists + user_exists=\$(sqlite3 '$DB_PATH' \"SELECT COUNT(*) FROM users WHERE username='$username' AND domain_id=\$domain_id;\") + + if [ \"\$user_exists\" != \"0\" ]; then + echo 'INFO: User already exists, updating enabled status' + sqlite3 '$DB_PATH' \"UPDATE users SET enabled=1 WHERE username='$username' AND domain_id=\$domain_id;\" + else + # Insert user into database + sqlite3 '$DB_PATH' \"INSERT INTO users (username, domain_id, enabled) VALUES ('$username', \$domain_id, 1);\" + fi + + if [ \$? -eq 0 ]; then + echo 'SUCCESS' + else + echo 'ERROR: Failed to add user to database' + exit 1 + fi + " 2>&1) + + if echo "$result" | grep -q "SUCCESS"; then + echo -e "${GREEN} ✓ User added to shared.db successfully${NC}" + return 0 + elif echo "$result" | grep -q "INFO: User already exists"; then + echo -e "${GREEN} ✓ User already exists in shared.db (enabled)${NC}" + return 0 + else + echo -e "${RED} ✗ Failed to add user to shared.db${NC}" >&2 + echo "$result" >&2 + return 1 + fi +} + +# ============================================ +# Function: Remove user from shared.db +# ============================================ +# Arguments: +# $1 - Username +# $2 - Domain +# Returns: +# 0 on success, 1 on failure +# ============================================ +db_remove_user() { + local username="$1" + local domain="$2" + + if [ -z "$username" ] || [ -z "$domain" ]; then + echo -e "${RED}✗ Username and domain are required${NC}" >&2 + return 1 + fi + + if ! check_smtp_container; then + return 1 + fi + + echo " - Removing user from shared.db: ${username}@${domain}" + + local result + result=$(docker exec "$SMTP_CONTAINER" bash -c " + # Get domain_id + domain_id=\$(sqlite3 '$DB_PATH' \"SELECT id FROM domains WHERE domain='$domain' AND enabled=1;\") + + if [ -z \"\$domain_id\" ]; then + echo 'ERROR: Domain $domain not found in database' + exit 1 + fi + + # Disable user (soft delete) + sqlite3 '$DB_PATH' \"UPDATE users SET enabled=0 WHERE username='$username' AND domain_id=\$domain_id;\" + + if [ \$? -eq 0 ]; then + echo 'SUCCESS' + else + echo 'ERROR: Failed to disable user in database' + exit 1 + fi + " 2>&1) + + if echo "$result" | grep -q "SUCCESS"; then + echo -e "${GREEN} ✓ User disabled in shared.db successfully${NC}" + return 0 + else + echo -e "${RED} ✗ Failed to disable user in shared.db${NC}" >&2 + echo "$result" >&2 + return 1 + fi +} + +# ============================================ +# Function: List users from shared.db +# ============================================ +# Arguments: +# $1 - Domain (optional) +# Returns: +# 0 on success, 1 on failure +# ============================================ +db_list_users() { + local domain="$1" + + if ! check_smtp_container; then + return 1 + fi + + local query + if [ -n "$domain" ]; then + query="SELECT u.username, d.domain, u.enabled FROM users u INNER JOIN domains d ON u.domain_id = d.id WHERE d.domain='$domain' ORDER BY u.username;" + else + query="SELECT u.username, d.domain, u.enabled FROM users u INNER JOIN domains d ON u.domain_id = d.id ORDER BY d.domain, u.username;" + fi + + docker exec "$SMTP_CONTAINER" bash -c "sqlite3 -header -column '$DB_PATH' \"$query\"" +} + +# ============================================ +# Function: Check if user exists in shared.db +# ============================================ +# Arguments: +# $1 - Username +# $2 - Domain +# Returns: +# 0 if exists, 1 if not +# ============================================ +db_user_exists() { + local username="$1" + local domain="$2" + + if [ -z "$username" ] || [ -z "$domain" ]; then + return 1 + fi + + if ! check_smtp_container; then + return 1 + fi + + local count + count=$(docker exec "$SMTP_CONTAINER" bash -c " + domain_id=\$(sqlite3 '$DB_PATH' \"SELECT id FROM domains WHERE domain='$domain' AND enabled=1;\") + if [ -n \"\$domain_id\" ]; then + sqlite3 '$DB_PATH' \"SELECT COUNT(*) FROM users WHERE username='$username' AND domain_id=\$domain_id AND enabled=1;\" + else + echo '0' + fi + " 2>/dev/null | tr -d '\n\r') + + if [ "$count" -gt 0 ]; then + return 0 + else + return 1 + fi +} + +# ============================================ +# Function: Initialize domain in shared.db +# ============================================ +# Arguments: +# $1 - Domain +# Returns: +# 0 on success, 1 on failure +# ============================================ +db_init_domain() { + local domain="$1" + + if [ -z "$domain" ]; then + echo -e "${RED}✗ Domain is required${NC}" >&2 + return 1 + fi + + if ! check_smtp_container; then + return 1 + fi + + echo " - Initializing domain in shared.db: ${domain}" + + local result + result=$(docker exec "$SMTP_CONTAINER" bash -c " + # Check if domain exists + domain_exists=\$(sqlite3 '$DB_PATH' \"SELECT COUNT(*) FROM domains WHERE domain='$domain';\") + + if [ \"\$domain_exists\" != \"0\" ]; then + echo 'INFO: Domain already exists' + sqlite3 '$DB_PATH' \"UPDATE domains SET enabled=1 WHERE domain='$domain';\" + else + # Insert domain into database + sqlite3 '$DB_PATH' \"INSERT INTO domains (domain, enabled) VALUES ('$domain', 1);\" + fi + + if [ \$? -eq 0 ]; then + echo 'SUCCESS' + else + echo 'ERROR: Failed to initialize domain' + exit 1 + fi + " 2>&1) + + if echo "$result" | grep -q "SUCCESS\|INFO: Domain already exists"; then + echo -e "${GREEN} ✓ Domain initialized successfully${NC}" + return 0 + else + echo -e "${RED} ✗ Failed to initialize domain${NC}" >&2 + echo "$result" >&2 + return 1 + fi +} + +# Export functions for use in other scripts +export -f db_add_user +export -f db_remove_user +export -f db_list_users +export -f db_user_exists +export -f db_init_domain +export -f check_smtp_container \ No newline at end of file diff --git a/services/docker-compose.yaml b/services/docker-compose.yaml index 1b27d5f..2be447c 100644 --- a/services/docker-compose.yaml +++ b/services/docker-compose.yaml @@ -131,44 +131,6 @@ services: depends_on: - clamav-server - # Initialize database from the image - thunder-db-init: - image: ghcr.io/asgardeo/thunder:0.14.0 - container_name: thunder-db-init - command: sh -c "cp -r /opt/thunder/repository/database/* /data/" - volumes: - - thunder-db:/data - restart: "no" - - # Run setup once with the shared database - thunder-setup: - image: ghcr.io/asgardeo/thunder:0.14.0 - container_name: thunder-setup - command: ./setup.sh - volumes: - - thunder-db:/opt/thunder/repository/database - depends_on: - thunder-db-init: - condition: service_completed_successfully - restart: "no" - - # Run Thunder server with the shared database - thunder: - image: ghcr.io/asgardeo/thunder:0.14.0 - container_name: thunder-server - depends_on: - thunder-setup: - condition: service_completed_successfully - ports: - - "127.0.0.1:8090:8090" - volumes: - - thunder-db:/opt/thunder/repository/database - - ./silver-config/thunder/certs/server.cert:/opt/thunder/repository/resources/security/server.cert:ro - - ./silver-config/thunder/certs/server.key:/opt/thunder/repository/resources/security/server.key:ro - networks: - - mail-network - restart: unless-stopped - certbot-server: image: ghcr.io/lsflk/silver-certbot:main container_name: certbot-server @@ -229,7 +191,7 @@ services: - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro ports: - - "8080:8080" + - "8081:8080" networks: - mail-network restart: unless-stopped @@ -277,12 +239,11 @@ services: - HTTPS_PORT=3443 - ENABLE_HTTPS=true - ENABLE_HTTP_REDIRECT=false # Disable HTTP redirect to prevent warnings - - THUNDER_API=https://thunder-server:8090 + - KEYCLOAK_API=http://keycloak-server:8080 - SILVER_CONFIG=/etc/silver/silver.yaml networks: - mail-network depends_on: - - thunder - smtp-server restart: unless-stopped volumes: @@ -301,8 +262,7 @@ volumes: go-imap-data: loki-data: redis-data: - thunder-db: networks: - mail-network: + mail-network: \ No newline at end of file