Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
commit
67c1d41a0c
82 changed files with 8713 additions and 0 deletions
50
scripts/check-queues.sh
Executable file
50
scripts/check-queues.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEPLOY_DIR="${SCRIPT_DIR}/../deployment"
|
||||
|
||||
if [ ! -d "$DEPLOY_DIR" ]; then
|
||||
echo "ERROR: deployment directory not found at ${DEPLOY_DIR}"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
compose() { docker compose "$@"; }
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose() { docker-compose "$@"; }
|
||||
else
|
||||
echo "ERROR: docker compose or docker-compose is required"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
QUEUE_NAME="${ELEKTRINE_QUEUE_NAME:-elektrine:inbound}"
|
||||
DLQ_NAME="${ELEKTRINE_DLQ_NAME:-elektrine:inbound:dlq}"
|
||||
QUEUE_WARN_THRESHOLD="${QUEUE_WARN_THRESHOLD:-1000}"
|
||||
DLQ_CRIT_THRESHOLD="${DLQ_CRIT_THRESHOLD:-10}"
|
||||
|
||||
inbound_depth=$(compose exec -T redis redis-cli LLEN "$QUEUE_NAME" | tr -d '\r')
|
||||
dlq_depth=$(compose exec -T redis redis-cli LLEN "$DLQ_NAME" | tr -d '\r')
|
||||
|
||||
if ! [[ "$inbound_depth" =~ ^[0-9]+$ ]] || ! [[ "$dlq_depth" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: failed to read queue depth (inbound=${inbound_depth}, dlq=${dlq_depth})"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "inbound_queue=${QUEUE_NAME} depth=${inbound_depth}"
|
||||
echo "dlq_queue=${DLQ_NAME} depth=${dlq_depth}"
|
||||
|
||||
if [ "$dlq_depth" -ge "$DLQ_CRIT_THRESHOLD" ]; then
|
||||
echo "CRITICAL: DLQ depth ${dlq_depth} >= ${DLQ_CRIT_THRESHOLD}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$inbound_depth" -ge "$QUEUE_WARN_THRESHOLD" ]; then
|
||||
echo "WARNING: inbound queue depth ${inbound_depth} >= ${QUEUE_WARN_THRESHOLD}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: queue depths are within thresholds"
|
||||
exit 0
|
||||
39
scripts/configure-tls.sh
Executable file
39
scripts/configure-tls.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Configure Haraka TLS with Caddy certificates
|
||||
set -e
|
||||
|
||||
DOMAIN=${HARAKA_DOMAIN:-mail.example.com}
|
||||
CERT_PATH="/app/ssl/cert.crt"
|
||||
KEY_PATH="/app/ssl/cert.key"
|
||||
|
||||
echo "Setting up TLS for $DOMAIN"
|
||||
|
||||
# Wait for certificates
|
||||
echo "Waiting for SSL certificates..."
|
||||
timeout=300
|
||||
elapsed=0
|
||||
until [ -f "$CERT_PATH" ] && [ -f "$KEY_PATH" ]; do
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout - running without SMTP TLS"
|
||||
exit 0
|
||||
fi
|
||||
echo "Still waiting... ($elapsed/${timeout}s)"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
echo "Certificates found, updating TLS config..."
|
||||
|
||||
# Write TLS config
|
||||
cat > /app/config/tls.ini << EOF
|
||||
[main]
|
||||
key=$KEY_PATH
|
||||
cert=$CERT_PATH
|
||||
|
||||
ciphers=ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
EOF
|
||||
|
||||
echo "TLS configured - SMTP encryption enabled"
|
||||
4
scripts/deploy/baremetal_deploy.sh
Executable file
4
scripts/deploy/baremetal_deploy.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker_deploy.sh" "$@"
|
||||
51
scripts/deploy/docker_deploy.sh
Executable file
51
scripts/deploy/docker_deploy.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEPLOY_DIR="$ROOT_DIR/deployment"
|
||||
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.same-server.yml"
|
||||
ENV_FILE="$DEPLOY_DIR/.env"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/deploy/docker_deploy.sh [--env-file deployment/.env] [docker compose args...]
|
||||
|
||||
Deploys elektrine-haraka for the same-host Docker layout.
|
||||
EOF
|
||||
}
|
||||
|
||||
ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env-file)
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config >/dev/null
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d redis clamav spamassassin
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d haraka-inbound haraka-submission haraka-outbound haraka-worker cert-copier
|
||||
|
||||
if [[ "${#ARGS[@]}" -gt 0 ]]; then
|
||||
exec docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "${ARGS[@]}"
|
||||
fi
|
||||
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
|
||||
304
scripts/elektrine-worker.js
Executable file
304
scripts/elektrine-worker.js
Executable file
|
|
@ -0,0 +1,304 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Elektrine async worker.
|
||||
*
|
||||
* Consumes queued inbound RFC822 messages from Redis, parses them,
|
||||
* and delivers normalized payloads to the Phoenix webhook endpoint.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
config,
|
||||
http,
|
||||
mime,
|
||||
spam,
|
||||
attachments,
|
||||
bounce,
|
||||
text,
|
||||
domains
|
||||
} = require('../lib');
|
||||
const queue = require('../lib/queue-client');
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
const cfg = config.load();
|
||||
const logger = telemetry.create_console_logger('worker');
|
||||
const queue_client = new queue.QueueClient(cfg, logger);
|
||||
|
||||
let shutting_down = false;
|
||||
const counters = {
|
||||
consumed: 0,
|
||||
delivered: 0,
|
||||
skipped_bounce: 0,
|
||||
retried: 0,
|
||||
failed: 0,
|
||||
dlq: 0
|
||||
};
|
||||
|
||||
function as_header_object(headers) {
|
||||
if (!(headers instanceof Map)) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
headers.forEach((value, key) => {
|
||||
if (value === undefined || value === null) return;
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
out[key] = typeof value === 'string' ? text.normalize_header(value) : value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
out[key] = value.map((item) => String(item)).join(', ');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value.text === 'string') {
|
||||
out[key] = text.normalize_header(value.text);
|
||||
return;
|
||||
}
|
||||
|
||||
out[key] = String(value);
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function build_spam_context(envelope) {
|
||||
const notes = {};
|
||||
if (envelope.spamassassin) {
|
||||
notes.spamassassin = {
|
||||
score: envelope.spamassassin.score,
|
||||
reqd: envelope.spamassassin.required,
|
||||
flag: envelope.spamassassin.flag,
|
||||
tests: envelope.spamassassin.tests
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
transaction: { notes },
|
||||
connection: { notes: {} }
|
||||
};
|
||||
}
|
||||
|
||||
function get_local_recipients(recipients) {
|
||||
const unique = new Set();
|
||||
|
||||
return (Array.isArray(recipients) ? recipients : [])
|
||||
.map((recipient) => text.normalize_header(recipient || ''))
|
||||
.filter((recipient) => recipient && domains.is_local_domain(domains.extract_domain(recipient)))
|
||||
.filter((recipient) => !unique.has(recipient) && unique.add(recipient));
|
||||
}
|
||||
|
||||
function build_webhook_data(envelope, parsed, recipient = null) {
|
||||
const from_email = text.normalize_header(parsed.from ? parsed.from.text : envelope.mail_from);
|
||||
const subject = text.normalize_header(parsed.subject || '');
|
||||
const text_body = cfg.include_body ? (parsed.text || '') : '';
|
||||
const html_body = cfg.include_body ? (parsed.html || '') : '';
|
||||
const local_recipients = get_local_recipients(envelope.rcpt_to);
|
||||
const effective_recipient = text.normalize_header(recipient || local_recipients[0] || (envelope.rcpt_to && envelope.rcpt_to[0]) || '');
|
||||
const to_email = text.normalize_header(parsed.to ? parsed.to.text : effective_recipient);
|
||||
const rcpt_to = effective_recipient;
|
||||
const mail_from = text.normalize_header(envelope.mail_from || '');
|
||||
|
||||
const spam_context = build_spam_context(envelope);
|
||||
const spam_data = spam.extract(spam_context.connection, spam_context.transaction, parsed.headers);
|
||||
const attachment_data = attachments.extract(null, parsed, {
|
||||
include_content: cfg.include_attachments
|
||||
});
|
||||
|
||||
return {
|
||||
message_id: envelope.message_id,
|
||||
from: from_email,
|
||||
to: to_email,
|
||||
rcpt_to: rcpt_to,
|
||||
mail_from: mail_from,
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
headers: cfg.include_headers ? as_header_object(parsed.headers) : undefined,
|
||||
spam_status: spam_data.status,
|
||||
spam_score: spam_data.score,
|
||||
spam_threshold: spam_data.threshold,
|
||||
spam_report: spam_data.report,
|
||||
spam_status_header: spam_data.status_header,
|
||||
attachments: attachment_data.attachments,
|
||||
attachment_count: attachment_data.count,
|
||||
has_attachments: attachment_data.has_attachments,
|
||||
size: envelope.data_bytes,
|
||||
timestamp: new Date().toISOString(),
|
||||
is_bounce: bounce.is_bounce(from_email, subject, text_body, {
|
||||
envelope_from: mail_from
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function send_webhook_with_retry(payload) {
|
||||
let attempt = 0;
|
||||
while (attempt <= cfg.webhook_max_retries) {
|
||||
try {
|
||||
await http.send_webhook(cfg.webhook_url, payload, {
|
||||
api_key: cfg.phoenix_api_key,
|
||||
timeout: cfg.webhook_timeout,
|
||||
headers: {
|
||||
'X-Message-Id': payload.message_id,
|
||||
'X-Idempotency-Key': payload.message_id
|
||||
},
|
||||
logger: (message) => logger.debug('http_client', { message })
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
const status = err.status || null;
|
||||
const is_permanent_4xx = status >= 400 && status < 500 && status !== 429;
|
||||
const exhausted = attempt >= cfg.webhook_max_retries;
|
||||
|
||||
if (is_permanent_4xx || exhausted) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
counters.retried += 1;
|
||||
const delay_ms = cfg.webhook_retry_base_delay_ms * Math.pow(2, attempt);
|
||||
logger.warn('webhook_retry', {
|
||||
message_id: payload.message_id,
|
||||
attempt: attempt + 1,
|
||||
status,
|
||||
delay_ms,
|
||||
message: err.message
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay_ms));
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function push_dlq(envelope, error) {
|
||||
const dlq_payload = {
|
||||
failed_at: new Date().toISOString(),
|
||||
message_id: envelope.message_id,
|
||||
error: {
|
||||
status: error.status || null,
|
||||
message: error.message
|
||||
},
|
||||
payload: envelope
|
||||
};
|
||||
|
||||
await queue_client.enqueue_dlq(cfg.queue_dlq_name, dlq_payload);
|
||||
counters.dlq += 1;
|
||||
}
|
||||
|
||||
async function process_message(raw_element) {
|
||||
counters.consumed += 1;
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(raw_element);
|
||||
} catch (err) {
|
||||
counters.failed += 1;
|
||||
logger.error('invalid_queue_payload', { message: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw_buffer = Buffer.from(envelope.raw_rfc822_base64 || '', 'base64');
|
||||
const parsed = await mime.parse_mime(raw_buffer, {
|
||||
logger: (level, message) => {
|
||||
if (level === 'warn') logger.warn('mime_parse_fallback', { message });
|
||||
}
|
||||
});
|
||||
const local_recipients = get_local_recipients(envelope.rcpt_to);
|
||||
const target_recipients = local_recipients.length > 0 ? local_recipients : [null];
|
||||
const sample_payload = build_webhook_data(envelope, parsed, target_recipients[0]);
|
||||
|
||||
if (sample_payload.is_bounce) {
|
||||
counters.skipped_bounce += 1;
|
||||
logger.info('skip_bounce', {
|
||||
message_id: sample_payload.message_id,
|
||||
subject: sample_payload.subject
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipient of target_recipients) {
|
||||
const webhook_payload = build_webhook_data(envelope, parsed, recipient);
|
||||
|
||||
await send_webhook_with_retry(webhook_payload);
|
||||
counters.delivered += 1;
|
||||
logger.info('webhook_delivered', {
|
||||
message_id: webhook_payload.message_id,
|
||||
rcpt_to: webhook_payload.rcpt_to,
|
||||
attachment_count: webhook_payload.attachment_count
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
counters.failed += 1;
|
||||
logger.error('process_failed', {
|
||||
message_id: envelope.message_id,
|
||||
status: err.status || null,
|
||||
message: err.message
|
||||
});
|
||||
|
||||
try {
|
||||
await push_dlq(envelope, err);
|
||||
} catch (dlq_err) {
|
||||
logger.error('dlq_write_failed', {
|
||||
message_id: envelope.message_id,
|
||||
message: dlq_err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!cfg.phoenix_api_key || !cfg.webhook_url) {
|
||||
logger.error('missing_required_config', {
|
||||
has_api_key: Boolean(cfg.phoenix_api_key),
|
||||
webhook_url: cfg.webhook_url || null
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('worker_start', {
|
||||
queue_name: cfg.queue_name,
|
||||
dlq_name: cfg.queue_dlq_name,
|
||||
redis_url: cfg.redis_url,
|
||||
webhook_url: cfg.webhook_url,
|
||||
max_retries: cfg.webhook_max_retries
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
logger.info('worker_stats', { ...counters });
|
||||
}, 60000);
|
||||
|
||||
while (!shutting_down) {
|
||||
try {
|
||||
const raw_element = await queue_client.pop(cfg.queue_name, cfg.queue_pop_timeout_sec);
|
||||
if (!raw_element) continue;
|
||||
await process_message(raw_element);
|
||||
} catch (err) {
|
||||
logger.error('worker_loop_error', { message: err.message });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await queue_client.close();
|
||||
logger.info('worker_stop');
|
||||
}
|
||||
|
||||
function start_shutdown(signal) {
|
||||
if (shutting_down) return;
|
||||
shutting_down = true;
|
||||
logger.warn('shutdown_signal', { signal });
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => start_shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => start_shutdown('SIGINT'));
|
||||
|
||||
run().catch((err) => {
|
||||
logger.error('worker_fatal', { message: err.message, stack: err.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
51
scripts/generate-dkim-keys.sh
Normal file
51
scripts/generate-dkim-keys.sh
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
domains=("$@")
|
||||
else
|
||||
domains=(
|
||||
example.com
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Generating DKIM keys for: ${domains[*]}"
|
||||
|
||||
mkdir -p config/dkim
|
||||
|
||||
for domain in "${domains[@]}"; do
|
||||
echo "Generating keys for ${domain}..."
|
||||
openssl genrsa -out "config/dkim/${domain}.key" 2048
|
||||
openssl rsa -in "config/dkim/${domain}.key" -pubout -out "config/dkim/${domain}.pub"
|
||||
done
|
||||
|
||||
echo "Keys generated successfully!"
|
||||
echo ""
|
||||
echo "DNS Records to add:"
|
||||
echo "==================="
|
||||
|
||||
for domain in "${domains[@]}"; do
|
||||
pubkey=$(openssl rsa -in "config/dkim/${domain}.key" -pubout -outform DER 2>/dev/null | openssl base64 -A | tr -d '\n')
|
||||
echo ""
|
||||
echo "For ${domain}:"
|
||||
echo "default._domainkey.${domain}. TXT \"v=DKIM1; k=rsa; p=${pubkey}\""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "SPF Records:"
|
||||
for domain in "${domains[@]}"; do
|
||||
echo "${domain}. TXT \"v=spf1 ip4:YOUR_VPS_IP ~all\""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "DMARC Records:"
|
||||
for domain in "${domains[@]}"; do
|
||||
echo "_dmarc.${domain}. TXT \"v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain}\""
|
||||
done
|
||||
|
||||
chmod 600 config/dkim/*.key
|
||||
chmod 644 config/dkim/*.pub
|
||||
|
||||
echo ""
|
||||
echo "Key permissions set. Private keys are now readable only by owner."
|
||||
64
scripts/init-ssl.sh
Executable file
64
scripts/init-ssl.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
|
||||
# SSL Certificate Initialization Script for elektrine-haraka
|
||||
# This script runs inside Docker and sets up Let's Encrypt SSL certificates
|
||||
|
||||
set -e
|
||||
|
||||
DOMAIN="${HARAKA_DOMAIN:-mail.example.com}"
|
||||
EMAIL="${SSL_EMAIL:-admin@example.com}"
|
||||
STAGING=${STAGING:-0}
|
||||
|
||||
echo "🔐 Setting up SSL certificates for $DOMAIN"
|
||||
|
||||
# Check if certificates already exist
|
||||
if [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then
|
||||
echo "✅ SSL certificates already exist for $DOMAIN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📋 Starting SSL certificate setup..."
|
||||
|
||||
# Wait for nginx to be ready
|
||||
sleep 10
|
||||
|
||||
# Request certificate using certbot
|
||||
echo "📄 Requesting SSL certificate..."
|
||||
if [ "$STAGING" = "1" ]; then
|
||||
echo "⚠️ Using Let's Encrypt staging environment"
|
||||
STAGING_FLAG="--staging"
|
||||
else
|
||||
STAGING_FLAG=""
|
||||
fi
|
||||
|
||||
# Use certbot with webroot method
|
||||
certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--email $EMAIL \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
--non-interactive \
|
||||
$STAGING_FLAG \
|
||||
-d $DOMAIN
|
||||
|
||||
echo "✅ SSL certificate obtained successfully!"
|
||||
|
||||
# Update Haraka TLS configuration to use the certificates
|
||||
echo "🔧 Updating Haraka TLS configuration..."
|
||||
cat > /etc/letsencrypt/haraka-tls.ini << EOF
|
||||
[main]
|
||||
# Let's Encrypt certificates (mounted from Docker volume)
|
||||
key=/app/ssl/live/$DOMAIN/privkey.pem
|
||||
cert=/app/ssl/live/$DOMAIN/fullchain.pem
|
||||
|
||||
# Security settings
|
||||
ciphers=ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
|
||||
# TLS versions
|
||||
# secureProtocol=TLSv1_2_method
|
||||
EOF
|
||||
|
||||
echo "🎉 SSL setup complete! Certificates ready for use."
|
||||
64
scripts/setup-firewall.sh
Normal file
64
scripts/setup-firewall.sh
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Firewall Setup for elektrine-haraka
|
||||
# Run this script on your host server (not in Docker)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔥 Setting up UFW firewall rules for elektrine-haraka..."
|
||||
|
||||
# Reset UFW to defaults
|
||||
sudo ufw --force reset
|
||||
|
||||
# Default policies
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
|
||||
# SSH (adjust port if needed)
|
||||
sudo ufw allow ssh
|
||||
echo "✅ SSH access allowed"
|
||||
|
||||
# HTTP/HTTPS (port 80 needed for Let's Encrypt challenges)
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
echo "✅ HTTP/HTTPS traffic allowed (port 80 required for SSL certificates)"
|
||||
|
||||
# SMTP ports
|
||||
sudo ufw allow 25/tcp
|
||||
sudo ufw allow 587/tcp
|
||||
echo "✅ SMTP traffic allowed"
|
||||
|
||||
# Rate limiting for SMTP (prevent abuse)
|
||||
sudo ufw limit 25/tcp
|
||||
sudo ufw limit 587/tcp
|
||||
echo "✅ SMTP rate limiting enabled"
|
||||
|
||||
# Allow localhost connections (for API)
|
||||
sudo ufw allow from 127.0.0.1
|
||||
sudo ufw allow from ::1
|
||||
echo "✅ Localhost traffic allowed"
|
||||
|
||||
# Optional: Allow specific IP ranges for management
|
||||
# sudo ufw allow from 192.168.1.0/24
|
||||
# sudo ufw allow from 10.0.0.0/8
|
||||
|
||||
# Enable UFW
|
||||
sudo ufw --force enable
|
||||
|
||||
# Show status
|
||||
sudo ufw status verbose
|
||||
|
||||
echo ""
|
||||
echo "🎉 Firewall setup complete!"
|
||||
echo ""
|
||||
echo "Open ports:"
|
||||
echo " 22/tcp - SSH"
|
||||
echo " 25/tcp - SMTP (rate limited)"
|
||||
echo " 80/tcp - HTTP (required for Let's Encrypt SSL certificates)"
|
||||
echo " 443/tcp - HTTPS"
|
||||
echo " 587/tcp - SMTP Submission (rate limited)"
|
||||
echo ""
|
||||
echo "All other ports are blocked by default."
|
||||
echo ""
|
||||
echo "To allow additional IPs for management:"
|
||||
echo " sudo ufw allow from YOUR.IP.ADDRESS.HERE"
|
||||
55
scripts/setup.sh
Executable file
55
scripts/setup.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
#!/bin/bash
|
||||
|
||||
# elektrine-haraka Setup Script
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../deployment"
|
||||
|
||||
echo "=== elektrine-haraka Setup ==="
|
||||
|
||||
# Check if .env file exists, if not create a template
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file template..."
|
||||
cat > .env << EOF_INNER
|
||||
HARAKA_DOMAIN=your-domain.com
|
||||
PHOENIX_API_KEY=your-haraka-to-phoenix-key
|
||||
HARAKA_HTTP_API_KEY=your-phoenix-to-haraka-key
|
||||
PHOENIX_WEBHOOK_URL=https://your-app.com/api/haraka/inbound
|
||||
PHOENIX_VERIFY_URL=https://your-app.com/api/haraka/verify-recipient
|
||||
PHOENIX_DOMAINS_URL=https://your-app.com/api/haraka/domains
|
||||
REDIS_URL=redis://redis:6379
|
||||
ELEKTRINE_QUEUE_NAME=elektrine:inbound
|
||||
ELEKTRINE_DLQ_NAME=elektrine:inbound:dlq
|
||||
EOF_INNER
|
||||
echo ""
|
||||
echo "Please edit .env file with your actual values:"
|
||||
echo " - HARAKA_DOMAIN: Your email server domain"
|
||||
echo " - PHOENIX_API_KEY: Key used by Haraka to call Phoenix endpoints"
|
||||
echo " - HARAKA_HTTP_API_KEY: Key required by Haraka HTTP API for send calls"
|
||||
echo " - PHOENIX_WEBHOOK_URL: Your Phoenix app webhook endpoint"
|
||||
echo " - PHOENIX_VERIFY_URL: Recipient verification endpoint"
|
||||
echo " - PHOENIX_DOMAINS_URL: Domain cache endpoint"
|
||||
echo ""
|
||||
echo "Then run this script again."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Starting elektrine-haraka services..."
|
||||
docker compose up -d
|
||||
|
||||
echo "Services starting up..."
|
||||
sleep 5
|
||||
|
||||
echo "Setup complete!"
|
||||
echo ""
|
||||
echo "Your email server is running at: https://$(grep HARAKA_DOMAIN .env | cut -d= -f2)"
|
||||
echo "Status endpoint: https://$(grep HARAKA_DOMAIN .env | cut -d= -f2)/status"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " docker compose logs -f haraka-inbound # View inbound logs"
|
||||
echo " docker compose logs -f haraka-submission # View submission logs"
|
||||
echo " docker compose logs -f haraka-outbound # View outbound API logs"
|
||||
echo " docker compose logs -f haraka-worker # View worker logs"
|
||||
echo " docker compose logs -f caddy # View Caddy logs"
|
||||
echo " docker compose ps # Check service status"
|
||||
echo " docker compose down # Stop all services"
|
||||
198
scripts/start-haraka.sh
Executable file
198
scripts/start-haraka.sh
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROLE="${HARAKA_ROLE:-inbound-mx}"
|
||||
SOURCE_CONFIG_DIR="/app/config"
|
||||
SOURCE_PLUGINS_DIR="/app/plugins"
|
||||
SOURCE_LIB_DIR="/app/lib"
|
||||
SOURCE_NODE_MODULES_DIR="/app/node_modules"
|
||||
RUNTIME_ROOT_DIR="/tmp/haraka-config-${ROLE}"
|
||||
RUNTIME_CONFIG_DIR="$RUNTIME_ROOT_DIR/config"
|
||||
RUNTIME_PLUGINS_DIR="$RUNTIME_ROOT_DIR/plugins"
|
||||
RUNTIME_LIB_DIR="$RUNTIME_ROOT_DIR/lib"
|
||||
RUNTIME_NODE_MODULES_DIR="$RUNTIME_ROOT_DIR/node_modules"
|
||||
PERSISTENT_DKIM_DIR="${HARAKA_DKIM_DIR:-}"
|
||||
|
||||
rm -rf "$RUNTIME_ROOT_DIR"
|
||||
mkdir -p "$RUNTIME_CONFIG_DIR"
|
||||
cp -R "$SOURCE_CONFIG_DIR"/. "$RUNTIME_CONFIG_DIR"/
|
||||
|
||||
escape_sed() {
|
||||
printf '%s' "$1" | sed 's/[\/&]/\\&/g'
|
||||
}
|
||||
|
||||
trim_value() {
|
||||
printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
append_domain() {
|
||||
candidate="$(trim_value "$1")"
|
||||
[ -n "$candidate" ] || return 0
|
||||
|
||||
case " $BOOTSTRAP_DKIM_DOMAINS " in
|
||||
*" $candidate "*) ;;
|
||||
*) BOOTSTRAP_DKIM_DOMAINS="$BOOTSTRAP_DKIM_DOMAINS $candidate" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
generate_dkim_private_key() {
|
||||
domain="$1"
|
||||
domain_dir="$2"
|
||||
private_path="$domain_dir/private"
|
||||
selector_path="$domain_dir/selector"
|
||||
|
||||
[ -f "$private_path" ] && [ -f "$selector_path" ] && return 0
|
||||
|
||||
mkdir -p "$domain_dir"
|
||||
|
||||
if [ ! -f "$private_path" ]; then
|
||||
openssl genrsa -out "$private_path" 2048 >/dev/null 2>&1
|
||||
chmod 600 "$private_path"
|
||||
fi
|
||||
|
||||
if [ ! -f "$selector_path" ]; then
|
||||
printf 'default\n' > "$selector_path"
|
||||
chmod 644 "$selector_path"
|
||||
fi
|
||||
|
||||
echo "Generated DKIM key for $domain" >&2
|
||||
}
|
||||
|
||||
HARAKA_DOMAIN_VALUE="${HARAKA_DOMAIN:-mail.example.com}"
|
||||
PHOENIX_WEBHOOK_URL_VALUE="${PHOENIX_WEBHOOK_URL:-}"
|
||||
PHOENIX_VERIFY_URL_VALUE="${PHOENIX_VERIFY_URL:-}"
|
||||
PHOENIX_DOMAINS_URL_VALUE="${PHOENIX_DOMAINS_URL:-}"
|
||||
PRIMARY_DOMAIN_VALUE="${PRIMARY_DOMAIN:-}"
|
||||
EMAIL_DOMAIN_VALUE="${EMAIL_DOMAIN:-}"
|
||||
SUPPORTED_DOMAINS_VALUE="${SUPPORTED_DOMAINS:-${EMAIL_SUPPORTED_DOMAINS:-}}"
|
||||
BOOTSTRAP_DKIM_DOMAINS=""
|
||||
|
||||
append_domain "$PRIMARY_DOMAIN_VALUE"
|
||||
append_domain "$EMAIL_DOMAIN_VALUE"
|
||||
|
||||
if [ -n "$SUPPORTED_DOMAINS_VALUE" ]; then
|
||||
OLD_IFS="$IFS"
|
||||
IFS=','
|
||||
for raw_domain in $SUPPORTED_DOMAINS_VALUE; do
|
||||
append_domain "$raw_domain"
|
||||
done
|
||||
IFS="$OLD_IFS"
|
||||
fi
|
||||
|
||||
sed -i "s/example\.com/$(escape_sed "$HARAKA_DOMAIN_VALUE")/g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
sed -i "s/app\.example\.com/host.docker.internal/g" "$RUNTIME_CONFIG_DIR/auth_proxy.ini"
|
||||
|
||||
if [ -n "$PHOENIX_WEBHOOK_URL_VALUE" ]; then
|
||||
sed -i "s#https://app\.example\.com/api/haraka/inbound#$(escape_sed "$PHOENIX_WEBHOOK_URL_VALUE")#g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
fi
|
||||
|
||||
if [ -n "$PHOENIX_VERIFY_URL_VALUE" ]; then
|
||||
sed -i "s#https://app\.example\.com/api/haraka/verify-recipient#$(escape_sed "$PHOENIX_VERIFY_URL_VALUE")#g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
fi
|
||||
|
||||
if [ -n "$PHOENIX_DOMAINS_URL_VALUE" ]; then
|
||||
sed -i "s#https://app\.example\.com/api/haraka/domains#$(escape_sed "$PHOENIX_DOMAINS_URL_VALUE")#g" "$RUNTIME_CONFIG_DIR/elektrine.ini"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$HARAKA_DOMAIN_VALUE" > "$RUNTIME_CONFIG_DIR/host_list"
|
||||
|
||||
if [ -n "$PERSISTENT_DKIM_DIR" ]; then
|
||||
mkdir -p "$PERSISTENT_DKIM_DIR"
|
||||
if [ -d "$SOURCE_CONFIG_DIR/dkim" ] && [ -z "$(ls -A "$PERSISTENT_DKIM_DIR" 2>/dev/null)" ]; then
|
||||
cp -R "$SOURCE_CONFIG_DIR"/dkim/. "$PERSISTENT_DKIM_DIR"/
|
||||
fi
|
||||
rm -rf "$RUNTIME_CONFIG_DIR/dkim"
|
||||
ln -s "$PERSISTENT_DKIM_DIR" "$RUNTIME_CONFIG_DIR/dkim"
|
||||
fi
|
||||
|
||||
if [ -d "$RUNTIME_CONFIG_DIR/dkim" ]; then
|
||||
for domain in $BOOTSTRAP_DKIM_DOMAINS; do
|
||||
generate_dkim_private_key "$domain" "$RUNTIME_CONFIG_DIR/dkim/$domain"
|
||||
done
|
||||
|
||||
for key_file in "$RUNTIME_CONFIG_DIR"/dkim/*.key; do
|
||||
[ -f "$key_file" ] || break
|
||||
domain="$(basename "$key_file" .key)"
|
||||
domain_dir="$RUNTIME_CONFIG_DIR/dkim/$domain"
|
||||
mkdir -p "$domain_dir"
|
||||
if [ ! -f "$domain_dir/private" ]; then
|
||||
cp "$key_file" "$domain_dir/private"
|
||||
fi
|
||||
if [ ! -f "$domain_dir/selector" ]; then
|
||||
printf 'default\n' > "$domain_dir/selector"
|
||||
fi
|
||||
done
|
||||
|
||||
find "$RUNTIME_CONFIG_DIR/dkim" -mindepth 1 -maxdepth 1 -type d -exec chmod 755 {} +
|
||||
find "$RUNTIME_CONFIG_DIR/dkim" -type f -name private -exec chmod 644 {} +
|
||||
find "$RUNTIME_CONFIG_DIR/dkim" -type f -name selector -exec chmod 644 {} +
|
||||
fi
|
||||
|
||||
# Haraka resolves custom plugins and local requires relative to the runtime root
|
||||
# passed with `-c`. Link shared code/dependencies there so local plugins load.
|
||||
ln -s "$SOURCE_PLUGINS_DIR" "$RUNTIME_PLUGINS_DIR"
|
||||
ln -s "$SOURCE_LIB_DIR" "$RUNTIME_LIB_DIR"
|
||||
if [ -d "$SOURCE_NODE_MODULES_DIR" ]; then
|
||||
ln -s "$SOURCE_NODE_MODULES_DIR" "$RUNTIME_NODE_MODULES_DIR"
|
||||
fi
|
||||
|
||||
PLUGINS_PROFILE="$RUNTIME_CONFIG_DIR/plugins.${ROLE}"
|
||||
SMTP_PROFILE="$RUNTIME_CONFIG_DIR/smtp.${ROLE}.ini"
|
||||
LOG_PROFILE="$RUNTIME_CONFIG_DIR/log.${ROLE}.ini"
|
||||
PLUGINS_FILE="$RUNTIME_CONFIG_DIR/plugins"
|
||||
|
||||
if [ -f "$PLUGINS_PROFILE" ]; then
|
||||
cp "$PLUGINS_PROFILE" "$PLUGINS_FILE"
|
||||
else
|
||||
echo "WARN: Role plugins profile not found: $PLUGINS_PROFILE" >&2
|
||||
if [ ! -f "$PLUGINS_FILE" ]; then
|
||||
echo "ERROR: No fallback plugins file found at $PLUGINS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "WARN: Falling back to $PLUGINS_FILE" >&2
|
||||
fi
|
||||
|
||||
# The karma plugin version in this image is incompatible with Haraka 3.1.1.
|
||||
if grep -Eq '^[[:space:]]*karma[[:space:]]*$' "$PLUGINS_FILE"; then
|
||||
echo "WARN: Removing incompatible plugin 'karma' from runtime plugins list" >&2
|
||||
awk '!/^[[:space:]]*karma[[:space:]]*$/' "$PLUGINS_FILE" > "$PLUGINS_FILE.tmp"
|
||||
mv "$PLUGINS_FILE.tmp" "$PLUGINS_FILE"
|
||||
fi
|
||||
|
||||
if [ -f "$SMTP_PROFILE" ]; then
|
||||
cp "$SMTP_PROFILE" "$RUNTIME_CONFIG_DIR/smtp.ini"
|
||||
fi
|
||||
|
||||
if [ -f "$LOG_PROFILE" ]; then
|
||||
cp "$LOG_PROFILE" "$RUNTIME_CONFIG_DIR/log.ini"
|
||||
fi
|
||||
|
||||
TLS_KEY_PATH="/app/ssl/cert.key"
|
||||
TLS_CERT_PATH="/app/ssl/cert.crt"
|
||||
|
||||
if [ ! -f "$TLS_KEY_PATH" ] || [ ! -f "$TLS_CERT_PATH" ]; then
|
||||
TLS_KEY_PATH="$RUNTIME_CONFIG_DIR/tls_key.pem"
|
||||
TLS_CERT_PATH="$RUNTIME_CONFIG_DIR/tls_cert.pem"
|
||||
TLS_DOMAIN="${HARAKA_DOMAIN:-localhost}"
|
||||
|
||||
echo "WARN: TLS certificates not found, generating temporary self-signed certificate for role=$ROLE" >&2
|
||||
openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 30 \
|
||||
-subj "/CN=$TLS_DOMAIN" \
|
||||
-keyout "$TLS_KEY_PATH" \
|
||||
-out "$TLS_CERT_PATH" >/dev/null 2>&1
|
||||
|
||||
cat > "$RUNTIME_CONFIG_DIR/tls.ini" <<EOF
|
||||
[main]
|
||||
key=$TLS_KEY_PATH
|
||||
cert=$TLS_CERT_PATH
|
||||
minVersion=TLSv1.2
|
||||
maxVersion=TLSv1.3
|
||||
requestCert=false
|
||||
rejectUnauthorized=false
|
||||
secureRenegotiation=true
|
||||
honorCipherOrder=true
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Starting Haraka with role=$ROLE"
|
||||
exec haraka -c "$RUNTIME_ROOT_DIR"
|
||||
41
scripts/sync-caddy-certs.sh
Normal file
41
scripts/sync-caddy-certs.sh
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DOMAIN="${HARAKA_DOMAIN:-mail.example.com}"
|
||||
SSL_DIR="/app/ssl"
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
find_cert_dir() {
|
||||
for base in \
|
||||
"/root/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory" \
|
||||
"/root/.local/share/caddy/certificates/acme-staging-v02.api.letsencrypt.org-directory" \
|
||||
"/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory" \
|
||||
"/data/caddy/certificates/acme-staging-v02.api.letsencrypt.org-directory"
|
||||
do
|
||||
if [ -d "$base/$DOMAIN" ]; then
|
||||
printf '%s\n' "$base/$DOMAIN"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
copy_if_present() {
|
||||
cert_dir="$(find_cert_dir || true)"
|
||||
|
||||
if [ -n "$cert_dir" ] && [ -f "$cert_dir/$DOMAIN.crt" ] && [ -f "$cert_dir/$DOMAIN.key" ]; then
|
||||
cp "$cert_dir/$DOMAIN.crt" "$SSL_DIR/cert.crt"
|
||||
cp "$cert_dir/$DOMAIN.key" "$SSL_DIR/cert.key"
|
||||
chmod 644 "$SSL_DIR/cert.crt"
|
||||
chmod 600 "$SSL_DIR/cert.key"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
while true; do
|
||||
copy_if_present || true
|
||||
sleep 15
|
||||
done
|
||||
Loading…
Add table
Add a link
Reference in a new issue