Virtualmin Discourse Post-Install Manager

Virtualmin Rootless Podman Discourse: Post-Install Master Initializer

Virtualmin has recently made its Podman plugin available in dev, opening up clean containerized deployments directly within the Virtualmin control panel. However, out-of-the-box Discourse deployments currently face two critical bootstrapping hurdles:

  1. First-Run Authentication Lockout: Upon initial installation, Discourse prompts for an admin account and forces admin@[domain]. If you create this, the container cannot send out the required activation email, leaving you locked out.
  2. Outbound Crawling & Gateway Networking: Internal link previews fail, and Postfix relay authorization for the container’s gateway IP (10.88.0.x) needs manual configuration to allow outbound transactional emails.

To resolve these issues cleanly without manually digging into Rails consoles or container shell instances, I wrote an interactive Post-Install Master Initializer.


Key Features

  • Rootless Podman Instance Scanning: Autodetects rootless Podman users running Discourse web containers, mapping system owners, domain names, and container IDs automatically.
  • Database User Management: Syncs, adds, edits, or deletes Discourse users directly via internal Rails passes—instantly activating new accounts as fully approved administrators.
  • Persistent Credential Caching: Optionally caches admin credentials to a secure .env file (600 permissions) for rapid deployment across multiple containers.
  • Network & Gateway Alignment: Automatically detects container gateway IPs and updates Discourse’s allowed_internal_hosts alongside host Postfix mynetworks configurations.
  • Dry-Run & Diagnostic Logging: Inspects live database state and network configurations without altering system settings. Includes built-in log viewing (--log) and in-place binary updating (--update-script).

CLI Parameter Reference

Parameter Function
--domain DOMAIN Target a specific domain directly (bypasses the instance menu).
--user USERNAME Target a specific system owner.
--update-script In-place self-update with editor choice and automated syntax check.
--update-cache Refreshes the local discovery cache (/root/.discourse_containers.cache).
--log Displays the session log from the most recent execution pass.
--dry-run Read-only inspection of network settings and database tables.
-h, --help Displays command syntax and options.

Step 1: Bootstrapper & Installation Script

Run this initial snippet directly in Bash as root. It handles path creation, binary placement, editor detection (nano, micro, vim, vi), and launches your chosen editor so you can paste the main script binary in Step 2.

cd /tmp
clear

echo "=========================================================="
echo "    Virtualmin Discourse Initializer - Installation      "
echo "=========================================================="
echo ""
echo "Select installation target directory:"
echo "  [1] /usr/local/bin  (Recommended - Already in \$PATH)"
echo "  [2] /root/scripts   (Isolated directory)"
echo "  [3] Custom Directory"
echo ""
read -p "Selection [1-3] (Default: 1): " DIR_CHOICE

case $DIR_CHOICE in
    2) TARGET_DIR="/root/scripts" ;;
    3) read -p "Enter custom directory path: " TARGET_DIR ;;
    *) TARGET_DIR="/usr/local/bin" ;;
esac

mkdir -p "$TARGET_DIR"
SCRIPT_PATH="$TARGET_DIR/virtualmin-discourse-init.sh"

if [ -f "$SCRIPT_PATH" ]; then
    TS=$(date +%s)
    BACKUP_PATH="${TARGET_DIR}/virtualmin-discourse-init-${TS}.sh"
    mv "$SCRIPT_PATH" "$BACKUP_PATH"
    echo ""
    echo "[-] Existing script backed up to: $BACKUP_PATH"
fi

if ! echo "$PATH" \vert{} grep -E -q "(^\vert{}:)${TARGET_DIR}(:|\$)"; then
    echo ""
    echo "[!] Notice: $TARGET_DIR is not currently in your system \$PATH."
    read -p "Would you like to add $TARGET_DIR to /root/.bashrc? [y/N]: " ADD_PATH
    if [[ "$ADD_PATH" =~ ^[Yy]$ ]]; then
        echo "export PATH=\"\$PATH:$TARGET_DIR\"" >> /root/.bashrc
        export PATH="$PATH:$TARGET_DIR"
        echo "    -> Added $TARGET_DIR to /root/.bashrc"
    fi
fi

AVAILABLE_EDITORS=()
for ed in "${EDITOR:-}" nano micro vim vi emacs pico; do
    if [ -n "$ed" ] && command -v "$ed" &>/dev/null; then
        if [[ ! " ${AVAILABLE_EDITORS[*]} " =~ " ${ed} " ]]; then
            AVAILABLE_EDITORS+=("$ed")
        fi
    fi
done

CHOSEN_EDITOR="${AVAILABLE_EDITORS[0]:-nano}"

