From 02af1b8c7b36e7067768f9a21201908c19b31f61 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 12:53:13 +0100 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20Add=20self-hosted=20install?= =?UTF-8?q?=20script=20and=20production=20Docker=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single curl|bash installer that provisions SWORD on a fresh Ubuntu 24.04 VPS with Traefik, MySQL, Redis, and auto-registers the host as a localhost server for immediate WordPress site creation. --- .dockerignore | 13 + app/Console/Commands/SwordInit.php | 67 ++++ docker/production/Dockerfile | 89 +++++ docker/production/docker-compose.prod.yml | 28 ++ docker/production/nginx.conf | 30 ++ docker/production/php-fpm.conf | 14 + docker/production/supervisord.conf | 44 ++ install.sh | 466 ++++++++++++++++++++++ 8 files changed, 751 insertions(+) create mode 100644 .dockerignore create mode 100644 app/Console/Commands/SwordInit.php create mode 100644 docker/production/Dockerfile create mode 100644 docker/production/docker-compose.prod.yml create mode 100644 docker/production/nginx.conf create mode 100644 docker/production/php-fpm.conf create mode 100644 docker/production/supervisord.conf create mode 100755 install.sh 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..e153e8e --- /dev/null +++ b/app/Console/Commands/SwordInit.php @@ -0,0 +1,67 @@ + $this->option('admin-email')], + [ + 'name' => $this->option('admin-name'), + 'password' => $this->option('admin-password'), + ], + ); + + $this->info("Admin user ready: {$user->email}"); + + $sshKeyPath = '/home/sword/.ssh/id_ed25519'; + + if (! file_exists($sshKeyPath)) { + $this->error("SSH key not found at {$sshKeyPath}"); + + return self::FAILURE; + } + + $privateKey = file_get_contents($sshKeyPath); + $publicKey = file_get_contents("{$sshKeyPath}.pub"); + + $server = Server::firstOrCreate( + ['provider' => 'localhost'], + [ + 'user_id' => $user->id, + 'name' => 'Localhost', + 'ip_address' => $this->option('server-ip'), + 'hostname' => gethostname(), + 'timezone' => date_default_timezone_get(), + 'ssh_port' => 22, + 'ssh_private_key' => $privateKey, + 'ssh_public_key' => $publicKey, + 'mysql_root_password' => $this->option('mysql-root-password'), + 'sudo_password' => $this->option('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..82631ba --- /dev/null +++ b/docker/production/Dockerfile @@ -0,0 +1,89 @@ +# ── Stage 1: Build frontend assets ────────────────────── +FROM node:24-alpine AS assets + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY vite.config.* tsconfig*.json eslint* ./ +COPY resources/ resources/ +COPY public/ public/ +RUN npm run build + +# ── Stage 2: Production PHP 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 \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && 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 code +COPY --chown=www-data:www-data . /var/www/html + +# Copy compiled frontend assets from stage 1 +COPY --from=assets --chown=www-data:www-data /app/public/build /var/www/html/public/build + +# Install PHP dependencies (production only) +RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader \ + && rm -rf /root/.composer + +# 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..256aa95 --- /dev/null +++ b/docker/production/docker-compose.prod.yml @@ -0,0 +1,28 @@ +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 + 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..32def0c --- /dev/null +++ b/docker/production/nginx.conf @@ -0,0 +1,30 @@ +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; + } + + 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..c6ebfe0 --- /dev/null +++ b/install.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# SWORD Self-Hosted Installer +# Usage: curl -sL | bash +# ============================================================ + +REPO="https://github.com/SynioBE/SWORD.git" +SWORD_DIR="/srv/sword" + +# ── 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; } + +# ── 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 +[ -z "$SWORD_DOMAIN" ] && fail "Domain is required." + +read -rp "Email for Let's Encrypt certificates: " LE_EMAIL +[ -z "$LE_EMAIL" ] && fail "Email is required." + +read -rp "Admin name: " ADMIN_NAME +[ -z "$ADMIN_NAME" ] && fail "Admin name is required." + +read -rp "Admin email: " ADMIN_EMAIL +[ -z "$ADMIN_EMAIL" ] && fail "Admin email is required." + +read -srp "Admin password: " ADMIN_PASSWORD +echo "" +[ -z "$ADMIN_PASSWORD" ] && fail "Admin password is required." + +info "Starting installation..." + +# ── Detect public IP ─────────────────────────────────── + +SERVER_IP=$(curl -s4 https://ifconfig.me || curl -s4 https://api.ipify.org || hostname -I | awk '{print $1}') +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) + +# ── 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" </dev/null; then + break + fi + sleep 2 +done + +# Verify MySQL is actually ready +if ! docker exec sword_mysql mysqladmin ping -p"${MYSQL_ROOT_PASSWORD}" --silent 2>/dev/null; then + fail "MySQL failed to start within 120 seconds." +fi + +ok "MySQL is ready." + +# ── Create SWORD database and user ────────────────────── + +info "Creating SWORD database..." +docker exec sword_mysql mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e " + CREATE DATABASE IF NOT EXISTS sword CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE USER IF NOT EXISTS 'sword'@'%' IDENTIFIED BY '${DB_PASSWORD}'; + GRANT ALL PRIVILEGES ON sword.* TO 'sword'@'%'; + FLUSH PRIVILEGES; +" + +ok "Database ready." + +# ── Write SWORD .env ──────────────────────────────────── + +cat > "$SWORD_DIR/app/.env" </dev/null 2>&1; then + 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." +else + waitForApt + apt-get install -y -qq ufw + 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." +fi + +# ── 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}Credentials have been saved to:${NC}" +echo -e " ${CYAN}${SWORD_DIR}/app/.env${NC}" +echo -e " ${CYAN}${SWORD_DIR}/shared/.env${NC}" +echo "" +echo -e " ${YELLOW}Please save your admin password — it is not stored in plaintext.${NC}" +echo "" From 0cf495499e120d23171108081ca38cbae3aefca2 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 12:56:02 +0100 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=94=A7=20Add=20BRANCH=20variable=20?= =?UTF-8?q?to=20install=20script=20for=20easier=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index c6ebfe0..e9ad3aa 100755 --- a/install.sh +++ b/install.sh @@ -7,6 +7,7 @@ set -euo pipefail # ============================================================ REPO="https://github.com/SynioBE/SWORD.git" +BRANCH="main" SWORD_DIR="/srv/sword" # ── Colors ────────────────────────────────────────────── @@ -378,7 +379,7 @@ EOF info "Cloning SWORD repository..." CLONE_DIR=$(mktemp -d) -git clone --depth 1 "$REPO" "$CLONE_DIR" +git clone --depth 1 --branch "$BRANCH" "$REPO" "$CLONE_DIR" info "Building SWORD Docker image (this may take a few minutes)..." docker build -t sword-app:latest -f "$CLONE_DIR/docker/production/Dockerfile" "$CLONE_DIR" From 9f2cefbafa3907cb505fa7f834db75e4812dd5db Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 12:56:29 +0100 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=94=A7=20Allow=20SWORD=5FBRANCH=20e?= =?UTF-8?q?nv=20var=20override=20for=20install=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index e9ad3aa..7d450f8 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,7 @@ set -euo pipefail # ============================================================ REPO="https://github.com/SynioBE/SWORD.git" -BRANCH="main" +BRANCH="${SWORD_BRANCH:-main}" SWORD_DIR="/srv/sword" # ── Colors ────────────────────────────────────────────── From 8edc59f0671936c347557e4a29d3eccebc0f3683 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 13:07:03 +0100 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20Fix=20YAML=20parsing=20in?= =?UTF-8?q?=20shared=20infra=20compose=20heredoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 7d450f8..e84a988 100755 --- a/install.sh +++ b/install.sh @@ -237,7 +237,7 @@ SQLEOF # ── Shared infra docker-compose ───────────────────────── -cat > "$SWORD_DIR/shared/docker-compose.yml" < "$SWORD_DIR/shared/docker-compose.yml" <<'COMPOSEEOF' services: traefik: image: traefik:v3 @@ -253,14 +253,14 @@ services: - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - - "--certificatesresolvers.letsencrypt.acme.email=${LE_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.email=__LE_EMAIL__" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - - ${SWORD_DIR}/letsencrypt:/letsencrypt + - /srv/sword/letsencrypt:/letsencrypt networks: - sword_network @@ -269,10 +269,10 @@ services: container_name: sword_mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: \${MYSQL_ROOT_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} volumes: - - ${SWORD_DIR}/shared/mysql/data:/var/lib/mysql - - ${SWORD_DIR}/shared/mysql/my.cnf:/etc/my.cnf + - /srv/sword/shared/mysql/data:/var/lib/mysql + - /srv/sword/shared/mysql/my.cnf:/etc/my.cnf networks: - sword_network @@ -280,7 +280,7 @@ services: image: mcuadros/ofelia:latest container_name: sword_ofelia restart: unless-stopped - command: 'daemon --docker' + command: "daemon --docker" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: @@ -292,6 +292,8 @@ networks: external: true COMPOSEEOF +sed -i "s|__LE_EMAIL__|${LE_EMAIL}|g" "$SWORD_DIR/shared/docker-compose.yml" + # ── Start shared infra ────────────────────────────────── info "Starting shared infrastructure..." From c38a491f98fa140c5d503df8b9eadbb481270c58 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 13:07:46 +0100 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20Read=20prompts=20from=20/d?= =?UTF-8?q?ev/tty=20for=20curl|bash=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index e84a988..1ce341f 100755 --- a/install.sh +++ b/install.sh @@ -46,19 +46,19 @@ echo -e "${CYAN}║ SWORD Installer ║${NC}" echo -e "${CYAN}╚══════════════════════════════════════╝${NC}" echo "" -read -rp "Domain for SWORD (e.g. sword.example.com): " SWORD_DOMAIN +read -rp "Domain for SWORD (e.g. sword.example.com): " SWORD_DOMAIN < /dev/tty [ -z "$SWORD_DOMAIN" ] && fail "Domain is required." -read -rp "Email for Let's Encrypt certificates: " LE_EMAIL +read -rp "Email for Let's Encrypt certificates: " LE_EMAIL < /dev/tty [ -z "$LE_EMAIL" ] && fail "Email is required." -read -rp "Admin name: " ADMIN_NAME +read -rp "Admin name: " ADMIN_NAME < /dev/tty [ -z "$ADMIN_NAME" ] && fail "Admin name is required." -read -rp "Admin email: " ADMIN_EMAIL +read -rp "Admin email: " ADMIN_EMAIL < /dev/tty [ -z "$ADMIN_EMAIL" ] && fail "Admin email is required." -read -srp "Admin password: " ADMIN_PASSWORD +read -srp "Admin password: " ADMIN_PASSWORD < /dev/tty echo "" [ -z "$ADMIN_PASSWORD" ] && fail "Admin password is required." From 7442129e72e5d280c36f6f6be95d307afc4c0fa7 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 13:13:34 +0100 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=90=9B=20Use=20container=20env=20va?= =?UTF-8?q?r=20for=20MySQL=20auth=20instead=20of=20host=20shell=20var?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index 1ce341f..2ec78a5 100755 --- a/install.sh +++ b/install.sh @@ -299,17 +299,20 @@ sed -i "s|__LE_EMAIL__|${LE_EMAIL}|g" "$SWORD_DIR/shared/docker-compose.yml" info "Starting shared infrastructure..." docker compose -f "$SWORD_DIR/shared/docker-compose.yml" up -d -# Wait for MySQL to be ready +# 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 mysqladmin ping -p"${MYSQL_ROOT_PASSWORD}" --silent 2>/dev/null; then - break + 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 mysqladmin ping -p"${MYSQL_ROOT_PASSWORD}" --silent 2>/dev/null; then +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 @@ -318,12 +321,12 @@ ok "MySQL is ready." # ── Create SWORD database and user ────────────────────── info "Creating SWORD database..." -docker exec sword_mysql mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e " +docker exec sword_mysql sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \" CREATE DATABASE IF NOT EXISTS sword CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS 'sword'@'%' IDENTIFIED BY '${DB_PASSWORD}'; GRANT ALL PRIVILEGES ON sword.* TO 'sword'@'%'; FLUSH PRIVILEGES; -" +\"" ok "Database ready." From 10206418790e5403cfb222014f4baacea72a9ad0 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 13:16:37 +0100 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=90=9B=20Add=20PHP=20to=20asset=20b?= =?UTF-8?q?uild=20stage=20for=20Wayfinder=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/production/Dockerfile | 66 +++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 82631ba..5a539b6 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -1,17 +1,56 @@ -# ── Stage 1: Build frontend assets ────────────────────── -FROM node:24-alpine AS assets +# ── Stage 1: Install PHP dependencies ─────────────────── +FROM ubuntu:24.04 AS php-deps + +ENV DEBIAN_FRONTEND=noninteractive WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci +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 vite.config.* tsconfig*.json eslint* ./ -COPY resources/ resources/ -COPY public/ public/ +COPY . . +RUN composer dump-autoload --optimize + +# ── Stage 2: Build frontend assets ───────────────────── +FROM node:24-bookworm-slim AS assets + +RUN apt-get update && apt-get install -y php-cli && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=php-deps /app /app + +RUN npm ci RUN npm run build -# ── Stage 2: Production PHP image ────────────────────── +# ── Stage 3: Production image ────────────────────────── FROM ubuntu:24.04 LABEL maintainer="Synio" @@ -53,7 +92,6 @@ RUN apt-get update && apt-get upgrade -y \ php8.5-ldap \ php8.5-imagick \ php8.5-sqlite3 \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ && apt-get -y autoremove \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -66,16 +104,12 @@ 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 code -COPY --chown=www-data:www-data . /var/www/html +# 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 stage 1 +# Copy compiled frontend assets from assets stage COPY --from=assets --chown=www-data:www-data /app/public/build /var/www/html/public/build -# Install PHP dependencies (production only) -RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader \ - && rm -rf /root/.composer - # 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 \ From 1586a0f12625b4da3a5a355f70f9042f8511939c Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 13:29:27 +0100 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=90=9B=20Use=20PHP=208.5=20from=20p?= =?UTF-8?q?hp-deps=20stage=20for=20asset=20build=20(Wayfinder=20needs=20>?= =?UTF-8?q?=3D=208.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/production/Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 5a539b6..b0429dc 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -39,13 +39,13 @@ COPY . . RUN composer dump-autoload --optimize # ── Stage 2: Build frontend assets ───────────────────── -FROM node:24-bookworm-slim AS assets +FROM php-deps AS assets -RUN apt-get update && apt-get install -y php-cli && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY --from=php-deps /app /app +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 From d2b8e244f0c474ef1b3c19bae543b621f3662730 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 13:35:19 +0100 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=90=9B=20Fix=20HTTPS=20detection=20?= =?UTF-8?q?behind=20Traefik=20reverse=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/production/nginx.conf | 4 ++++ install.sh | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docker/production/nginx.conf b/docker/production/nginx.conf index 32def0c..124dab9 100644 --- a/docker/production/nginx.conf +++ b/docker/production/nginx.conf @@ -22,6 +22,10 @@ server { 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).* { diff --git a/install.sh b/install.sh index 2ec78a5..2ce81a4 100755 --- a/install.sh +++ b/install.sh @@ -377,6 +377,8 @@ REDIS_PORT=6379 MAIL_MAILER=log +TRUSTED_PROXIES=* + SWORD_DOMAIN=${SWORD_DOMAIN} EOF From dbcd4a98a54e0f412ff11e16c60e9a4130391541 Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 14:06:44 +0100 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=90=9B=20Pass=20SSH=20keys=20as=20a?= =?UTF-8?q?rguments=20to=20sword:init=20instead=20of=20reading=20from=20co?= =?UTF-8?q?ntainer=20filesystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/SwordInit.php | 14 +++++++------- install.sh | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/Console/Commands/SwordInit.php b/app/Console/Commands/SwordInit.php index e153e8e..7f17ce5 100644 --- a/app/Console/Commands/SwordInit.php +++ b/app/Console/Commands/SwordInit.php @@ -14,7 +14,9 @@ class SwordInit extends Command {--admin-password= : Admin user password} {--server-ip= : Public IP address of this server} {--mysql-root-password= : MySQL root password} - {--sudo-password= : Sudo password for the sword user}'; + {--sudo-password= : Sudo password for the sword user} + {--ssh-private-key= : SSH private key contents} + {--ssh-public-key= : SSH public key contents}'; protected $description = 'Initialize SWORD with an admin user and localhost server'; @@ -30,17 +32,15 @@ public function handle(): int $this->info("Admin user ready: {$user->email}"); - $sshKeyPath = '/home/sword/.ssh/id_ed25519'; + $privateKey = $this->option('ssh-private-key'); + $publicKey = $this->option('ssh-public-key'); - if (! file_exists($sshKeyPath)) { - $this->error("SSH key not found at {$sshKeyPath}"); + if (empty($privateKey) || empty($publicKey)) { + $this->error('SSH keys are required. Pass --ssh-private-key and --ssh-public-key.'); return self::FAILURE; } - $privateKey = file_get_contents($sshKeyPath); - $publicKey = file_get_contents("{$sshKeyPath}.pub"); - $server = Server::firstOrCreate( ['provider' => 'localhost'], [ diff --git a/install.sh b/install.sh index 2ce81a4..b99d2fe 100755 --- a/install.sh +++ b/install.sh @@ -421,13 +421,17 @@ docker exec sword_app php artisan migrate --force # ── Run sword:init ────────────────────────────────────── info "Initializing SWORD..." +SSH_PRIVATE_KEY=$(cat /home/sword/.ssh/id_ed25519) +SSH_PUBLIC_KEY=$(cat /home/sword/.ssh/id_ed25519.pub) docker exec sword_app php artisan sword:init \ --admin-name="$ADMIN_NAME" \ --admin-email="$ADMIN_EMAIL" \ --admin-password="$ADMIN_PASSWORD" \ --server-ip="$SERVER_IP" \ --mysql-root-password="$MYSQL_ROOT_PASSWORD" \ - --sudo-password="$SUDO_PASSWORD" + --sudo-password="$SUDO_PASSWORD" \ + --ssh-private-key="$SSH_PRIVATE_KEY" \ + --ssh-public-key="$SSH_PUBLIC_KEY" # ── Firewall ──────────────────────────────────────────── From ad3400036d4b46b58a4d3d18f2ebc3f77d8b996f Mon Sep 17 00:00:00 2001 From: Florian Blaser Date: Sun, 22 Mar 2026 14:42:57 +0100 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=94=92=20Harden=20deployment=20proc?= =?UTF-8?q?ess=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass secrets to sword:init via temp JSON file instead of CLI args (prevents exposure in process listings) - Set chmod 600 on all .env files immediately after creation - Add Redis password authentication - Validate domain format, email format, and password length on input - Validate public IP is not a private address - Pin Ofelia to v3.3.3 instead of :latest - Explicitly disable Traefik dashboard - Add cleanup trap for temp directories on script exit - Display sudo password at end of install (not stored elsewhere) - Use --env-file for Docker Compose variable interpolation --- app/Console/Commands/SwordInit.php | 60 +++++---- docker/production/docker-compose.prod.yml | 1 + install.sh | 151 ++++++++++++++++------ 3 files changed, 144 insertions(+), 68 deletions(-) diff --git a/app/Console/Commands/SwordInit.php b/app/Console/Commands/SwordInit.php index 7f17ce5..8498646 100644 --- a/app/Console/Commands/SwordInit.php +++ b/app/Console/Commands/SwordInit.php @@ -8,52 +8,60 @@ class SwordInit extends Command { - protected $signature = 'sword:init - {--admin-name= : Admin user name} - {--admin-email= : Admin user email} - {--admin-password= : Admin user password} - {--server-ip= : Public IP address of this server} - {--mysql-root-password= : MySQL root password} - {--sudo-password= : Sudo password for the sword user} - {--ssh-private-key= : SSH private key contents} - {--ssh-public-key= : SSH public key contents}'; + protected $signature = 'sword:init {config-file : Path to JSON config file with init parameters}'; protected $description = 'Initialize SWORD with an admin user and localhost server'; public function handle(): int { - $user = User::firstOrCreate( - ['email' => $this->option('admin-email')], - [ - 'name' => $this->option('admin-name'), - 'password' => $this->option('admin-password'), - ], - ); + $configPath = $this->argument('config-file'); - $this->info("Admin user ready: {$user->email}"); + if (! file_exists($configPath)) { + $this->error("Config file not found: {$configPath}"); - $privateKey = $this->option('ssh-private-key'); - $publicKey = $this->option('ssh-public-key'); + return self::FAILURE; + } - if (empty($privateKey) || empty($publicKey)) { - $this->error('SSH keys are required. Pass --ssh-private-key and --ssh-public-key.'); + $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' => $this->option('server-ip'), + 'ip_address' => $config['server_ip'], 'hostname' => gethostname(), 'timezone' => date_default_timezone_get(), 'ssh_port' => 22, - 'ssh_private_key' => $privateKey, - 'ssh_public_key' => $publicKey, - 'mysql_root_password' => $this->option('mysql-root-password'), - 'sudo_password' => $this->option('sudo-password'), + '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, diff --git a/docker/production/docker-compose.prod.yml b/docker/production/docker-compose.prod.yml index 256aa95..61fa2b5 100644 --- a/docker/production/docker-compose.prod.yml +++ b/docker/production/docker-compose.prod.yml @@ -19,6 +19,7 @@ services: image: redis:alpine container_name: sword_redis restart: unless-stopped + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] networks: - sword_network diff --git a/install.sh b/install.sh index b99d2fe..8610562 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,7 @@ set -euo pipefail REPO="https://github.com/SynioBE/SWORD.git" BRANCH="${SWORD_BRANCH:-main}" SWORD_DIR="/srv/sword" +CLONE_DIR="" # ── Colors ────────────────────────────────────────────── @@ -23,6 +24,17 @@ 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 @@ -49,24 +61,51 @@ 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." -read -srp "Admin password: " ADMIN_PASSWORD < /dev/tty +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 || hostname -I | awk '{print $1}') +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 ───────────────────────────────────────── @@ -192,12 +231,14 @@ fi 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" < /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." @@ -372,7 +422,7 @@ CACHE_STORE=redis REDIS_CLIENT=phpredis REDIS_HOST=sword_redis -REDIS_PASSWORD=null +REDIS_PASSWORD=${REDIS_PASSWORD} REDIS_PORT=6379 MAIL_MAILER=log @@ -381,11 +431,13 @@ TRUSTED_PROXIES=* SWORD_DOMAIN=${SWORD_DOMAIN} EOF +chmod 600 "$SWORD_DIR/app/.env" # ── Clone repo and build image ────────────────────────── info "Cloning SWORD repository..." CLONE_DIR=$(mktemp -d) +chmod 700 "$CLONE_DIR" git clone --depth 1 --branch "$BRANCH" "$REPO" "$CLONE_DIR" info "Building SWORD Docker image (this may take a few minutes)..." @@ -394,7 +446,15 @@ docker build -t sword-app:latest -f "$CLONE_DIR/docker/production/Dockerfile" "$ # Copy the compose file cp "$CLONE_DIR/docker/production/docker-compose.prod.yml" "$SWORD_DIR/app/docker-compose.prod.yml" +# Write .env for Docker Compose variable interpolation (SWORD_DOMAIN, REDIS_PASSWORD) +cat > "$SWORD_DIR/app/docker-compose.env" < "$INIT_CONFIG" </dev/null 2>&1; then - 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." -else +if ! command -v ufw >/dev/null 2>&1; then waitForApt apt-get install -y -qq ufw - 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." 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 "" @@ -470,9 +533,13 @@ 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}Credentials have been saved to:${NC}" +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 " ${YELLOW}Please save your admin password — it is not stored in plaintext.${NC}" +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 ""