diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba9f0d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +vendor +.git +.env +.env.* +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +tests +.github +.claude +docker/production/docker-compose.prod.yml diff --git a/app/Console/Commands/SwordInit.php b/app/Console/Commands/SwordInit.php new file mode 100644 index 0000000..8498646 --- /dev/null +++ b/app/Console/Commands/SwordInit.php @@ -0,0 +1,75 @@ +argument('config-file'); + + if (! file_exists($configPath)) { + $this->error("Config file not found: {$configPath}"); + + return self::FAILURE; + } + + $config = json_decode(file_get_contents($configPath), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->error('Invalid JSON in config file: '.json_last_error_msg()); + + return self::FAILURE; + } + + $required = ['admin_name', 'admin_email', 'admin_password', 'server_ip', 'mysql_root_password', 'sudo_password', 'ssh_private_key', 'ssh_public_key']; + foreach ($required as $key) { + if (empty($config[$key])) { + $this->error("Missing required config key: {$key}"); + + return self::FAILURE; + } + } + + $user = User::firstOrCreate( + ['email' => $config['admin_email']], + [ + 'name' => $config['admin_name'], + 'password' => $config['admin_password'], + ], + ); + + $this->info("Admin user ready: {$user->email}"); + + $server = Server::firstOrCreate( + ['provider' => 'localhost'], + [ + 'user_id' => $user->id, + 'name' => 'Localhost', + 'ip_address' => $config['server_ip'], + 'hostname' => gethostname(), + 'timezone' => date_default_timezone_get(), + 'ssh_port' => 22, + 'ssh_private_key' => $config['ssh_private_key'], + 'ssh_public_key' => $config['ssh_public_key'], + 'mysql_root_password' => $config['mysql_root_password'], + 'sudo_password' => $config['sudo_password'], + 'status' => 'provisioned', + 'provisioned_at' => now(), + 'is_online' => true, + ], + ); + + $this->info("Localhost server ready: {$server->ip_address} (ID: {$server->id})"); + + return self::SUCCESS; + } +} diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile new file mode 100644 index 0000000..b0429dc --- /dev/null +++ b/docker/production/Dockerfile @@ -0,0 +1,123 @@ +# ── Stage 1: Install PHP dependencies ─────────────────── +FROM ubuntu:24.04 AS php-deps + +ENV DEBIAN_FRONTEND=noninteractive + +WORKDIR /app + +RUN apt-get update \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg curl ca-certificates zip unzip \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y \ + php8.5-cli \ + php8.5-mysql \ + php8.5-gd \ + php8.5-curl \ + php8.5-mbstring \ + php8.5-xml \ + php8.5-zip \ + php8.5-bcmath \ + php8.5-intl \ + php8.5-readline \ + php8.5-redis \ + php8.5-igbinary \ + php8.5-msgpack \ + php8.5-soap \ + php8.5-ldap \ + php8.5-imagick \ + php8.5-sqlite3 \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && rm -rf /var/lib/apt/lists/* + +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts + +COPY . . +RUN composer dump-autoload --optimize + +# ── Stage 2: Build frontend assets ───────────────────── +FROM php-deps AS assets + +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN npm ci +RUN npm run build + +# ── Stage 3: Production image ────────────────────────── +FROM ubuntu:24.04 + +LABEL maintainer="Synio" + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 + +WORKDIR /var/www/html + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update && apt-get upgrade -y \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg curl ca-certificates zip unzip supervisor nginx \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y \ + php8.5-fpm \ + php8.5-cli \ + php8.5-mysql \ + php8.5-gd \ + php8.5-curl \ + php8.5-mbstring \ + php8.5-xml \ + php8.5-zip \ + php8.5-bcmath \ + php8.5-intl \ + php8.5-readline \ + php8.5-redis \ + php8.5-igbinary \ + php8.5-msgpack \ + php8.5-soap \ + php8.5-ldap \ + php8.5-imagick \ + php8.5-sqlite3 \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Remove default nginx site +RUN rm -f /etc/nginx/sites-enabled/default + +# Copy configs +COPY docker/production/nginx.conf /etc/nginx/sites-enabled/default +COPY docker/production/php-fpm.conf /etc/php/8.5/fpm/pool.d/www.conf +COPY docker/production/supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +# Copy application with vendor from php-deps stage +COPY --from=php-deps --chown=www-data:www-data /app /var/www/html + +# Copy compiled frontend assets from assets stage +COPY --from=assets --chown=www-data:www-data /app/public/build /var/www/html/public/build + +# Ensure storage and cache dirs exist with correct permissions +RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache \ + && chmod -R 775 storage bootstrap/cache + +# Create directory for PHP-FPM socket/pid +RUN mkdir -p /run/php + +EXPOSE 8080 + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/docker/production/docker-compose.prod.yml b/docker/production/docker-compose.prod.yml new file mode 100644 index 0000000..61fa2b5 --- /dev/null +++ b/docker/production/docker-compose.prod.yml @@ -0,0 +1,29 @@ +services: + app: + image: sword-app:latest + container_name: sword_app + restart: unless-stopped + env_file: /srv/sword/app/.env + volumes: + - /srv/sword/app/storage:/var/www/html/storage + networks: + - sword_network + labels: + - traefik.enable=true + - traefik.http.routers.sword.rule=Host(`${SWORD_DOMAIN}`) + - traefik.http.routers.sword.entrypoints=websecure + - traefik.http.routers.sword.tls.certresolver=letsencrypt + - traefik.http.services.sword.loadbalancer.server.port=8080 + + redis: + image: redis:alpine + container_name: sword_redis + restart: unless-stopped + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] + networks: + - sword_network + +networks: + sword_network: + name: sword_network + external: true diff --git a/docker/production/nginx.conf b/docker/production/nginx.conf new file mode 100644 index 0000000..124dab9 --- /dev/null +++ b/docker/production/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 8080 default_server; + server_name _; + root /var/www/html/public; + + index index.php; + + charset utf-8; + + client_max_body_size 100M; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_param HTTPS on; + fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; + fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for; + fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/docker/production/php-fpm.conf b/docker/production/php-fpm.conf new file mode 100644 index 0000000..0a6ddd7 --- /dev/null +++ b/docker/production/php-fpm.conf @@ -0,0 +1,14 @@ +[www] +user = www-data +group = www-data + +listen = 127.0.0.1:9000 + +pm = dynamic +pm.max_children = 20 +pm.start_servers = 5 +pm.min_spare_servers = 2 +pm.max_spare_servers = 10 +pm.max_requests = 500 + +clear_env = no diff --git a/docker/production/supervisord.conf b/docker/production/supervisord.conf new file mode 100644 index 0000000..848e33e --- /dev/null +++ b/docker/production/supervisord.conf @@ -0,0 +1,44 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:php-fpm] +command=/usr/sbin/php-fpm8.5 -F +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:queue-worker] +command=/usr/bin/php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600 +user=www-data +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +numprocs=1 +stdout_logfile=/var/www/html/storage/logs/queue-worker.log +stderr_logfile=/var/www/html/storage/logs/queue-worker.log + +[program:scheduler] +command=/usr/bin/php /var/www/html/artisan schedule:work +user=www-data +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +stdout_logfile=/var/www/html/storage/logs/scheduler.log +stderr_logfile=/var/www/html/storage/logs/scheduler.log diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8610562 --- /dev/null +++ b/install.sh @@ -0,0 +1,545 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# SWORD Self-Hosted Installer +# Usage: curl -sL | bash +# ============================================================ + +REPO="https://github.com/SynioBE/SWORD.git" +BRANCH="${SWORD_BRANCH:-main}" +SWORD_DIR="/srv/sword" +CLONE_DIR="" + +# ── Colors ────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[SWORD]${NC} $1"; } +ok() { echo -e "${GREEN}[SWORD]${NC} $1"; } +warn() { echo -e "${YELLOW}[SWORD]${NC} $1"; } +fail() { echo -e "${RED}[SWORD]${NC} $1"; exit 1; } + +# ── Cleanup trap ──────────────────────────────────────── + +cleanup() { + if [ -n "$CLONE_DIR" ] && [ -d "$CLONE_DIR" ]; then + rm -rf "$CLONE_DIR" + fi + # Remove secrets temp file if it exists + rm -f /tmp/sword-init-config.json 2>/dev/null || true +} +trap cleanup EXIT + +# ── Root & OS check ──────────────────────────────────── + +if [ "$(id -u)" -ne 0 ]; then + fail "This script must be run as root." +fi + +if [ ! -f /etc/os-release ]; then + fail "Cannot detect OS. /etc/os-release not found." +fi + +. /etc/os-release +if [ "$ID" != "ubuntu" ] || [ "$VERSION_ID" != "24.04" ]; then + fail "This installer requires Ubuntu 24.04. Detected: $ID $VERSION_ID" +fi + +# ── Prompts ───────────────────────────────────────────── + +echo "" +echo -e "${CYAN}╔══════════════════════════════════════╗${NC}" +echo -e "${CYAN}║ SWORD Installer ║${NC}" +echo -e "${CYAN}╚══════════════════════════════════════╝${NC}" +echo "" + +read -rp "Domain for SWORD (e.g. sword.example.com): " SWORD_DOMAIN < /dev/tty +[ -z "$SWORD_DOMAIN" ] && fail "Domain is required." + +# Validate domain format +if ! echo "$SWORD_DOMAIN" | grep -qP '^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$'; then + fail "Invalid domain format: $SWORD_DOMAIN" +fi + +read -rp "Email for Let's Encrypt certificates: " LE_EMAIL < /dev/tty +[ -z "$LE_EMAIL" ] && fail "Email is required." + +# Validate email format +if ! echo "$LE_EMAIL" | grep -qP '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then + fail "Invalid email format: $LE_EMAIL" +fi + +read -rp "Admin name: " ADMIN_NAME < /dev/tty +[ -z "$ADMIN_NAME" ] && fail "Admin name is required." + +read -rp "Admin email: " ADMIN_EMAIL < /dev/tty +[ -z "$ADMIN_EMAIL" ] && fail "Admin email is required." + +if ! echo "$ADMIN_EMAIL" | grep -qP '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then + fail "Invalid email format: $ADMIN_EMAIL" +fi + +read -srp "Admin password (min 8 characters): " ADMIN_PASSWORD < /dev/tty +echo "" +[ -z "$ADMIN_PASSWORD" ] && fail "Admin password is required." +[ ${#ADMIN_PASSWORD} -lt 8 ] && fail "Admin password must be at least 8 characters." + +info "Starting installation..." + +# ── Detect public IP ─────────────────────────────────── + +SERVER_IP=$(curl -s4 https://ifconfig.me || curl -s4 https://api.ipify.org || true) + +# Validate we got a public IP (not empty, not private range) +if [ -z "$SERVER_IP" ]; then + fail "Could not detect public IP address. Check your network connection." +fi + +if echo "$SERVER_IP" | grep -qP '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.)'; then + warn "Detected IP $SERVER_IP appears to be a private address." + read -rp "Enter the public IP of this server: " SERVER_IP < /dev/tty + [ -z "$SERVER_IP" ] && fail "Public IP is required." +fi + +info "Detected public IP: $SERVER_IP" + +# ── Apt helpers ───────────────────────────────────────── + +export DEBIAN_FRONTEND=noninteractive + +waitForApt() { + while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 2; done + while fuser /var/lib/dpkg/lock >/dev/null 2>&1; do sleep 2; done + while fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do sleep 2; done +} + +# ── Install Docker ────────────────────────────────────── + +if ! command -v docker >/dev/null 2>&1; then + info "Installing Docker..." + + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --yes --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list + + waitForApt + apt-get update + + waitForApt + apt-get install -y -qq \ + docker-ce docker-ce-cli containerd.io \ + docker-buildx-plugin docker-compose-plugin + + mkdir -p /etc/docker + cat > /etc/docker/daemon.json <<'DOCKEREOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + }, + "live-restore": true, + "default-address-pools": [ + { + "base": "10.240.0.0/16", + "size": 24 + } + ] +} +DOCKEREOF + + systemctl enable --now docker + ok "Docker installed." +else + ok "Docker already installed." +fi + +# ── Install git if missing ────────────────────────────── + +if ! command -v git >/dev/null 2>&1; then + waitForApt + apt-get install -y -qq git +fi + +# ── Create sword user ────────────────────────────────── + +info "Setting up sword user..." + +SUDO_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) + +if ! id sword &>/dev/null; then + useradd -m -s /bin/bash sword +fi + +groupadd -f docker +usermod -aG docker sword +usermod -aG sudo sword + +echo "sword:${SUDO_PASSWORD}" | chpasswd + +# Generate SSH keypair for sword user +mkdir -p /home/sword/.ssh +chmod 700 /home/sword/.ssh + +if [ ! -f /home/sword/.ssh/id_ed25519 ]; then + ssh-keygen -t ed25519 -f /home/sword/.ssh/id_ed25519 -N "" -C "sword-localhost" +fi + +# Authorize sword's public key in root's authorized_keys +mkdir -p /root/.ssh +chmod 700 /root/.ssh +touch /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys + +SWORD_PUBKEY=$(cat /home/sword/.ssh/id_ed25519.pub) +if ! grep -qF "${SWORD_PUBKEY}" /root/.ssh/authorized_keys; then + echo "${SWORD_PUBKEY}" >> /root/.ssh/authorized_keys +fi + +chown -R sword:sword /home/sword/.ssh + +# Add host key to known_hosts so SSH doesn't prompt +ssh-keyscan -H "$SERVER_IP" >> /home/sword/.ssh/known_hosts 2>/dev/null || true +ssh-keyscan -H localhost >> /home/sword/.ssh/known_hosts 2>/dev/null || true +chown sword:sword /home/sword/.ssh/known_hosts + +ok "sword user ready." + +# ── Directory structure ───────────────────────────────── + +info "Creating directory structure..." + +mkdir -p "$SWORD_DIR"/{shared/mysql/data,app/storage,sites,stacks,letsencrypt} + +# ── Docker network ────────────────────────────────────── + +if ! docker network ls -q -f name=^sword_network$ | grep -q .; then + docker network create sword_network +fi + +# ── Generate secrets ──────────────────────────────────── + +APP_KEY="base64:$(openssl rand -base64 32)" +DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) +MYSQL_ROOT_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) +REDIS_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) + +# ── Shared infra .env ────────────────────────────────── + +cat > "$SWORD_DIR/shared/.env" < "$SWORD_DIR/shared/mysql/my.cnf" <<'SQLEOF' +[mysqld] +user=mysql +character-set-server=utf8mb4 +collation-server=utf8mb4_unicode_ci + +innodb_buffer_pool_size=1G +innodb_buffer_pool_instances=1 +innodb_log_file_size=256M +innodb_flush_log_at_trx_commit=1 +innodb_file_per_table=1 +innodb_flush_method=O_DIRECT + +max_connections=150 +thread_cache_size=50 +max_allowed_packet=64M +wait_timeout=60 +interactive_timeout=60 + +table_open_cache=2000 +tmp_table_size=64M +max_heap_table_size=64M + +host_cache_size=0 +skip-name-resolve +skip-log-bin + +[mysql] +default-character-set=utf8mb4 + +[client] +default-character-set=utf8mb4 +SQLEOF + +# ── Shared infra docker-compose ───────────────────────── + +cat > "$SWORD_DIR/shared/docker-compose.yml" <<'COMPOSEEOF' +services: + traefik: + image: traefik:v3 + container_name: sword_traefik + restart: unless-stopped + command: + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + - "--entrypoints.web.http.redirections.entrypoint.permanent=true" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.letsencrypt.acme.email=__LE_EMAIL__" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--api.dashboard=false" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /srv/sword/letsencrypt:/letsencrypt + networks: + - sword_network + + mysql: + image: mysql:8.4 + container_name: sword_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + volumes: + - /srv/sword/shared/mysql/data:/var/lib/mysql + - /srv/sword/shared/mysql/my.cnf:/etc/my.cnf + networks: + - sword_network + + ofelia: + image: mcuadros/ofelia:v3.3.3 + container_name: sword_ofelia + restart: unless-stopped + command: "daemon --docker" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - sword_network + +networks: + sword_network: + name: sword_network + external: true +COMPOSEEOF + +sed -i "s|__LE_EMAIL__|${LE_EMAIL}|g" "$SWORD_DIR/shared/docker-compose.yml" + +# ── Start shared infra ────────────────────────────────── + +info "Starting shared infrastructure..." +docker compose -f "$SWORD_DIR/shared/docker-compose.yml" up -d + +# Wait for MySQL to be fully ready (including init scripts) +info "Waiting for MySQL to be ready..." +for i in $(seq 1 60); do + if docker exec sword_mysql sh -c 'mysqladmin ping -p"${MYSQL_ROOT_PASSWORD}" --silent' 2>/dev/null; then + # Also verify we can actually authenticate (init may still be running) + if docker exec sword_mysql sh -c 'mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "SELECT 1"' >/dev/null 2>&1; then + break + fi + fi + sleep 2 +done + +# Verify MySQL is actually ready +if ! docker exec sword_mysql sh -c 'mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "SELECT 1"' >/dev/null 2>&1; then + fail "MySQL failed to start within 120 seconds." +fi + +ok "MySQL is ready." + +# ── Create SWORD database and user ────────────────────── + +info "Creating SWORD database..." + +# Write SQL to a temp file inside the container to avoid secrets in process args +docker exec sword_mysql sh -c "cat > /tmp/init.sql <<'INITSQL' +CREATE DATABASE IF NOT EXISTS sword CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +INITSQL +" + +# The DB_PASSWORD needs interpolation, so we write it separately +docker exec sword_mysql sh -c "echo \"CREATE USER IF NOT EXISTS 'sword'@'%' IDENTIFIED BY '${DB_PASSWORD}';\" >> /tmp/init.sql" +docker exec sword_mysql sh -c "echo \"GRANT ALL PRIVILEGES ON sword.* TO 'sword'@'%';\" >> /tmp/init.sql" +docker exec sword_mysql sh -c "echo 'FLUSH PRIVILEGES;' >> /tmp/init.sql" + +docker exec sword_mysql sh -c 'mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" < /tmp/init.sql' +docker exec sword_mysql rm -f /tmp/init.sql + +ok "Database ready." + +# ── Write SWORD .env ──────────────────────────────────── + +cat > "$SWORD_DIR/app/.env" < "$SWORD_DIR/app/docker-compose.env" < "$INIT_CONFIG" </dev/null 2>&1; then + waitForApt + apt-get install -y -qq ufw +fi + +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment "SSH" +ufw allow 80/tcp comment "HTTP" +ufw allow 443/tcp comment "HTTPS" +ufw --force enable +ok "Firewall configured." + +# ── Done ──────────────────────────────────────────────── + +echo "" +echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ SWORD Installation Complete! ║${NC}" +echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e " URL: ${CYAN}https://${SWORD_DOMAIN}${NC}" +echo -e " Email: ${CYAN}${ADMIN_EMAIL}${NC}" +echo -e " Server: ${CYAN}${SERVER_IP}${NC}" +echo "" +echo -e " ${YELLOW}Sudo password for 'sword' user:${NC}" +echo -e " ${CYAN}${SUDO_PASSWORD}${NC}" +echo "" +echo -e " ${YELLOW}Credentials saved to:${NC}" +echo -e " ${CYAN}${SWORD_DIR}/app/.env${NC}" +echo -e " ${CYAN}${SWORD_DIR}/shared/.env${NC}" +echo "" +echo -e " ${RED}Save the sudo password above — it is not stored elsewhere.${NC}" +echo -e " ${YELLOW}Please also save your admin password — it is not stored in plaintext.${NC}" +echo ""