if [ ${#AVAILABLE_EDITORS[@]} -gt 1 ]; then
    echo ""
    echo "Detected text editors on your system:"
    for i in "${!AVAILABLE_EDITORS[@]}"; do
        echo "  [$((i+1))] ${AVAILABLE_EDITORS[$i]}"
    done
    echo ""
    read -p "Select editor [1-${#AVAILABLE_EDITORS[@]}] (Default: 1 - ${CHOSEN_EDITOR}): " ED_CHOICE
    if [[ "$ED_CHOICE" =~ ^[0-9]+$ ]] && [ "$ED_CHOICE" -ge 1 ] && [ "$ED_CHOICE" -le "${#AVAILABLE_EDITORS[@]}" ]; then
        CHOSEN_EDITOR="${AVAILABLE_EDITORS[$((ED_CHOICE-1))]}"
    fi
fi

echo ""
echo "[-] Launching ${CHOSEN_EDITOR} on${SCRIPT_PATH}..."
"$CHOSEN_EDITOR" "$SCRIPT_PATH" && chmod +x "$SCRIPT_PATH"

echo ""
echo "=========================================================="
echo "                   Installation Complete!                 "
echo "=========================================================="
echo " Script Location: $SCRIPT_PATH"
echo ""
echo " Usage Options:"
echo "   1. Interactive Admin & Network Tool:"
echo "      virtualmin-discourse-init.sh"
echo ""
echo "   2. Self-Update / In-Place Upgrade:"
echo "      virtualmin-discourse-init.sh --update-script"
echo ""
echo "   3. Read-Only Pass / Display Help:"
echo "      virtualmin-discourse-init.sh --dry-run"
echo "      virtualmin-discourse-init.sh --help"
echo "=========================================================="

Step 2: Main Operational Script

Paste the code block below into the editor window launched by the installation script above, then save and exit.

#!/usr/bin/env bash
set -eo pipefail

DRY_RUN=0
UPDATE_CACHE_ONLY=0
CLI_DOMAIN=""
CLI_USER=""
REAL_SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || echo "$0")"
SCRIPT_DIR="$(dirname "$REAL_SCRIPT_PATH")"
CLI_ARGS="$*"

CACHE_FILE="/root/.discourse_containers.cache"
LOG_FILE="${SCRIPT_DIR}/virtualmin-discourse-init.log"
[ "$SCRIPT_DIR" == "/usr/local/bin" ] && LOG_FILE="/root/virtualmin-discourse-init.log"

rotate_log() {
    > "$LOG_FILE"
}

log_diag() {
    echo "$@" | sed -r 's/\x1B\[[0-9;]*[mK]//g' >> "$LOG_FILE"
}

show_help() {
    echo "=========================================================="
    echo "   Virtualmin Discourse Post-Install Master Initializer   "
    echo "=========================================================="
    echo "Usage: virtualmin-discourse-init.sh [OPTIONS]"
    echo ""
    echo "Options:"
    echo "  --domain DOMAIN   Target a specific domain directly (skips selection menu)."
    echo "  --user USERNAME   Target a specific system owner (narrows menu if user owns multiple)."
    echo "  --update-script   Perform an in-place script update. Backs up running binary,"
    echo "                    launches preferred editor to paste update, and validates."
    echo "  --update-cache    Perform a fresh scan for containers, refresh local cache"
    echo "                    file ($CACHE_FILE), and exit cleanly."
    echo "  --log             Display the session log from the most recent run."
    echo "  --dry-run         Run in read-only mode to inspect container network settings,"
    echo "                    cached .env values, and Discourse database user tables."
    echo "  -h, --help        Display this help message and exit."
    echo "=========================================================="
    exit 0
}

display_last_log() {
    if [ -f "$LOG_FILE" ]; then
        echo "=========================================================="
        echo "          LAST RUN LOG FILE: $LOG_FILE"
        echo "=========================================================="
        cat "$LOG_FILE"
        echo "=========================================================="
    else
        echo "[!] No previous log file found at: $LOG_FILE"
    fi
    exit 0
}

perform_self_update() {
    echo "=========================================================="
    echo "   Virtualmin Discourse Initializer - Self-Update         "
    echo "=========================================================="
    echo ""
    echo "Current binary location: $REAL_SCRIPT_PATH"
    
    TS=$(date +%s)
    BACKUP_PATH="${REAL_SCRIPT_PATH}-${TS}.bak"
    mv "$REAL_SCRIPT_PATH" "$BACKUP_PATH"
    echo "[-] Running script backed up to: $BACKUP_PATH"
    
    AVAILABLE_EDITORS=()
    for ed in "${EDITOR:-}" nano micro vim vi emacs pico; do
        if [ -n "$ed" ] && command -v "$ed" &>/dev/null; then
            if [[ ! " ${AVAILABLE_EDITORS[*]} " =~ " ${ed} " ]]; then
                AVAILABLE_EDITORS+=("$ed")
            fi
        fi
    done
    
    CHOSEN_EDITOR="${AVAILABLE_EDITORS[0]:-nano}"
    if [ ${#AVAILABLE_EDITORS[@]} -gt 1 ]; then
        echo ""
        echo "Detected text editors:"
        for i in "${!AVAILABLE_EDITORS[@]}"; do
            echo "  [$((i+1))] ${AVAILABLE_EDITORS[$i]}"
        done
        echo ""
        read -p "Select editor [1-${#AVAILABLE_EDITORS[@]}] (Default: 1 - ${CHOSEN_EDITOR}): " ED_CHOICE
        if [[ "$ED_CHOICE" =~ ^[0-9]+$ ]] && [ "$ED_CHOICE" -ge 1 ] && [ "$ED_CHOICE" -le "${#AVAILABLE_EDITORS[@]}" ]; then
            CHOSEN_EDITOR="${AVAILABLE_EDITORS[$((ED_CHOICE-1))]}"
        fi
    fi
    
    echo ""
    echo "[-] Opening editor for update..."
    "$CHOSEN_EDITOR" "$REAL_SCRIPT_PATH" && chmod +x "$REAL_SCRIPT_PATH"
    
    echo ""
    echo "[-] Self-update complete! Verifying updated script:"
    echo "----------------------------------------------------------"
    "$REAL_SCRIPT_PATH" --help
    exit 0
}

user_podman() {
    local target_user="$1"
    shift
    local user_uid
    user_uid=$(id -u "$target_user" 2>/dev/null || true)
    
    if [ -z "$user_uid" ]; then
        log_diag "[LOG-DIAG] Failed to resolve UID for user: $target_user"
        return 1
    fi

    loginctl enable-linger "$target_user" 2>/dev/null || true
    systemctl start "user@$user_uid.service" 2>/dev/null || true

    if [ ! -d "/run/user/$user_uid" ]; then
        mkdir -p "/run/user/$user_uid"
        chown "$target_user:$target_user" "/run/user/$user_uid"
        chmod 700 "/run/user/$user_uid"
    fi

    (cd / && sudo -u "$target_user" XDG_RUNTIME_DIR="/run/user/$user_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$user_uid/bus" podman "$@")
}

scan_containers() {
    echo "[-] Scanning system for active Virtualmin Discourse instances..."
    log_diag "[-] Scanning system for active Virtualmin Discourse instances..."
    
    DISCOVERED_USERS=()
    DISCOVERED_DOMAINS=()
    DISCOVERED_CONTAINERS=()

    log_diag "[LOG-DIAG] Fetching active users..."
    local candidate_uids
    candidate_uids=$(loginctl list-users --no-legend 2>/dev/null | awk '{print $1}')
    
    if [ -z "$candidate_uids" ]; then
        log_diag "[LOG-DIAG] loginctl returned empty list. Fallback to /etc/passwd users with UID >= 1000..."
        candidate_uids=$(awk -F: '$3 >= 1000 && $3 < 60000 {print $3}' /etc/passwd)
    fi

    for user_uid in $candidate_uids; do
        username=$(id -nu "$user_uid" 2>/dev/null || true)
        if [ -n "$username" ] && [ "$username" != "root" ]; then
            log_diag "[LOG-DIAG] Testing user: $username (UID: $user_uid)..."
            
            local ps_output
            ps_output=$(user_podman "$username" ps --format "{{.Names}}" 2>&1 || true)
            log_diag "[LOG-DIAG] Podman ps result for $username:"
            log_diag "$ps_output"
            
            # Match any web container for Discourse
            while IFS= read -r web_container; do
                if [ -n "$web_container" ]; then
                    dom_name=""
                    if command -v virtualmin &>/dev/null; then
                        dom_name=$(virtualmin list-domains --user "$username" --name-only 2>/dev/null | grep -i "discourse" | head -n 1 || true)
                        if [ -z "$dom_name" ]; then
                            dom_name=$(virtualmin list-domains --user "$username" --name-only 2>/dev/null | head -n 1 || true)
                        fi
                    fi
                    [ -z "$dom_name" ] && dom_name="discourse.example.com ($username)"
                    
                    DISCOVERED_USERS+=("$username")
                    DISCOVERED_DOMAINS+=("$dom_name")
                    DISCOVERED_CONTAINERS+=("$web_container")

                    echo "    Checking user: $username ... [FOUND: $dom_name ($web_container)]"
                    log_diag "[LOG-MATCH] User $username -> Domain: $dom_name | Container: $web_container"
                fi
            done < <(echo "$ps_output" | grep -E "_discourse-web$|discourse.*web" || true)

            if ! echo "$ps_output" | grep -qE "_discourse-web$|discourse.*web"; then
                echo "    Checking user: $username ... (No Discourse container)"
            fi
        fi
    done

    if [ ${#DISCOVERED_CONTAINERS[@]} -eq 0 ]; then
        echo ""
        echo "[!] Error: No Discourse web containers detected on this server."
        log_diag "[!] Error: No Discourse web containers detected on this server."
        rm -f "$CACHE_FILE"
        exit 1
    fi

    > "$CACHE_FILE"
    for i in "${!DISCOVERED_CONTAINERS[@]}"; do
        echo "${DISCOVERED_DOMAINS[$i]}|${DISCOVERED_USERS[$i]}|${DISCOVERED_CONTAINERS[$i]}" >> "$CACHE_FILE"
    done
    chmod 600 "$CACHE_FILE"
    echo ""
    echo "[+] Discovery complete! Cached ${#DISCOVERED_CONTAINERS[@]} container(s) to $CACHE_FILE"
    log_diag "[-] Updated container discovery cache: $CACHE_FILE"
}

# Parse CLI Arguments
while [[ $# -gt 0 ]]; do
    case $1 in
        --domain)
            CLI_DOMAIN="$2"
            shift 2
            ;;
        --user)
            CLI_USER="$2"
            shift 2
            ;;
        --update-script|--update)
            perform_self_update
            ;;
        --update-cache)
            UPDATE_CACHE_ONLY=1
            shift
            ;;
        --log|--display-log)
            display_last_log
            ;;
        --dry-run)
            DRY_RUN=1
            shift
            ;;
        -h|--help)
            show_help
            ;;
        *)
            shift
            ;;
    esac
