feat: complete production backend integration

This commit is contained in:
Marek Miklewicz
2026-06-02 20:51:57 +02:00
parent d3f2fd69db
commit 4b531a4c24
20 changed files with 853 additions and 40 deletions
+160 -10
View File
@@ -3,6 +3,16 @@ set -eu
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
STATE_FILE="$BASE_DIR/config/backend-state.json"
PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder"
CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim"
CUSTOM_CONF="$CUSTOM_DIR/exim.conf"
SOURCE_CONF="/usr/local/directadmin/custombuild/configure/exim/exim.conf"
ROUTER_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_router.conf"
TRANSPORT_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_transport.conf"
ROLLBACK_ACTIVE=0
ROLLBACK_CREATED=0
ROLLBACK_REBUILD=0
ROLLBACK_FILE=
usage() {
echo "Usage: scripts/backend.sh da|exim" >&2
@@ -16,21 +26,161 @@ fi
mkdir -p "$BASE_DIR/config" "$BASE_DIR/data" "$BASE_DIR/state" "$BASE_DIR/logs"
chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs"
write_state() {
backend="$1"
printf '{"active_backend":"%s","migration_status":"complete"}\n' "$backend" > "$STATE_FILE"
chmod 600 "$STATE_FILE"
}
remove_marked_block() {
marker="$1"
file="$2"
tmp="${file}.global-autoresponder.$$"
awk -v marker="$marker" '
$0 == "# BEGIN global-autoresponder " marker { skip=1; next }
$0 == "# END global-autoresponder " marker { skip=0; next }
skip != 1 { print }
' "$file" > "$tmp"
mv "$tmp" "$file"
}
insert_after_section() {
section="$1"
snippet="$2"
file="$3"
tmp="${file}.global-autoresponder.$$"
if ! awk -v section="$section" -v snippet="$snippet" '
BEGIN {
while ((getline line < snippet) > 0) {
block = block line "\n"
}
close(snippet)
}
{ print }
$0 == section && inserted != 1 {
printf "\n%s\n", block
inserted = 1
}
END {
if (inserted != 1) {
exit 3
}
}
' "$file" > "$tmp"; then
rm -f "$tmp"
echo "Cannot install exim backend: section $section not found in custom exim.conf." >&2
exit 1
fi
mv "$tmp" "$file"
}
backup_custom_conf() {
mkdir -p "$CUSTOM_DIR"
ROLLBACK_FILE="${CUSTOM_CONF}.global-autoresponder.backup.$$"
if [ -f "$CUSTOM_CONF" ]; then
cp "$CUSTOM_CONF" "$ROLLBACK_FILE"
ROLLBACK_CREATED=0
else
: > "$ROLLBACK_FILE"
ROLLBACK_CREATED=1
fi
ROLLBACK_ACTIVE=1
}
restore_custom_conf() {
if [ "$ROLLBACK_ACTIVE" != "1" ]; then
return
fi
set +e
if [ "$ROLLBACK_CREATED" = "1" ]; then
rm -f "$CUSTOM_CONF"
else
cp "$ROLLBACK_FILE" "$CUSTOM_CONF"
fi
rm -f "$ROLLBACK_FILE"
if [ "$ROLLBACK_REBUILD" = "1" ]; then
rebuild_and_reload_exim >/dev/null 2>&1
fi
ROLLBACK_ACTIVE=0
}
commit_custom_conf() {
rm -f "$ROLLBACK_FILE"
ROLLBACK_ACTIVE=0
ROLLBACK_CREATED=0
ROLLBACK_REBUILD=0
}
trap 'restore_custom_conf' EXIT
trap 'restore_custom_conf; exit 1' HUP INT TERM
validate_exim_conf() {
if command -v exim >/dev/null 2>&1; then
exim -C "$CUSTOM_CONF" -bV >/dev/null
elif command -v exim4 >/dev/null 2>&1; then
exim4 -C "$CUSTOM_CONF" -bV >/dev/null
else
echo "Cannot validate exim backend: exim binary not found." >&2
exit 1
fi
}
rebuild_and_reload_exim() {
if command -v da >/dev/null 2>&1; then
da build exim_conf
elif [ -x /usr/local/directadmin/custombuild/build ]; then
/usr/local/directadmin/custombuild/build exim_conf
else
echo "Cannot rebuild exim configuration: da/build command not found." >&2
exit 1
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl reload exim 2>/dev/null || systemctl restart exim
else
service exim reload 2>/dev/null || service exim restart
fi
}
if [ "$1" = "da" ]; then
cat > "$STATE_FILE" <<'EOF'
{"active_backend":"da","migration_status":"complete"}
EOF
if [ -f "$CUSTOM_CONF" ]; then
backup_custom_conf
remove_marked_block "router" "$CUSTOM_CONF"
remove_marked_block "transport" "$CUSTOM_CONF"
if grep -Fq "global_autoresponder_" "$CUSTOM_CONF"; then
echo "Cannot switch to da: plugin Exim markers were not removed cleanly." >&2
exit 1
fi
validate_exim_conf
ROLLBACK_REBUILD=1
rebuild_and_reload_exim
commit_custom_conf
fi
write_state "da"
echo "Backend set to da"
exit 0
fi
CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim"
if [ ! -d "$CUSTOM_DIR" ]; then
echo "Cannot enable exim backend: safe CustomBuild exim customization directory not found." >&2
if [ ! -d "$(dirname "$SOURCE_CONF")" ]; then
echo "Cannot enable exim backend: DirectAdmin CustomBuild exim source config not found." >&2
exit 1
fi
if [ ! -f "$ROUTER_SNIPPET" ] || [ ! -f "$TRANSPORT_SNIPPET" ]; then
echo "Cannot enable exim backend: plugin Exim snippets are missing." >&2
exit 1
fi
cat > "$STATE_FILE" <<'EOF'
{"active_backend":"exim","migration_status":"pending_exim_validation"}
EOF
echo "Backend prepared for exim; validate Exim customization before production use."
backup_custom_conf
if [ ! -f "$CUSTOM_CONF" ]; then
cp "$SOURCE_CONF" "$CUSTOM_CONF"
fi
remove_marked_block "router" "$CUSTOM_CONF"
remove_marked_block "transport" "$CUSTOM_CONF"
insert_after_section "begin routers" "$ROUTER_SNIPPET" "$CUSTOM_CONF"
insert_after_section "begin transports" "$TRANSPORT_SNIPPET" "$CUSTOM_CONF"
validate_exim_conf
ROLLBACK_REBUILD=1
rebuild_and_reload_exim
commit_custom_conf
write_state "exim"
echo "Backend set to exim"
+11 -2
View File
@@ -1,2 +1,11 @@
# global-autoresponder router placeholder
# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths.
# BEGIN global-autoresponder router
# Runs an unseen delivery to the plugin worker for local recipients. Normal mailbox
# delivery continues because unseen is enabled and the worker suppresses ineligible mail.
global_autoresponder_router:
driver = accept
domains = +local_domains
condition = ${if exists{/usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php}{yes}{no}}
transport = global_autoresponder_transport
unseen
no_verify
# END global-autoresponder router
@@ -1,2 +1,13 @@
# global-autoresponder transport placeholder
# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths.
# BEGIN global-autoresponder transport
# The worker receives the original message on stdin and Exim-provided envelope
# variables such as SENDER, RECIPIENT, LOCAL_PART, and DOMAIN in the environment.
global_autoresponder_transport:
driver = pipe
command = /usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php
user = root
group = root
return_fail_output = false
log_output = true
message_prefix =
message_suffix =
# END global-autoresponder transport
+21
View File
@@ -48,6 +48,9 @@ final class GlobalAutoresponderWorker
if (!$this->isActiveRule($rule)) {
continue;
}
if (!$this->ruleIncludesRecipient($rule, $recipient)) {
continue;
}
$ruleId = (string)($rule['id'] ?? '');
if ($ruleId === '') {
continue;
@@ -81,6 +84,24 @@ final class GlobalAutoresponderWorker
return (int)($rule['start_ts'] ?? 0) <= $now && (int)($rule['end_ts'] ?? 0) >= $now;
}
/** @param array<string,mixed> $rule */
private function ruleIncludesRecipient(array $rule, string $recipient): bool
{
if (!array_key_exists('dynamic_scope', $rule) || !empty($rule['dynamic_scope'])) {
return true;
}
$snapshot = $rule['mailbox_snapshot'] ?? [];
if (!is_array($snapshot)) {
return false;
}
foreach ($snapshot as $mailbox) {
if (is_array($mailbox) && strtolower((string)($mailbox['email'] ?? '')) === strtolower($recipient)) {
return true;
}
}
return false;
}
/** @param array<string,string> $env @param string[] $keys */
private static function firstEnv(array $env, array $keys): string
{
+78 -1
View File
@@ -2,4 +2,81 @@
<?php
declare(strict_types=1);
echo "DirectAdmin vacation synchronization is executed by plugin handlers when DA backend is active.\n";
define('PLUGIN_ROOT', dirname(__DIR__));
define('PLUGIN_EXEC_DIR', PLUGIN_ROOT . '/exec');
foreach ([
'Settings.php',
'RuleRepository.php',
'DirectAdminSyncRepository.php',
'MailboxDirectory.php',
'DirectAdminVacationApi.php',
'DirectAdminVacationSyncService.php',
] as $file) {
require_once PLUGIN_EXEC_DIR . '/lib/' . $file;
}
function sync_usage(): void
{
fwrite(STDERR, "Usage: sync_directadmin_vacations.php [--user=USERNAME]\n");
}
try {
$targetUser = '';
foreach (array_slice($_SERVER['argv'] ?? [], 1) as $arg) {
if (str_starts_with($arg, '--user=')) {
$targetUser = substr($arg, 7);
continue;
}
sync_usage();
exit(2);
}
if ($targetUser === '') {
$targetUser = getenv('USERNAME') ?: getenv('USER') ?: '';
}
if ($targetUser !== '' && !preg_match('/^[A-Za-z0-9._-]+$/', $targetUser)) {
throw new InvalidArgumentException('Invalid username');
}
$settings = Settings::load(Settings::EXTERNAL_SETTINGS);
if ($settings->backendMode() !== 'da') {
echo "DirectAdmin backend is not active; nothing to sync.\n";
exit(0);
}
$users = $targetUser !== '' ? [$targetUser] : users_with_rules(Settings::DATA_DIR);
$service = new DirectAdminVacationSyncService(
new RuleRepository(),
new DirectAdminSyncRepository(),
new MailboxDirectory(),
new DirectAdminVacationApi()
);
foreach ($users as $username) {
$service->syncUser($username);
echo "Synced {$username}\n";
}
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, "DirectAdmin vacation synchronization failed: " . $e->getMessage() . "\n");
exit(1);
}
/** @return string[] */
function users_with_rules(string $baseDir): array
{
$dir = rtrim($baseDir, '/') . '/users';
if (!is_dir($dir)) {
return [];
}
$users = [];
foreach (scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || !preg_match('/^[A-Za-z0-9._-]+$/', $entry)) {
continue;
}
if (is_file($dir . '/' . $entry . '/rules.json')) {
$users[] = $entry;
}
}
sort($users);
return $users;
}