done

if [ "$EUID" -ne 0 ]; then
    echo "[!] Error: This script must be run as root (or with sudo)."
    exit 1
fi

rotate_log

log_diag "=========================================================="
log_diag " Session Start     : $(date '+%Y-%m-%d %H:%M:%S %Z')"
log_diag " Executing Binary  : $REAL_SCRIPT_PATH"
log_diag " CLI Flags Passed  : ${CLI_ARGS:-[None]}"
log_diag " Target Domain Flag: ${CLI_DOMAIN:-[None]}"
log_diag " Target User Flag  : ${CLI_USER:-[None]}"
log_diag " Active Log Target : $LOG_FILE"
log_diag " Environment User  : $(whoami) (EUID: $EUID)"
log_diag " Cache File Target : $CACHE_FILE"
log_diag "=========================================================="

if [ "$UPDATE_CACHE_ONLY" -eq 1 ]; then
    scan_containers
    exit 0
fi

clear
echo "=========================================================="
if [ "$DRY_RUN" -eq 1 ]; then
    echo "    Virtualmin Discourse Initializer (DRY-RUN MODE)      "
else
    echo "    Virtualmin Discourse Post-Install Master Initializer   "
fi
echo "=========================================================="
echo ""

load_cached_containers() {
    DISCOVERED_DOMAINS=()
    DISCOVERED_USERS=()
    DISCOVERED_CONTAINERS=()

    while IFS='|' read -r dom u_owner cont; do
        if [ -n "$dom" ] && [ -n "$u_owner" ] && [ -n "$cont" ]; then
            DISCOVERED_DOMAINS+=("$dom")
            DISCOVERED_USERS+=("$u_owner")
            DISCOVERED_CONTAINERS+=("$cont")
        fi
    done < "$CACHE_FILE"
}

if [ -f "$CACHE_FILE" ]; then
    echo "[-] Loading instances from discovery cache ($CACHE_FILE)..."
    log_diag "[-] Loading instances from discovery cache ($CACHE_FILE)..."
    load_cached_containers
    if [ ${#DISCOVERED_CONTAINERS[@]} -eq 0 ]; then
        echo "[!] Cache file empty or invalid. Triggering rescan..."
        log_diag "[!] Cache file empty or invalid. Triggering rescan..."
        scan_containers
        load_cached_containers
    fi
else
    scan_containers
fi

# Multi-Match Container Filtering Logic
FILTERED_INDICES=()

find_target_matches() {
    FILTERED_INDICES=()
    for i in "${!DISCOVERED_CONTAINERS[@]}"; do
        if [ -n "$CLI_DOMAIN" ] && [ -n "$CLI_USER" ]; then
            if [[ "${DISCOVERED_DOMAINS[$i]}" =~ $CLI_DOMAIN ]] && [ "${DISCOVERED_USERS[$i]}" == "$CLI_USER" ]; then
                FILTERED_INDICES+=("$i")
            fi
        elif [ -n "$CLI_DOMAIN" ]; then
            if [[ "${DISCOVERED_DOMAINS[$i]}" =~ $CLI_DOMAIN ]]; then
                FILTERED_INDICES+=("$i")
            fi
        elif [ -n "$CLI_USER" ]; then
            if [ "${DISCOVERED_USERS[$i]}" == "$CLI_USER" ]; then
                FILTERED_INDICES+=("$i")
            fi
        else
            FILTERED_INDICES+=("$i")
        fi
    done
}

if [ -n "$CLI_DOMAIN" ] || [ -n "$CLI_USER" ]; then
    echo "[-] Filtering instances using CLI parameters..."
    log_diag "[-] Filtering instances using CLI parameters..."
    find_target_matches
    
    if [ ${#FILTERED_INDICES[@]} -eq 0 ]; then
        echo "[!] No cached match found for domain '$CLI_DOMAIN' / user '$CLI_USER'. Triggering fresh rescan..."
        log_diag "[!] Rescanning due to no cache match for CLI params."
        scan_containers
        load_cached_containers
        find_target_matches
    fi

    if [ ${#FILTERED_INDICES[@]} -eq 0 ]; then
        echo "[!] Error: No Discourse containers found matching domain '$CLI_DOMAIN' or user '$CLI_USER'."
        exit 1
    fi
else
    for i in "${!DISCOVERED_CONTAINERS[@]}"; do
        FILTERED_INDICES+=("$i")
    done
fi

TARGET_INDEX="-1"

if [ ${#FILTERED_INDICES[@]} -eq 1 ]; then
    TARGET_INDEX="${FILTERED_INDICES[0]}"
    echo "[+] Uniquely identified target instance: ${DISCOVERED_DOMAINS[$TARGET_INDEX]} (Owner: ${DISCOVERED_USERS[$TARGET_INDEX]})"
    log_diag "[+] Unique target matched: ${DISCOVERED_DOMAINS[$TARGET_INDEX]}"
else
    while true; do
        echo ""
        if [ -n "$CLI_USER" ]; then
            echo "Found multiple Discourse instances owned by user '$CLI_USER':"
        else
            echo "Found the following Discourse installation(s):"
        fi
        echo "  [0] Rescan system for new containers"
        for idx in "${!FILTERED_INDICES[@]}"; do
            real_i="${FILTERED_INDICES[$idx]}"
            echo "  [$((idx+1))] Domain: ${DISCOVERED_DOMAINS[$real_i]}  |  Owner: ${DISCOVERED_USERS[$real_i]}  |  Container: ${DISCOVERED_CONTAINERS[$real_i]}"
        done
        echo ""

        read -p "Select installation number [0-${#FILTERED_INDICES[@]}] (or press ENTER to exit): " SELECTION

        if [ -z "$SELECTION" ]; then
            echo "No selection made. Exiting safely."
            exit 0
        fi

        if [ "$SELECTION" == "0" ]; then
            echo ""
            scan_containers
            load_cached_containers
            find_target_matches
            continue
        fi

        if [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ "$SELECTION" -ge 1 ] && [ "$SELECTION" -le "${#FILTERED_INDICES[@]}" ]; then
            TARGET_INDEX="${FILTERED_INDICES[$((SELECTION-1))]}"
            break
        else
            echo "[!] Invalid selection."
        fi
    done
fi

TARGET_USER="${DISCOVERED_USERS[$TARGET_INDEX]}"
TARGET_DOMAIN="${DISCOVERED_DOMAINS[$TARGET_INDEX]}"
TARGET_CONTAINER="${DISCOVERED_CONTAINERS[$TARGET_INDEX]}"
TARGET_UID=$(id -u "$TARGET_USER")

ENV_FILE=""
if [ -f "./.discourse_admin.env" ]; then
    ENV_FILE="./.discourse_admin.env"
elif [ -f "/root/.discourse_admin.env" ]; then
    ENV_FILE="/root/.discourse_admin.env"
fi

if [ -n "$ENV_FILE" ]; then
    source "$ENV_FILE"
fi

fetch_user_table() {
    echo "[-] Querying Discourse database for active users..."
    log_diag "[-] Querying Discourse database for active users..."
    user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "
    User.includes(:primary_email).order(:id).each do |u|
      em = u.primary_email&.email || 'No email'
      adm = u.admin? ? 'Yes' : 'No'
      act = u.active? ? 'Yes' : 'No'
      puts \"#{u.id}|#{u.username}|#{em}|#{adm}|#{act}\"
    end
    " 2>/dev/null || echo ""
}

USER_TABLE_RAW=$(fetch_user_table)

if [ "$DRY_RUN" -eq 1 ]; then
    echo "[-] Inspecting container gateway & host network settings..."
    log_diag "[-] Inspecting container gateway & host network settings..."
    HOST_IP=$(hostname -I | awk '{print $1}')
    GATEWAY_IP=$(user_podman "$TARGET_USER" inspect "$TARGET_CONTAINER" --format '{{.NetworkSettings.Gateway}}' || true)
    [ -z "$GATEWAY_IP" ] && GATEWAY_IP="10.88.0.1"
    
    CURRENT_HOSTS=$(user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "puts SiteSetting.allowed_internal_hosts.presence || '[Unset]'" 2>/dev/null || echo "Unknown")
    
    echo "=========================================================="
    echo "                  DRY-RUN INSPECTION REPORT                "
    echo "=========================================================="
    echo " Selected Domain     : $TARGET_DOMAIN ($TARGET_USER)"
    echo " Host Primary IP     : $HOST_IP"
    echo " Gateway IP          : $GATEWAY_IP"
    echo " Allowed Internal    : $CURRENT_HOSTS"
    echo " Config File (.env)  : ${ENV_FILE:-[Not Found]}"
    if [ -n "$ENV_FILE" ]; then
        echo "   -> Email          : $ADMIN_EMAIL"
        echo "   -> Username       : $ADMIN_USER"
        echo "   -> Password       : ${#ADMIN_PASS} characters"
    fi
    echo "----------------------------------------------------------"
    echo " Existing Users in Discourse Database:"
    printf "  %-4s | %-20s | %-30s | %-6s | %-6s\n" "ID" "Username" "Email Address" "Admin" "Active"
    echo "  ----------------------------------------------------------------------------------"
    while IFS='|' read -r uid uname uemail uadmin uactive; do
        if [ -n "$uid" ]; then
            printf "  %-4s | %-20s | %-30s | %-6s | %-6s\n" "$uid" "$uname" "$uemail" "$uadmin" "$uactive"
        fi
    done <<< "$USER_TABLE_RAW"
    echo "=========================================================="
    echo ""
    echo "Dry-run pass complete. Log written to $LOG_FILE."
    log_diag "Dry-run pass complete."
    exit 0
fi

ACTIONS_LOG=()

# Main Instance Hub Menu Loop
while true; do
    echo ""
    echo "=========================================================="
    echo "    INSTANCE OPERATIONS HUB: $TARGET_DOMAIN"
    echo "=========================================================="
    echo " System Owner : $TARGET_USER (UID $TARGET_UID)"
    echo " Web Container: $TARGET_CONTAINER"
    echo "----------------------------------------------------------"
    echo " Choose task to perform:"
    echo "   [1] Manage User Accounts (Sync / Edit / Add / Delete)"
    echo "   [2] Manage Network & Gateway Settings (Link Preview / Postfix)"
    echo "   (Press ENTER to exit)"
    echo "=========================================================="
    echo ""
    read -p "Select hub option [1-2 or ENTER]: " HUB_CHOICE

    if [ -z "$HUB_CHOICE" ]; then
        break
    fi

    case $HUB_CHOICE in
        1)
            while true; do
                USER_TABLE_RAW=$(fetch_user_table)
                
                echo ""
                echo "=========================================================="
                echo "               DISCOURSE USER DATABASE & ENV              "
                echo "=========================================================="
                if [ -n "$ENV_FILE" ]; then
                    echo " Cached .env File    : $ENV_FILE"
                    echo "   -> Default User   : $ADMIN_USER"
                    echo "   -> Default Email  : $ADMIN_EMAIL"
                    echo "   -> Password       : ${#ADMIN_PASS} characters configured"
                else
                    echo " Cached .env File    : [None found]"
                fi
                echo "----------------------------------------------------------"
                echo " Active Users in Discourse Database:"
                printf "  %-4s | %-20s | %-30s | %-6s | %-6s\n" "ID" "Username" "Email Address" "Admin" "Active"
                echo "  ----------------------------------------------------------------------------------"
                while IFS='|' read -r uid uname uemail uadmin uactive; do
                    if [ -n "$uid" ]; then
                        printf "  %-4s | %-20s | %-30s | %-6s | %-6s\n" "$uid" "$uname" "$uemail" "$uadmin" "$uactive"
                    fi
                done <<< "$USER_TABLE_RAW"
                echo "=========================================================="
                echo ""

                echo "User Management Actions:"
                echo "  [1] Sync/Provision Admin Account using .env credentials"
                echo "  [2] Edit an existing user account"
                echo "  [3] Add a new user account"
                echo "  [4] Delete a user account"
                echo "  [5] Return to Main Instance Operations Hub"
                echo ""
                read -p "Select action [1-5] (Default: 5): " USER_ACTION
                [ -z "$USER_ACTION" ] && USER_ACTION="5"

                if [ "$USER_ACTION" -eq 5 ]; then
                    break
                fi

                case $USER_ACTION in
                    1)
                        if [ -z "$ENV_FILE" ]; then
                            echo ""
                            echo "[-] No .env cached. Enter admin credentials to sync:"
                            read -p "Admin Email: " ADMIN_EMAIL
                            read -p "Admin Username: " ADMIN_USER
                            read -sp "Admin Password: " ADMIN_PASS
                            echo ""
                            
                            read -p "Save these credentials to /root/.discourse_admin.env? [y/N]: " SAVE_ENV
                            if [[ "$SAVE_ENV" =~ ^[Yy]$ ]]; then
                                cat << ENV_EOF > /root/.discourse_admin.env
ADMIN_EMAIL="$ADMIN_EMAIL"
ADMIN_USER="$ADMIN_USER"
ADMIN_PASS="$ADMIN_PASS"
ENV_EOF
                                chmod 600 /root/.discourse_admin.env
                                ENV_FILE="/root/.discourse_admin.env"
                            fi
                        fi
                        
                        echo "[-] Executing Rails pass: Syncing Admin account ($ADMIN_USER / $ADMIN_EMAIL)..."
                        log_diag "[-] Syncing Admin account ($ADMIN_USER / $ADMIN_EMAIL)"
                        user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "
                        ue = UserEmail.find_by(email: '$ADMIN_EMAIL')
                        u = ue&.user || User.find_by(username: '$ADMIN_USER') || User.new
                        u.username = '$ADMIN_USER'
                        u.email = '$ADMIN_EMAIL'
                        u.password = '$ADMIN_PASS'
                        u.active = true
                        u.admin = true
                        u.approved = true
                        u.save(validate: false)
                        rec = u.user_emails.find_by(email: '$ADMIN_EMAIL')
                        rec ? rec.update!(primary: true) : u.user_emails.create!(email: '$ADMIN_EMAIL', primary: true)
                        EmailToken.where(user_id: u.id).destroy_all
                        u.update_columns(active: true, approved: true)
                        " 2>&1 || true
                        ACTIONS_LOG+=("Synced admin account '$ADMIN_USER' ($ADMIN_EMAIL) from .env")
                        ;;

                    2)
                        read -p "Enter User ID to edit: " TARGET_UID_ARG
                        if [ -z "$TARGET_UID_ARG" ]; then
                            echo "[!] No User ID entered. Returning to menu."
                            continue
                        fi

                        echo "[-] Querying user parameters for User ID $TARGET_UID_ARG..."
                        log_diag "[-] Querying user parameters for User ID $TARGET_UID_ARG"
                        USER_INFO=$(user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "
                        u = User.find_by(id: $TARGET_UID_ARG)
                        if u
                          puts u.username + '|' + (u.primary_email&.email || '')
                        end
                        " 2>/dev/null || echo "")

                        if [ -z "$USER_INFO" ]; then
                            echo "[!] User ID $TARGET_UID_ARG not found in database."
                            continue
                        fi

                        WORK_USER=$(echo "$USER_INFO" | cut -d'|' -f1)
                        WORK_EMAIL=$(echo "$USER_INFO" | cut -d'|' -f2)
                        WORK_PASS=""

                        while true; do
                            echo ""
                            echo "--- Editing User ID $TARGET_UID_ARG ---"
                            echo " 1. Username    : ${WORK_USER:-[Not Set]}"
                            echo " 2. Email       : ${WORK_EMAIL:-[Not Set]}"
                            echo " 3. Password    : $( [ -n "$WORK_PASS" ] && echo "${#WORK_PASS} characters set" || echo "[Unchanged]" )"
                            echo "---------------------------------"
                            [ -n "$ENV_FILE" ] && echo " 0. Overwrite all fields from .env file ($ADMIN_USER / $ADMIN_EMAIL)"
                            echo " 4. Prompt for ALL THREE fields manually"
                            echo " (Press ENTER to apply changes)"
                            echo ""
                            read -p "Select option [0-4 or ENTER]: " SUB_CHOICE
                            
                            [ -z "$SUB_CHOICE" ] && break
                            
                            case $SUB_CHOICE in
                                0)
                                    if [ -n "$ENV_FILE" ]; then
                                        WORK_USER="$ADMIN_USER"
                                        WORK_EMAIL="$ADMIN_EMAIL"
                                        WORK_PASS="$ADMIN_PASS"
                                    fi
                                    ;;
                                1) read -p "Enter Username: " WORK_USER ;;
                                2) read -p "Enter Email: " WORK_EMAIL ;;
                                3) read -sp "Enter Password: " WORK_PASS; echo "" ;;
                                4)
                                    read -p "Enter Username: " WORK_USER
                                    read -p "Enter Email: " WORK_EMAIL
                                    read -sp "Enter Password: " WORK_PASS
                                    echo ""
                                    ;;
                            esac
                        done

                        echo "[-] Executing Rails pass: Applying edits to User ID $TARGET_UID_ARG..."
                        log_diag "[-] Applying edits to User ID $TARGET_UID_ARG ($WORK_USER / $WORK_EMAIL)"
                        user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "
                        u = User.find_by(id: $TARGET_UID_ARG)
                        if u
                          u.username = '$WORK_USER'
                          u.email = '$WORK_EMAIL'
                          u.password = '$WORK_PASS' if '$WORK_PASS'.length > 0
                          u.active = true
                          u.admin = true
                          u.approved = true
                          u.save(validate: false)
                          rec = u.user_emails.find_by(email: '$WORK_EMAIL')
                          rec ? rec.update!(primary: true) : u.user_emails.create!(email: '$WORK_EMAIL', primary: true)
                          EmailToken.where(user_id: u.id).destroy_all
                          u.update_columns(active: true, approved: true)
                        end
                        " 2>&1 || true
                        ACTIONS_LOG+=("Updated User ID $TARGET_UID_ARG ('$WORK_USER' / '$WORK_EMAIL')")
                        ;;

                    3)
                        echo ""
                        echo "--- Add New User ---"
                        if [ -n "$ENV_FILE" ]; then
                            echo " Cached .env Config Available: Username: $ADMIN_USER | Email: $ADMIN_EMAIL"
                            echo "  [0] Create new user using .env credentials"
                            echo "  [1] Enter new user details manually"
                            echo ""
                            read -p "Select option [0-1] (Default: 1): " ADD_CHOICE
                            [ -z "$ADD_CHOICE" ] && ADD_CHOICE="1"
                        else
                            ADD_CHOICE="1"
                        fi

                        if [ "$ADD_CHOICE" -eq 0 ] && [ -n "$ENV_FILE" ]; then
                            WORK_USER="$ADMIN_USER"
                            WORK_EMAIL="$ADMIN_EMAIL"
                            WORK_PASS="$ADMIN_PASS"
                        else
                            read -p "Enter Username: " WORK_USER
                            read -p "Enter Email: " WORK_EMAIL
                            read -sp "Enter Password: " WORK_PASS
                            echo ""
                        fi

                        if [ -z "$WORK_USER" ] || [ -z "$WORK_EMAIL" ] || [ -z "$WORK_PASS" ]; then
                            echo "[!] Error: Username, Email, and Password cannot be blank."
                            continue
                        fi

                        echo "[-] Executing Rails pass: Creating user '$WORK_USER'..."
                        log_diag "[-] Creating user '$WORK_USER' ($WORK_EMAIL)"
                        user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "
                        ue = UserEmail.find_by(email: '$WORK_EMAIL')
                        u = ue&.user || User.find_by(username: '$WORK_USER') || User.new
                        u.username = '$WORK_USER'
                        u.email = '$WORK_EMAIL'
                        u.password = '$WORK_PASS'
                        u.active = true
                        u.admin = true
                        u.approved = true
                        u.save(validate: false)
                        rec = u.user_emails.find_by(email: '$WORK_EMAIL')
                        rec ? rec.update!(primary: true) : u.user_emails.create!(email: '$WORK_EMAIL', primary: true)
                        EmailToken.where(user_id: u.id).destroy_all
                        u.update_columns(active: true, approved: true)
                        " 2>&1 || true
                        ACTIONS_LOG+=("Created user '$WORK_USER' ($WORK_EMAIL)")
                        ;;
                        
                    4)
                        read -p "Enter User ID to delete: " DELETE_UID
                        if [ -n "$DELETE_UID" ]; then
                            read -p "Are you sure you want to permanently delete User ID $DELETE_UID? [y/N]: " CONFIRM_DEL
                            if [[ "$CONFIRM_DEL" =~ ^[Yy]$ ]]; then
                                echo "[-] Executing Rails pass: Deleting User ID $DELETE_UID..."
                                log_diag "[-] Deleting User ID $DELETE_UID"
                                user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "
                                u = User.find_by(id: $DELETE_UID)
                                if u
                                  u.user_emails.destroy_all
                                  EmailToken.where(user_id: u.id).destroy_all
                                  u.destroy!
                                  puts 'User deleted.'
                                end
                                " 2>&1 || true
                                ACTIONS_LOG+=("Deleted User ID $DELETE_UID")
                            fi
                        fi
                        ;;
                        
                    *)
                        echo "[!] Invalid action selected."
                        ;;
                esac
            done
            ;;

        2)
            echo ""
            echo "[-] Detecting Network & Gateway Parameters..."
            log_diag "[-] Detecting Network & Gateway Parameters"
            HOST_IP=$(hostname -I | awk '{print $1}')
            GATEWAY_IP=$(user_podman "$TARGET_USER" inspect "$TARGET_CONTAINER" --format '{{.NetworkSettings.Gateway}}' || true)
            [ -z "$GATEWAY_IP" ] && GATEWAY_IP="10.88.0.1"
            TARGET_HOSTS_STRING="$GATEWAY_IP|127.0.0.1|host.containers.internal|$HOST_IP"

            echo "[-] Querying current link preview settings..."
            log_diag "[-] Querying current link preview settings"
            CURRENT_INTERNAL_HOSTS=$(user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "puts SiteSetting.allowed_internal_hosts.presence || '[Unset]'" 2>/dev/null || echo "[Unset]")
            CURRENT_POSTFIX_MYNETWORKS="N/A"
            if command -v postconf &>/dev/null; then
                CURRENT_POSTFIX_MYNETWORKS=$(postconf -h mynetworks 2>/dev/null || echo "N/A")
            fi

            echo ""
            echo "=========================================================="
            echo "               NETWORK & CRAWLING CONFIGURATION           "
            echo "=========================================================="
            echo " Current Link Preview Hosts : $CURRENT_INTERNAL_HOSTS"
            echo " Target Link Preview Hosts  : $TARGET_HOSTS_STRING"
            echo ""
            echo " Current Postfix mynetworks : $CURRENT_POSTFIX_MYNETWORKS"
            echo " Gateway IP to authorize    : $GATEWAY_IP"
            echo "=========================================================="
            echo ""

            if [ "$CURRENT_INTERNAL_HOSTS" != "$TARGET_HOSTS_STRING" ] || ! echo "$CURRENT_POSTFIX_MYNETWORKS" | grep -q "$GATEWAY_IP"; then
                read -p "Network configuration differences detected. Update networking settings now? [Y/n]: " NET_CONFIRM
                if [[ ! "$NET_CONFIRM" =~ ^[Nn]$ ]]; then
                    echo "[-] Executing Network Updates..."
                    log_diag "[-] Executing Network Updates ($TARGET_HOSTS_STRING / $GATEWAY_IP)"
                    user_podman "$TARGET_USER" exec -i "$TARGET_CONTAINER" rails r "SiteSetting.allowed_internal_hosts = '$TARGET_HOSTS_STRING'" 2>&1 || true
                    
                    if command -v postfix &>/dev/null && [ -f /etc/postfix/main.cf ]; then
                        if ! grep -q "$GATEWAY_IP" /etc/postfix/main.cf; then
                            echo "[-] Updating Postfix mynetworks parameter..."
                            log_diag "[-] Updating Postfix mynetworks parameter ($GATEWAY_IP)"
                            postconf -e "mynetworks = 127.0.0.0/8 [::1]/128 $HOST_IP/32 $GATEWAY_IP/16"
                            systemctl reload postfix
                        fi
                    fi
                    ACTIONS_LOG+=("Updated allowed_internal_hosts and Postfix mynetworks ($GATEWAY_IP)")
                    echo " -> Network settings updated successfully."
                fi
            else
                echo "[+] Network and Postfix configurations are already aligned."
            fi
            read -p "Press ENTER to return to Instance Operations Hub..."
            ;;

        *)
            echo "[!] Invalid hub selection."
            ;;
    esac
done

echo ""
echo "=========================================================="
echo "                FINAL INITIALIZATION SUMMARY              "
echo "=========================================================="
echo " Managed Target Instance: $TARGET_DOMAIN ($TARGET_USER)"
echo " Session Log Written To : $LOG_FILE"
echo ""
echo " Actions Completed During Session:"
if [ ${#ACTIONS_LOG[@]} -gt 0 ]; then
    for act in "${ACTIONS_LOG[@]}"; do
        echo "    -> $act"
    done
else
    echo "    -> Session completed with no active modifications recorded."
fi
echo "=========================================================="
echo ""
echo "Initialization completed successfully!"

Note that there are two relevant topics on the Virtualmin forums:

and