diff --git a/.gitattributes b/.gitattributes index 128e843f5..6e3ff0631 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,4 +3,10 @@ DockerInit.sh text eol=lf DockerEntrypoint.sh text eol=lf frontend/src/generated/** text eol=lf frontend/public/openapi.json text eol=lf -frontend/src/test/__snapshots__/** text eol=lf \ No newline at end of file +frontend/src/test/__snapshots__/** text eol=lf + +# Cloud-image deploy assets are consumed on Linux — force LF regardless of host. +*.service text eol=lf +deploy/**/*.service text eol=lf +deploy/**/*.hcl text eol=lf +deploy/**/*.yaml text eol=lf \ No newline at end of file diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 000000000..e3298fc7a --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,260 @@ +name: Build Cloud Images + +# Build golden cloud images from a published release, for amd64 and arm64: +# * qemu -> qcow2 attached to the GitHub release (always) +# * amazon-ebs -> AWS AMI (only when AWS credentials are configured) +# +# Images contain NO database and NO baked credentials; first boot generates +# unique per-instance credentials (see deploy/firstboot + deploy/packer). + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to build images for (e.g. v3.3.1)" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: image-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + # Resolve the tag and wait until BOTH arch tarballs are actually published + # (the release matrix uploads assets one by one, so 'published' can fire + # before the tarballs exist). + setup: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + steps: + - name: Resolve tag + id: resolve + run: | + if [ "${{ github.event_name }}" = "release" ]; then + TAG="${{ github.event.release.tag_name }}" + else + TAG="${{ inputs.tag }}" + fi + [ -n "$TAG" ] || { echo "::error::no tag resolved"; exit 1; } + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Wait for released binary assets (amd64 + arm64) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.resolve.outputs.tag }} + run: | + want="x-ui-linux-amd64.tar.gz x-ui-linux-arm64.tar.gz" + for i in $(seq 1 30); do + names=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name') + missing="" + for w in $want; do + echo "$names" | grep -qx "$w" || missing="$missing $w" + done + if [ -z "$missing" ]; then + echo "All assets present on $TAG" + exit 0 + fi + echo "Waiting for$missing on $TAG ($i/30)..." + sleep 20 + done + echo "::error::missing release assets on $TAG after 10 minutes:$missing" + exit 1 + + # Gate the AWS AMI build so forks without secrets skip it cleanly + # (secrets cannot be referenced directly in job-level `if`). + check-aws: + runs-on: ubuntu-latest + outputs: + enabled: ${{ steps.c.outputs.enabled }} + use_oidc: ${{ steps.c.outputs.use_oidc }} + steps: + - id: c + env: + ROLE: ${{ secrets.AWS_ROLE_ARN }} + KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} + run: | + if [ -n "$ROLE" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "use_oidc=true" >> "$GITHUB_OUTPUT" + elif [ -n "$KEY" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "use_oidc=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "use_oidc=false" >> "$GITHUB_OUTPUT" + echo "::notice::No AWS credentials configured; skipping the AMI build." + fi + + qemu-image: + needs: setup + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + qemu_pkgs: qemu-system-x86 qemu-utils + - arch: arm64 + runner: ubuntu-24.04-arm + qemu_pkgs: qemu-system-arm qemu-efi-aarch64 qemu-utils + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install QEMU + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ${{ matrix.qemu_pkgs }} + + - name: Setup Packer + uses: hashicorp/setup-packer@v3 + with: + version: latest + + - name: Verify released binary asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.setup.outputs.tag }} + run: | + mkdir -p _asset + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset + ls -la _asset + + - name: Select accelerator + id: accel + run: | + if [ -e /dev/kvm ]; then echo "value=kvm" >> "$GITHUB_OUTPUT"; else echo "value=tcg" >> "$GITHUB_OUTPUT"; fi + + - name: Packer init + run: packer init deploy/packer/ + + - name: Build qcow2 image + env: + TAG: ${{ needs.setup.outputs.tag }} + ACCEL: ${{ steps.accel.outputs.value }} + run: | + packer build -only='qemu.x-ui' \ + -var "xui_version=${TAG}" \ + -var "xui_arch=${{ matrix.arch }}" \ + -var "qemu_accelerator=${ACCEL}" \ + deploy/packer/ + + - name: Compress qcow2 + id: pack + env: + TAG: ${{ needs.setup.outputs.tag }} + run: | + cd deploy/packer/output-qemu + src="3x-ui-ubuntu-24.04-${{ matrix.arch }}.qcow2" + out="3x-ui-ubuntu-24.04-${TAG}-${{ matrix.arch }}.qcow2.xz" + xz -T0 -6 -c "$src" > "$out" + sha256sum "$out" > "${out}.sha256" + echo "file=deploy/packer/output-qemu/${out}" >> "$GITHUB_OUTPUT" + echo "sha=deploy/packer/output-qemu/${out}.sha256" >> "$GITHUB_OUTPUT" + ls -la + + - name: Attach qcow2 to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.setup.outputs.tag }} + run: | + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ + "${{ steps.pack.outputs.file }}" "${{ steps.pack.outputs.sha }}" + + - name: Summary + env: + TAG: ${{ needs.setup.outputs.tag }} + ACCEL: ${{ steps.accel.outputs.value }} + run: | + { + echo "## QEMU image (${{ matrix.arch }})" + echo "- Tag: \`${TAG}\`" + echo "- Accelerator: \`${ACCEL}\`" + echo "- Attached: \`$(basename "${{ steps.pack.outputs.file }}")\`" + } >> "$GITHUB_STEP_SUMMARY" + + ami-image: + needs: [setup, check-aws] + if: needs.check-aws.outputs.enabled == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + instance_type: t3.small + - arch: arm64 + instance_type: t4g.small + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Packer + uses: hashicorp/setup-packer@v3 + with: + version: latest + + - name: Configure AWS credentials (OIDC) + if: needs.check-aws.outputs.use_oidc == 'true' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }} + + - name: Configure AWS credentials (access keys) + if: needs.check-aws.outputs.use_oidc != 'true' + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }} + + - name: Verify released binary asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.setup.outputs.tag }} + run: | + mkdir -p _asset + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset + ls -la _asset + + - name: Packer init + run: packer init deploy/packer/ + + - name: Build AMI + env: + TAG: ${{ needs.setup.outputs.tag }} + REGION: ${{ vars.AWS_REGION || 'eu-central-1' }} + run: | + packer build -only='amazon-ebs.x-ui' \ + -var "xui_version=${TAG}" \ + -var "xui_arch=${{ matrix.arch }}" \ + -var "instance_type=${{ matrix.instance_type }}" \ + -var "region=${REGION}" \ + deploy/packer/ + + - name: Publish AMI id to summary + env: + REGION: ${{ vars.AWS_REGION || 'eu-central-1' }} + run: | + AMI_ID=$(jq -r '.builds[] | select(.builder_type=="amazon-ebs") | .artifact_id' packer-manifest.json | tail -1 | cut -d: -f2) + { + echo "## AWS AMI (${{ matrix.arch }})" + echo "- Region: \`${REGION}\`" + echo "- Instance type: \`${{ matrix.instance_type }}\`" + echo "- AMI ID: \`${AMI_ID}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 000000000..b9e89fc5f --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,41 @@ +name: Deploy Smoke Tests + +# Container smoke tests for the unattended install path and first-boot +# credential generation. Runs only when the install/deploy assets change. + +on: + push: + paths: + - "install.sh" + - "deploy/**" + - ".github/workflows/smoke.yml" + pull_request: + paths: + - "install.sh" + - "deploy/**" + - ".github/workflows/smoke.yml" + +jobs: + noninteractive-install: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - name: Non-interactive install smoke test + run: bash deploy/test/smoke-noninteractive.sh + + first-boot: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - name: First-boot credential smoke test + run: bash deploy/test/smoke-firstboot.sh diff --git a/README.md b/README.md index 5cdf51aac..8c6d845ef 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,18 @@ During installation a random username, password, and access path are generated. For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki). +### Unattended install & cloud images + +The installer also runs **non-interactively** for cloud-init and golden images. +Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with +zero prompts, generating random credentials and writing them to +`/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for: + +- [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle) +- [Packer golden image](deploy/packer/) — build an AWS EC2 AMI + qcow2 (amd64/arm64) with per-instance credentials generated on first boot +- [Amazon Lightsail](deploy/lightsail/) — launch script + reusable snapshot builder +- [AWS Marketplace checklist](deploy/marketplace/aws/) + ## Supported Platforms **Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..78bbbd181 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,38 @@ +# Cloud deployment & golden images + +Tooling to ship the 3x-ui panel as a cloud image or via unattended install, +with **per-instance credentials generated on first boot** (never `admin/admin`, +never a shared session secret). Everything here supports **amd64 and arm64**. + +| Path | What it is | Use when | +| --- | --- | --- | +| [`cloud-init/`](cloud-init/) | Generic cloud-init user-data (unattended `install.sh`) | Any cloud, no image build | +| [`packer/`](packer/) | Packer build → AWS AMI + qcow2/raw | Reusable / Marketplace images | +| [`lightsail/`](lightsail/) | Launch script + snapshot builder | Amazon Lightsail | +| [`firstboot/`](firstboot/) | First-boot unit + script that mints per-instance creds | Used by the Packer/Lightsail images | +| [`marketplace/aws/`](marketplace/aws/) | AWS Marketplace submission checklist | Publishing an EC2 AMI | +| [`marketplace/hetzner/`](marketplace/hetzner/) | Hetzner Cloud notes | Hetzner deployments | +| [`test/`](test/) | Container smoke tests | Verifying the install/firstboot paths | + +## Two models + +- **Non-interactive install (cloud-init):** `install.sh` runs unattended when + `XUI_NONINTERACTIVE=1` or stdin is not a TTY. Each instance installs and + configures itself with random credentials. See [`cloud-init/README.md`](cloud-init/README.md). +- **Golden image (Packer):** the image contains the panel but **no DB and no + secrets**; `firstboot` generates unique credentials on first boot. See + [`packer/README.md`](packer/README.md). + +## Unattended install knobs + +`install.sh` reads these env vars in non-interactive mode (all optional; unset ⇒ +secure random / default): + +`XUI_USERNAME`, `XUI_PASSWORD`, `XUI_PANEL_PORT`, `XUI_WEB_BASE_PATH`, +`XUI_SSL_MODE` (`none`|`ip`|`domain`, default `none`), `XUI_DOMAIN`, +`XUI_ACME_EMAIL`, `XUI_ACME_HTTP_PORT` (ACME HTTP-01 listener port, default `80`), +`XUI_SSL_IPV6` (optional IPv6 address to add to an `ip`-mode cert), +`XUI_SERVER_IP` (fallback IP for the displayed access URL when auto-detection fails), +`XUI_DB_TYPE` (`sqlite`|`postgres`), `XUI_DB_DSN`. + +The resulting credentials are written to `/etc/x-ui/install-result.env` (mode 600). diff --git a/deploy/cloud-init/README.md b/deploy/cloud-init/README.md new file mode 100644 index 000000000..200f70137 --- /dev/null +++ b/deploy/cloud-init/README.md @@ -0,0 +1,71 @@ +# 3x-ui via cloud-init (generic, no golden image) + +This is the **secondary** deployment path: a single [`cloud-init.yaml`](cloud-init.yaml) +user-data file that installs 3x-ui non-interactively on a fresh Ubuntu/Debian +VM and generates **unique random credentials per instance**. Use it when you do +not want to build a golden image — it works on any cloud-init platform. + +> For AWS Marketplace / reusable images, use the Packer build in +> [`../packer/`](../packer/) instead. + +## How it works + +1. The VM boots a stock Ubuntu/Debian cloud image. +2. cloud-init writes and runs `/opt/xui-bootstrap.sh`, which exports + `XUI_NONINTERACTIVE=1` and pipes the project's `install.sh` into `bash`. +3. `install.sh` runs end-to-end with **zero prompts**, picking secure random + values for any credential you didn't pin. +4. The generated credentials are written to `/etc/x-ui/install-result.env` + (mode 600), echoed to the **serial console**, and appended to `/etc/motd`. + +Retrieve them after boot with either: + +```bash +sudo cat /etc/x-ui/install-result.env # over SSH +``` + +…or read the provider's **serial console** output (handy before you have SSH). + +## Customising + +Edit the `export XUI_*` lines inside the `write_files` block of +[`cloud-init.yaml`](cloud-init.yaml). All knobs are optional; unset ⇒ random/secure default. + +| Env var | Default | Meaning | +| --- | --- | --- | +| `XUI_SSL_MODE` | `none` | `none` (plain HTTP), `ip` (Let's Encrypt IP cert), `domain` | +| `XUI_USERNAME` | random | Admin username | +| `XUI_PASSWORD` | random | Admin password | +| `XUI_PANEL_PORT` | random high port | Panel listen port | +| `XUI_WEB_BASE_PATH` | random | Panel base path (obscures the URL) | +| `XUI_DOMAIN` | — | Required when `XUI_SSL_MODE=domain` | +| `XUI_ACME_EMAIL` | — | Let's Encrypt account email (domain mode) | +| `XUI_DB_TYPE` / `XUI_DB_DSN` | `sqlite` | Set `postgres` + DSN to use PostgreSQL | + +> **TLS note:** `none` serves the panel over plain HTTP on a random high port — +> fine behind a reverse proxy or an SSH tunnel, but put TLS in front of it before +> exposing the panel publicly. `domain` mode needs a public DNS A record pointing +> at the box and port 80 reachable at install time. + +## Per-provider usage + +- **Hetzner Cloud** — *Create Server → Cloud config*: paste the file. Or CLI: + `hcloud server create --image ubuntu-24.04 --user-data-from-file cloud-init.yaml ...` +- **AWS EC2** — *Advanced details → User data*: paste the file. Or + `aws ec2 run-instances --user-data file://cloud-init.yaml ...` + (For a reusable Marketplace image use the Packer AMI build instead.) +- **DigitalOcean** — *Create Droplet → Advanced options → Add Initialization + scripts (user data)*: paste the file. Or `doctl compute droplet create --user-data-file cloud-init.yaml ...` +- **Vultr** — *Deploy → Additional Features → Cloud-Init User-Data*: paste the file. +- **Google Cloud (GCE)** — `gcloud compute instances create xui \ + --image-family ubuntu-2404-lts-amd64 --image-project ubuntu-os-cloud \ + --metadata-from-file user-data=cloud-init.yaml` +- **Azure** — `az vm create --image Ubuntu2404 --custom-data cloud-init.yaml ...` +- **Oracle Cloud (OCI)** — *Create Instance → Show advanced options → + Management → Cloud-init script*: paste (or base64-upload) the file. + +## Validate before you deploy + +```bash +cloud-init schema --config-file deploy/cloud-init/cloud-init.yaml +``` diff --git a/deploy/cloud-init/cloud-init.yaml b/deploy/cloud-init/cloud-init.yaml new file mode 100644 index 000000000..05ee07a8d --- /dev/null +++ b/deploy/cloud-init/cloud-init.yaml @@ -0,0 +1,78 @@ +#cloud-config +# --------------------------------------------------------------------------- +# Generic 3x-ui unattended install via cloud-init user-data. +# +# Works on any cloud-init platform: Hetzner, AWS, DigitalOcean, Vultr, GCP, +# Azure, Oracle. Paste the whole file as the instance "user data". +# +# It installs the latest 3x-ui release NON-INTERACTIVELY, generating unique +# random credentials per instance. Full credentials are surfaced ONLY on the +# serial console (owner-only); /etc/motd (world-readable) shows just the access +# URL + username. Nothing is baked in advance — every instance is unique. +# +# Requires the non-interactive install.sh (3x-ui with XUI_NONINTERACTIVE support). +# Edit the exported XUI_* knobs in /opt/xui-bootstrap.sh below to customise. +# --------------------------------------------------------------------------- + +package_update: true +package_upgrade: false + +write_files: + - path: /opt/xui-bootstrap.sh + permissions: '0700' + owner: root:root + content: | + #!/usr/bin/env bash + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + + # --- Non-interactive install knobs -------------------------------------- + export XUI_NONINTERACTIVE=1 + # SSL mode: none (plain HTTP, default) | ip | domain + export XUI_SSL_MODE="${XUI_SSL_MODE:-none}" + # Pin credentials instead of random (leave unset for secure random values): + # export XUI_USERNAME="admin2" + # export XUI_PASSWORD="change-me-please" + # export XUI_PANEL_PORT="2053" + # export XUI_WEB_BASE_PATH="panel" + # Let's Encrypt domain certificate instead of plain HTTP: + # export XUI_SSL_MODE="domain" + # export XUI_DOMAIN="panel.example.com" + # export XUI_ACME_EMAIL="you@example.com" + # PostgreSQL instead of SQLite: + # export XUI_DB_TYPE="postgres" + # export XUI_DB_DSN="postgres://user:pass@host:5432/db?sslmode=disable" + # ------------------------------------------------------------------------ + + curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash + + # Surface the generated credentials. Full creds (incl. password + API token) + # go ONLY to the serial console (/dev/console, owner-only). /etc/motd is + # world-readable, so it gets just the access URL + username and a pointer + # to the root-only env file. + if [ -r /etc/x-ui/install-result.env ]; then + { + echo + echo "=== 3x-ui panel credentials (generated on first boot) ===" + cat /etc/x-ui/install-result.env + echo "========================================================" + echo "Change the password after first login." + } > /dev/console 2>/dev/null || true + + # shellcheck disable=SC1091 + . /etc/x-ui/install-result.env + { + echo + echo "=== 3x-ui panel (generated on first boot) ===" + echo "URL: ${XUI_ACCESS_URL:-unknown}" + echo "Username: ${XUI_USERNAME:-unknown}" + echo "Password + API token: sudo cat /etc/x-ui/install-result.env" + echo "=============================================" + echo "Change the password after first login." + } >> /etc/motd 2>/dev/null || true + fi + +runcmd: + - [bash, /opt/xui-bootstrap.sh] + +final_message: "3x-ui installed — full credentials in /etc/x-ui/install-result.env (sudo); /etc/motd shows the URL + username only." diff --git a/deploy/firstboot/x-ui-firstboot.service b/deploy/firstboot/x-ui-firstboot.service new file mode 100644 index 000000000..a0e2bc380 --- /dev/null +++ b/deploy/firstboot/x-ui-firstboot.service @@ -0,0 +1,22 @@ +[Unit] +Description=3x-ui first-boot per-instance credential generation +Documentation=https://github.com/MHSanaei/3x-ui +# Run after the network and cloud-init are up, but BEFORE the panel starts, so +# the panel never serves the default admin/admin account. +After=network-online.target cloud-init.service +Wants=network-online.target +Before=x-ui.service +# Skip entirely once the sentinel exists (cheap guard; the script re-checks too). +ConditionPathExists=!/etc/x-ui/.firstboot-done + +[Service] +Type=oneshot +RemainAfterExit=yes +# Inherit the same DB configuration the panel uses (sqlite default / postgres). +EnvironmentFile=-/etc/default/x-ui +EnvironmentFile=-/etc/conf.d/x-ui +EnvironmentFile=-/etc/sysconfig/x-ui +ExecStart=/usr/local/x-ui/x-ui-firstboot.sh + +[Install] +WantedBy=multi-user.target diff --git a/deploy/firstboot/x-ui-firstboot.sh b/deploy/firstboot/x-ui-firstboot.sh new file mode 100644 index 000000000..7bd86192a --- /dev/null +++ b/deploy/firstboot/x-ui-firstboot.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# +# x-ui-firstboot.sh — generate per-instance 3x-ui panel credentials on first boot. +# +# A golden image (AMI / qcow2) MUST ship without an initialized x-ui.db: the +# panel seeds a hardcoded admin/admin user and generates its session secret + +# panel GUID on first start, so a baked DB would make every clone share the same +# credentials and secret. This script runs ONCE, before x-ui.service starts, and +# replaces the default admin with fresh random credentials on a random high port. +# +# Idempotent: a sentinel file guards against re-running. If a non-default admin +# already exists (operator pre-configured the box), regeneration is skipped. +# +# Wired up by deploy/packer/scripts/provision.sh; ordered Before=x-ui.service. + +set -u + +SENTINEL="/etc/x-ui/.firstboot-done" +CRED_FILE="/etc/x-ui/credentials.txt" +MOTD_FILE="/etc/motd" +XUI_DIR="${XUI_MAIN_FOLDER:-/usr/local/x-ui}" +XUI_BIN="${XUI_DIR}/x-ui" + +log() { echo "[x-ui-firstboot] $*"; } + +# Already provisioned — nothing to do (idempotent on re-run / re-image). +if [ -f "$SENTINEL" ]; then + log "sentinel $SENTINEL present; skipping." + exit 0 +fi + +if [ ! -x "$XUI_BIN" ]; then + log "ERROR: x-ui binary not found at $XUI_BIN" + exit 1 +fi + +# Inherit DB configuration (sqlite default; postgres via XUI_DB_TYPE/XUI_DB_DSN) +# from the same env files the systemd unit loads, so the binary talks to the +# same database the panel will use. +for ef in /etc/default/x-ui /etc/conf.d/x-ui /etc/sysconfig/x-ui; do + if [ -r "$ef" ]; then + set -a + # shellcheck disable=SC1090 + . "$ef" + set +a + fi +done + +install -d -m 755 /etc/x-ui 2> /dev/null || true + +# Defense-in-depth: make sure the panel is not running while we mutate the DB. +if command -v systemctl > /dev/null 2>&1; then + systemctl stop x-ui > /dev/null 2>&1 || true +fi + +gen_random_string() { + local length="$1" + openssl rand -base64 $((length * 2)) | tr -dc 'a-zA-Z0-9' | head -c "$length" +} + +# Best-effort public IPv4 for the displayed access URL (cosmetic only — the +# panel binds 0.0.0.0). Falls back to the primary local IP, then a placeholder. +detect_ip() { + local ip="" + local url + for url in https://api4.ipify.org https://ipv4.icanhazip.com https://4.ident.me; do + ip=$(curl -fsS4 --max-time 3 "$url" 2> /dev/null | tr -d '[:space:]') + if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "$ip" + return 0 + fi + done + ip=$(hostname -I 2> /dev/null | awk '{print $1}') + if [ -n "$ip" ]; then + echo "$ip" + return 0 + fi + echo "" +} + +# Detect whether the seeded admin/admin default is still in place. +default_creds=$("$XUI_BIN" setting -show true 2> /dev/null | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') + +# The parse MUST yield exactly "true" or "false". If the command failed or its +# output format changed, refuse to proceed: do NOT write the sentinel, so the +# next boot retries instead of silently leaving admin/admin in place. +if [ "$default_creds" != "true" ] && [ "$default_creds" != "false" ]; then + log "ERROR: could not determine credential state (hasDefaultCredential='${default_creds}'); not writing sentinel, will retry next boot." + exit 1 +fi + +if [ "$default_creds" = "false" ]; then + log "non-default admin already configured; skipping credential regeneration." + { + echo "3x-ui first-boot: a non-default admin account already exists on this" + echo "instance, so credentials were left unchanged." + } > "$MOTD_FILE" 2> /dev/null || true + : > "$SENTINEL" 2> /dev/null || true + chmod 600 "$SENTINEL" 2> /dev/null || true + exit 0 +fi + +log "generating per-instance credentials..." + +NEW_USER="${XUI_USERNAME:-$(gen_random_string 10)}" +NEW_PASS="${XUI_PASSWORD:-$(gen_random_string 16)}" +NEW_PATH="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}" +NEW_PORT="${XUI_PANEL_PORT:-$(shuf -i 1024-62000 -n 1)}" + +# Clean settings slate: drops any baked port/webBasePath and forces the panel +# to regenerate its session secret + panel GUID on next start (per-instance). +"$XUI_BIN" setting -reset > /dev/null 2>&1 || true + +# Apply fresh random identity. UpdateFirstUser renames the seeded admin row and +# rehashes the password, so admin/admin no longer exists after this call. +if ! "$XUI_BIN" setting -username "$NEW_USER" -password "$NEW_PASS" -port "$NEW_PORT" -webBasePath "$NEW_PATH" > /dev/null 2>&1; then + log "ERROR: failed to apply new panel settings." + exit 1 +fi + +API_TOKEN=$("$XUI_BIN" setting -getApiToken true 2> /dev/null | grep -Eo 'apiToken: .+' | awk '{print $2}') +SERVER_IP=$(detect_ip) +ACCESS_URL="http://${SERVER_IP}:${NEW_PORT}/${NEW_PATH}" + +# Persist credentials for the operator (root-only). Values are shell-escaped +# with %q so the file stays safe to `source` even if a value contains shell +# metacharacters (the smoke test and operators source this file). +umask 077 +{ + echo "# 3x-ui per-instance credentials (generated on first boot)" + printf 'XUI_USERNAME=%q\n' "$NEW_USER" + printf 'XUI_PASSWORD=%q\n' "$NEW_PASS" + printf 'XUI_PANEL_PORT=%q\n' "$NEW_PORT" + printf 'XUI_WEB_BASE_PATH=%q\n' "$NEW_PATH" + printf 'XUI_ACCESS_URL=%q\n' "$ACCESS_URL" + printf 'XUI_API_TOKEN=%q\n' "$API_TOKEN" +} > "$CRED_FILE" +chmod 600 "$CRED_FILE" 2> /dev/null || true + +# Friendly login banner shown on SSH / console before the panel is reachable. +# /etc/motd is world-readable, so it MUST NOT contain the password or API token; +# those secrets live only in ${CRED_FILE} (mode 600). Show non-secret info only. +cat > "$MOTD_FILE" 2> /dev/null << EOF + +======================================================================== + 3x-ui panel — per-instance credentials (generated on first boot) +======================================================================== + Access URL : ${ACCESS_URL} + Username : ${NEW_USER} + + The password and API token are NOT shown here (this banner is + world-readable). Read them as root with: + sudo cat ${CRED_FILE} + + Change the password after login. If no public IP is shown above, + replace with the address you reach this server on. +======================================================================== + +EOF + +# Mark complete so we never regenerate on subsequent boots. +: > "$SENTINEL" 2> /dev/null || true +chmod 600 "$SENTINEL" 2> /dev/null || true + +log "done. Panel will start on port ${NEW_PORT} with a unique admin account." +exit 0 diff --git a/deploy/lightsail/README.md b/deploy/lightsail/README.md new file mode 100644 index 000000000..0e4a5d334 --- /dev/null +++ b/deploy/lightsail/README.md @@ -0,0 +1,94 @@ +# 3x-ui on Amazon Lightsail + +Two self-service ways to run 3x-ui on Lightsail, both producing **unique +per-instance credentials** (never `admin/admin`, never a shared secret). + +> **Reality check.** The Lightsail *blueprint* list (WordPress, LAMP, GitLab…) +> is curated by AWS — you **cannot** self-publish your panel there, and Lightsail +> **cannot** launch from an arbitrary EC2 AMI. What you *can* do yourself is the +> two paths below. (For a public AWS listing you'd use the EC2 **AMI** + +> Marketplace path in [`../marketplace/aws/`](../marketplace/aws/), which is a +> different product from Lightsail.) + +--- + +## Path A — launch script (simplest, self-service) + +Install on a fresh instance at creation time. No image to build. + +1. **Create instance** → platform **Linux/Unix** → blueprint **OS Only → Ubuntu 24.04**. +2. **Add launch script** → paste [`launch-script.sh`](launch-script.sh). +3. Create the instance. +4. After it boots, read the credentials: + ```bash + ssh ubuntu@ 'sudo cat /etc/x-ui/install-result.env' + ``` +5. **Open the panel port** (see the firewall note below) and log in. + +CLI equivalent: + +```bash +aws lightsail create-instances \ + --instance-names my-3xui \ + --availability-zone eu-central-1a \ + --blueprint-id ubuntu_24_04 \ + --bundle-id small_3_0 \ + --user-data file://deploy/lightsail/launch-script.sh \ + --region eu-central-1 +``` + +By default the panel uses a **random** high port (in `install-result.env`). To +pin a known port so you can pre-open it, set `export XUI_PANEL_PORT=54321` inside +`launch-script.sh`. + +--- + +## Path B — reusable snapshot (your own "ready image") + +Build a Lightsail **snapshot** once; launch as many instances from it as you +like, each generating its own credentials on first boot (the golden-image model). + +```bash +deploy/lightsail/build-snapshot.sh --region eu-central-1 --panel-port 54321 +``` + +What it does: launches a temporary Ubuntu instance with +[`snapshot-userdata.sh`](snapshot-userdata.sh) (installs the panel, **no DB**, +enables the first-boot unit), strips all state via the shared +[`cleanup.sh`](../packer/scripts/cleanup.sh), then snapshots and deletes the +build instance. Requires `awscli`, `jq`, `ssh` and Lightsail permissions. + +Launch instances from the snapshot: + +```bash +aws lightsail create-instances-from-snapshot \ + --instance-snapshot-name 3x-ui-ubuntu-24.04- \ + --instance-names my-3xui-1 --bundle-id small_3_0 \ + --availability-zone eu-central-1a --region eu-central-1 +``` + +Each launched instance runs `x-ui-firstboot` and writes its unique credentials to +`/etc/x-ui/credentials.txt` + `/etc/motd`. With `--panel-port` the port is the +same across instances (only the credentials differ), so you can pre-open it. + +> Lightsail snapshots are **private to your AWS account** (and region). To use one +> elsewhere you can export it to EC2 (`aws lightsail export-snapshot`) and share +> the resulting AMI. + +--- + +## Lightsail firewall note (important) + +Lightsail's per-instance firewall only opens **22 / 80 / 443** by default. The +panel runs on a different port, so you must open it: + +- Console: instance → **Networking → IPv4 Firewall → Add rule** (TCP, the panel port). +- CLI: + ```bash + aws lightsail open-instance-public-ports --region eu-central-1 \ + --instance-name my-3xui \ + --port-info fromPort=54321,toPort=54321,protocol=TCP + ``` + +The panel port is in `/etc/x-ui/install-result.env` (Path A) or +`/etc/x-ui/credentials.txt` (Path B), or fixed via `--panel-port` / `XUI_PANEL_PORT`. diff --git a/deploy/lightsail/build-snapshot.sh b/deploy/lightsail/build-snapshot.sh new file mode 100644 index 000000000..db6542575 --- /dev/null +++ b/deploy/lightsail/build-snapshot.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# +# build-snapshot.sh — build a reusable Amazon Lightsail snapshot of 3x-ui. +# +# Flow (mirrors the Packer golden-image model, via the Lightsail API): +# 1. create an Ubuntu Lightsail instance with snapshot-userdata.sh +# (installs the panel, NO database, enables the first-boot unit) +# 2. wait for provisioning, then (optionally) pin a known panel port and run +# the shared cleanup.sh (wipes any DB/creds/keys/host-keys/cloud-init state) +# 3. stop the instance and create an instance snapshot +# 4. delete the build instance (unless --keep-instance) +# +# Every instance you later launch from the snapshot generates its OWN unique +# credentials on first boot (see deploy/firstboot/). The snapshot is private to +# your AWS account. +# +# Requirements: awscli v2, jq, ssh. AWS credentials with Lightsail permissions. +# Usage: +# deploy/lightsail/build-snapshot.sh --region eu-central-1 [options] +# Options: +# --region AWS region (default: $AWS_REGION or eu-central-1) +# --blueprint-id Lightsail blueprint (default: ubuntu_24_04) +# --bundle-id Lightsail bundle/size (default: small_3_0) +# --availability-zone AZ (default: a) +# --panel-port

Pin the panel port in the snapshot so you can pre-open +# it in the Lightsail firewall (default: random per instance) +# --snapshot-name Snapshot name (default: 3x-ui-ubuntu-24.04-) +# --keep-instance Do not delete the build instance afterwards +set -euo pipefail + +REGION="${AWS_REGION:-eu-central-1}" +BLUEPRINT="ubuntu_24_04" +BUNDLE="small_3_0" +AZ="" +PANEL_PORT="" +SNAPSHOT_NAME="" +KEEP_INSTANCE=0 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STAMP="$(date +%Y%m%d-%H%M%S)" +INSTANCE_NAME="3xui-build-${STAMP}" +KEY_FILE="" + +log() { echo "[build-snapshot] $*"; } +die() { + echo "[build-snapshot] ERROR: $*" >&2 + exit 1 +} + +while [ $# -gt 0 ]; do + case "$1" in + --region) REGION="$2"; shift 2 ;; + --blueprint-id) BLUEPRINT="$2"; shift 2 ;; + --bundle-id) BUNDLE="$2"; shift 2 ;; + --availability-zone) AZ="$2"; shift 2 ;; + --panel-port) PANEL_PORT="$2"; shift 2 ;; + --snapshot-name) SNAPSHOT_NAME="$2"; shift 2 ;; + --keep-instance) KEEP_INSTANCE=1; shift ;; + -h | --help) sed -n '2,40p' "$0"; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +[ -n "$AZ" ] || AZ="${REGION}a" +[ -n "$SNAPSHOT_NAME" ] || SNAPSHOT_NAME="3x-ui-ubuntu-24.04-${STAMP}" + +for cmd in aws jq ssh; do + command -v "$cmd" > /dev/null 2>&1 || die "'$cmd' is required" +done + +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR) + +cleanup() { + [ -n "$KEY_FILE" ] && rm -f "$KEY_FILE" + if [ "$KEEP_INSTANCE" -eq 0 ]; then + aws lightsail delete-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +wait_state() { + local want="$1" tries="${2:-60}" st + for _ in $(seq 1 "$tries"); do + st=$(aws lightsail get-instance-state --instance-name "$INSTANCE_NAME" --region "$REGION" \ + --query 'state.name' --output text 2> /dev/null || echo "") + [ "$st" = "$want" ] && return 0 + sleep 5 + done + return 1 +} + +log "creating build instance ${INSTANCE_NAME} (${BLUEPRINT}/${BUNDLE}) in ${REGION}..." +aws lightsail create-instances \ + --instance-names "$INSTANCE_NAME" \ + --availability-zone "$AZ" \ + --blueprint-id "$BLUEPRINT" \ + --bundle-id "$BUNDLE" \ + --user-data "file://${SCRIPT_DIR}/snapshot-userdata.sh" \ + --region "$REGION" > /dev/null + +log "waiting for instance to run..." +wait_state running 60 || die "instance did not reach 'running'" + +IP=$(aws lightsail get-instance --instance-name "$INSTANCE_NAME" --region "$REGION" \ + --query 'instance.publicIpAddress' --output text) +if [ -z "$IP" ] || [ "$IP" = "None" ]; then die "no public IP"; fi +log "instance IP: ${IP}" + +KEY_FILE="$(mktemp)" +# download-default-key-pair returns the key in 'privateKeyBase64'. Despite the +# name, the CLI historically emits the plaintext PEM (-----BEGIN...); the API +# docs describe it as base64. Handle both: write PEM as-is, else base64-decode. +KEY_RAW="$(aws lightsail download-default-key-pair --region "$REGION" \ + --query 'privateKeyBase64' --output text)" +[ -n "$KEY_RAW" ] && [ "$KEY_RAW" != "None" ] || die "failed to download default key pair" +case "$KEY_RAW" in + *-----BEGIN*) printf '%s\n' "$KEY_RAW" > "$KEY_FILE" ;; + *) printf '%s' "$KEY_RAW" | base64 -d > "$KEY_FILE" 2> /dev/null \ + || die "private key is neither PEM nor valid base64" ;; +esac +grep -q -- "-----BEGIN" "$KEY_FILE" || die "downloaded key is not a valid PEM private key" +chmod 600 "$KEY_FILE" + +log "waiting for provisioning to finish (this installs the panel)..." +ok=0 +for _ in $(seq 1 72); do # ~12 min + if ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \ + 'test -f /var/lib/3xui-provision-done' 2> /dev/null; then + ok=1 + break + fi + sleep 10 +done +[ "$ok" -eq 1 ] || die "provisioning did not complete in time" +log "provisioning complete." + +if [ -n "$PANEL_PORT" ]; then + log "pinning panel port ${PANEL_PORT} (username/password stay random)..." + ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \ + "echo 'XUI_PANEL_PORT=${PANEL_PORT}' | sudo tee -a /etc/default/x-ui >/dev/null" +fi + +log "stripping instance state (shared cleanup.sh)..." +ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \ + 'curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/deploy/packer/scripts/cleanup.sh | sudo bash' + +log "stopping instance..." +aws lightsail stop-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null +wait_state stopped 60 || die "instance did not stop" + +log "creating snapshot ${SNAPSHOT_NAME}..." +aws lightsail create-instance-snapshot \ + --instance-name "$INSTANCE_NAME" \ + --instance-snapshot-name "$SNAPSHOT_NAME" \ + --region "$REGION" > /dev/null + +log "waiting for snapshot to become available..." +snap_ok=0 +for _ in $(seq 1 120); do # ~20 min + state=$(aws lightsail get-instance-snapshot --instance-snapshot-name "$SNAPSHOT_NAME" \ + --region "$REGION" --query 'instanceSnapshot.state' --output text 2> /dev/null || echo "") + [ "$state" = "available" ] && { + snap_ok=1 + break + } + sleep 10 +done +[ "$snap_ok" -eq 1 ] || die "snapshot did not become available" + +log "DONE." +echo +echo "================================================================" +echo " Lightsail snapshot ready: ${SNAPSHOT_NAME} (region ${REGION})" +echo "================================================================" +echo " Launch an instance from it:" +echo " aws lightsail create-instances-from-snapshot \\" +echo " --instance-snapshot-name ${SNAPSHOT_NAME} \\" +echo " --instance-names my-3xui-1 --bundle-id ${BUNDLE} \\" +echo " --availability-zone ${AZ} --region ${REGION}" +if [ -n "$PANEL_PORT" ]; then + echo + echo " Then open the panel port (pinned to ${PANEL_PORT}):" + echo " aws lightsail open-instance-public-ports --region ${REGION} \\" + echo " --instance-name my-3xui-1 \\" + echo " --port-info fromPort=${PANEL_PORT},toPort=${PANEL_PORT},protocol=TCP" +else + echo + echo " Each instance picks a RANDOM panel port. After it boots, read it from" + echo " sudo cat /etc/x-ui/credentials.txt" + echo " and open that TCP port in the instance's Lightsail IPv4 firewall." +fi +echo "================================================================" diff --git a/deploy/lightsail/launch-script.sh b/deploy/lightsail/launch-script.sh new file mode 100644 index 000000000..03e08bde5 --- /dev/null +++ b/deploy/lightsail/launch-script.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# +# Amazon Lightsail launch script for 3x-ui (self-service, per-instance creds). +# +# Use it one of two ways when creating an Ubuntu 24.04 Lightsail instance: +# * Console: "Add launch script" -> paste this file. +# * CLI: aws lightsail create-instances --user-data file://launch-script.sh ... +# +# It installs the latest 3x-ui release non-interactively and generates unique +# random credentials for THIS instance. The full credentials land in +# /etc/x-ui/install-result.env (mode 600); /etc/motd shows only the URL + username. +# +# IMPORTANT (Lightsail firewall): Lightsail only opens 22/80/443 by default. The +# panel listens on a random high port, so after boot read the port from +# /etc/x-ui/install-result.env and open it under the instance's Networking tab +# (IPv4 Firewall), or pin a known port below and pre-open it. +set -e +export DEBIAN_FRONTEND=noninteractive + +# --- Non-interactive install knobs ------------------------------------------ +export XUI_NONINTERACTIVE=1 +export XUI_SSL_MODE="${XUI_SSL_MODE:-none}" +# Pin a known panel port so you can pre-open it in the Lightsail firewall +# (otherwise a random high port is chosen). Username/password stay random: +# export XUI_PANEL_PORT="54321" +# Other optional pins (unset => secure random): +# export XUI_USERNAME="admin2" +# export XUI_PASSWORD="change-me" +# export XUI_WEB_BASE_PATH="panel" +# Domain TLS instead of plain HTTP: +# export XUI_SSL_MODE="domain" XUI_DOMAIN="panel.example.com" XUI_ACME_EMAIL="you@example.com" +# ---------------------------------------------------------------------------- + +curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash + +# /etc/motd is world-readable, so it gets ONLY non-secret info (URL + username); +# the full credentials stay in the root-only /etc/x-ui/install-result.env +# (mode 600) — read them with `sudo cat` over SSH. +if [ -r /etc/x-ui/install-result.env ]; then + # shellcheck disable=SC1091 + . /etc/x-ui/install-result.env + { + echo + echo "=== 3x-ui panel (generated on first boot) ===" + echo "URL: ${XUI_ACCESS_URL:-unknown}" + echo "Username: ${XUI_USERNAME:-unknown}" + echo "Password + API token: sudo cat /etc/x-ui/install-result.env" + echo "Open the panel port in the Lightsail IPv4 firewall, then log in." + echo "=============================================" + } >> /etc/motd 2>/dev/null || true +fi diff --git a/deploy/lightsail/snapshot-userdata.sh b/deploy/lightsail/snapshot-userdata.sh new file mode 100644 index 000000000..dbc67929a --- /dev/null +++ b/deploy/lightsail/snapshot-userdata.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# +# Lightsail snapshot provisioning user-data (used by build-snapshot.sh). +# +# Installs the 3x-ui panel into a build instance but creates NO database and +# NO credentials, and enables the first-boot unit. The instance is then snapshot +# so that every instance launched from the snapshot generates its own unique +# credentials on first boot (see deploy/firstboot/). +# +# This is the Lightsail equivalent of deploy/packer/scripts/provision.sh. It is +# NOT for end users — use deploy/lightsail/launch-script.sh for a direct install. +set -e +export DEBIAN_FRONTEND=noninteractive + +REPO=MHSanaei/3x-ui +XUI_DIR=/usr/local/x-ui +RAW="https://raw.githubusercontent.com/${REPO}/main" + +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates curl tar tzdata socat openssl cron jq + +ARCH=$(dpkg --print-architecture) # amd64 | arm64 +VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name) +if [ -z "$VER" ] || [ "$VER" = "null" ]; then + echo "failed to resolve 3x-ui version" >&2 + exit 1 +fi + +tmp=$(mktemp -d) +curl -fL4 --retry 3 -o "${tmp}/x.tar.gz" \ + "https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz" + +systemctl stop x-ui > /dev/null 2>&1 || true +rm -rf "$XUI_DIR" +tar -xzf "${tmp}/x.tar.gz" -C /usr/local/ +chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh" +chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true +cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui +chmod +x /usr/bin/x-ui +mkdir -p /var/log/x-ui + +# Panel + first-boot systemd units. +install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service +curl -fL4 -o "${XUI_DIR}/x-ui-firstboot.sh" "${RAW}/deploy/firstboot/x-ui-firstboot.sh" +curl -fL4 -o /etc/systemd/system/x-ui-firstboot.service "${RAW}/deploy/firstboot/x-ui-firstboot.service" +chmod 755 "${XUI_DIR}/x-ui-firstboot.sh" +chmod 644 /etc/systemd/system/x-ui-firstboot.service + +systemctl daemon-reload +systemctl enable x-ui-firstboot.service +systemctl enable x-ui.service + +# No DB, no creds in the image — first boot generates them per-instance. +rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true + +# Marker that build-snapshot.sh polls for over SSH. +touch /var/lib/3xui-provision-done +echo "[snapshot-userdata] provisioned 3x-ui ${VER} (${ARCH}); no DB created." diff --git a/deploy/marketplace/aws/README.md b/deploy/marketplace/aws/README.md new file mode 100644 index 000000000..bf8fcf52c --- /dev/null +++ b/deploy/marketplace/aws/README.md @@ -0,0 +1,92 @@ +# Publishing 3x-ui to the AWS Marketplace (AMI) + +This is the checklist for turning the Packer-built AMI into an AWS Marketplace +listing. It assumes you have already built an AMI with +[`../../packer/`](../../packer/) (locally or via `.github/workflows/image.yml`). + +> Do **not** commit AMI IDs, AWS account numbers, or credentials. The AMI ID is +> printed to the workflow job summary at build time. + +## 1. Seller registration (one-time) + +1. Sign in to the [AWS Marketplace Management Portal](https://aws.amazon.com/marketplace/management/) + with the AWS account that will own the listing. +2. Complete **seller registration** (legal entity, bank, tax interview). Required + before any product can be submitted. + +## 2. Build a compliant AMI + +Build in the seller account (or share the AMI into it): + +```bash +cd deploy/packer +packer init . +# amd64 +packer build -only='amazon-ebs.x-ui' \ + -var 'xui_version=vX.Y.Z' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' . +# arm64 (Graviton) +packer build -only='amazon-ebs.x-ui' \ + -var 'xui_version=vX.Y.Z' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' . +``` + +You can list both AMIs (amd64 + arm64) as architectures of a single Marketplace +product, or as separate products. + +The image already satisfies the Marketplace AMI policies enforced by `harden.sh` ++ `cleanup.sh`: + +- ✅ `PasswordAuthentication no`, `PermitRootLogin prohibit-password` +- ✅ no default OS account passwords (all locked) +- ✅ no baked `authorized_keys`, no SSH host keys (regenerated on boot) +- ✅ base OS = current Ubuntu 24.04 LTS, patched at build time +- ✅ no application default credentials — the panel admin is generated on first + boot on a random high port (no `admin/admin`, no shipped `x-ui.db`) + +## 3. Run the self-service AMI scan + +1. In the Management Portal: **Server products → AMIs → Upload/scan an AMI**. +2. Share the AMI with the AWS Marketplace scanning account when prompted + (the portal gives you the exact account id and the `modify-image-attribute` + command, or share it from the EC2 console). +3. Start the scan. It checks SSH config, default credentials, open ports, and + for malware. Fix any finding and re-scan. + +Common scan findings and where they're handled: + +| Finding | Fix (already in the build) | +| --- | --- | +| Password authentication enabled | `harden.sh` sshd drop-in | +| Root login with password | `harden.sh` `PermitRootLogin prohibit-password` | +| Default user password set | `harden.sh` `passwd -l` on all accounts | +| Authorized keys present | `cleanup.sh` removes them | +| Out-of-date packages | base image is the latest LTS; `provision.sh` runs `apt-get update` | + +## 4. Create the product (limited / private first) + +1. **Server products → Create new product → AMI** (or AMI + CloudFormation). +2. Add title, description, categories, pricing (free or paid), regions, the AMI + id, recommended instance types, and the **usage instructions** (tell buyers + to read `/etc/x-ui/credentials.txt` / MOTD after first boot for the generated + admin login, then change the password). +3. Submit as a **Limited** (private) listing first. AWS publishes it with + restricted visibility so only your account / allow-listed accounts see it. + +## 5. Preview & launch test + +1. From the limited listing, **subscribe and launch** a test instance. +2. SSH in, `sudo cat /etc/x-ui/credentials.txt`, open the panel URL, log in, + confirm the panel works and the credentials are unique to that instance. +3. Launch a second instance and confirm its credentials differ (no shared + secrets). + +## 6. Go public + +1. Once the scan passes and the preview looks correct, request **public + visibility** (move from Limited to Public) in the listing. +2. AWS does a final review before the listing goes live. + +## References + +- AWS Marketplace seller guide: +- AMI-based product requirements: +- Self-service AMI scanning: diff --git a/deploy/marketplace/hetzner/README.md b/deploy/marketplace/hetzner/README.md new file mode 100644 index 000000000..c136330c2 --- /dev/null +++ b/deploy/marketplace/hetzner/README.md @@ -0,0 +1,58 @@ +# 3x-ui on Hetzner Cloud + +Hetzner Cloud does **not** have a third-party image marketplace the way AWS does. +There are two practical ways to ship 3x-ui on Hetzner. + +## Option A — cloud-init (recommended, no image build) + +Use the generic user-data from [`../../cloud-init/`](../../cloud-init/). It installs +3x-ui non-interactively and generates unique per-instance credentials. + +Web console: **Create Server → Cloud config** → paste +[`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml). + +CLI: + +```bash +hcloud server create \ + --name xui-1 \ + --type cx22 \ + --image ubuntu-24.04 \ + --user-data-from-file deploy/cloud-init/cloud-init.yaml +``` + +After boot, fetch the generated credentials: + +```bash +ssh root@ 'cat /etc/x-ui/install-result.env' +``` + +## Option B — snapshot from the qcow2 / a configured server + +Hetzner lets you create a **snapshot** of a running server and launch new +servers from it. Two ways to get there: + +1. **From the Packer qcow2:** Hetzner does not allow direct qcow2 upload via the + normal API, but you can boot a server, write the image to its disk in rescue + mode, then take a snapshot — or simply use Option A, which needs no image. +2. **From a configured server:** spin up a server, install via cloud-init + (Option A), verify, then **delete `/etc/x-ui/x-ui.db` and the first-boot + sentinel** before snapshotting so clones regenerate their own credentials: + + ```bash + systemctl stop x-ui + rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done /etc/x-ui/credentials.txt + # re-enable first-boot regeneration if you installed via Packer: + systemctl enable x-ui-firstboot 2>/dev/null || true + ``` + + > ⚠️ If you snapshot a server **with** its `x-ui.db`, every clone shares the + > same admin credentials and session secret. Always remove the DB first. + +## "App"-style listing + +Hetzner's curated apps live in the community repo +[`github.com/hetznercloud/apps`](https://github.com/hetznercloud/apps): each app +is essentially a documented cloud-init config plus metadata. To propose 3x-ui as +a Hetzner app, follow that repo's contribution pattern and base the app's +cloud-config on [`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml). diff --git a/deploy/packer/.gitignore b/deploy/packer/.gitignore new file mode 100644 index 000000000..be9a06dde --- /dev/null +++ b/deploy/packer/.gitignore @@ -0,0 +1,7 @@ +# Packer build artifacts (never commit images or manifests) +output-qemu/ +*.qcow2 +*.raw +packer-manifest.json +packer_cache/ +crash.log diff --git a/deploy/packer/README.md b/deploy/packer/README.md new file mode 100644 index 000000000..c75c68757 --- /dev/null +++ b/deploy/packer/README.md @@ -0,0 +1,116 @@ +# 3x-ui golden image (Packer) + +Builds a cloud image with the 3x-ui panel pre-installed but **not configured**: +the image ships with **no database and no credentials**, and generates a unique +admin account on first boot. This is the **primary** path for AWS Marketplace +and any reusable image. + +Two sources, one build: + +| Source | Output | For | +| --- | --- | --- | +| `amazon-ebs` | AWS AMI | AWS / Marketplace | +| `qemu` | `qcow2` (+ `raw`) | Hetzner, DigitalOcean, Vultr, GCP, Azure, Oracle, bare metal | + +Both sources build for **`amd64` and `arm64`** (select with `-var xui_arch=...`). + +## Why no baked DB + +3x-ui seeds a hardcoded `admin/admin` user and generates its session secret + +panel GUID the first time it starts. If an image shipped an initialized +`x-ui.db`, **every clone would share the same credentials and secret**. So the +build deliberately: + +- installs the panel binary + systemd unit but **never starts it** and **never + creates a DB** (`scripts/provision.sh`); +- wipes any stray DB/credentials/host-keys at the end (`scripts/cleanup.sh`); +- enables `x-ui-firstboot.service`, which on first boot resets settings, sets a + random username/password on a random high port, regenerates the secret/GUID, + and writes the credentials to `/etc/x-ui/credentials.txt` + `/etc/motd` + (`deploy/firstboot/`). + +## Prerequisites + +- [Packer](https://developer.hashicorp.com/packer) ≥ 1.9 +- For `qemu` amd64: `qemu-system-x86`, `qemu-utils` (and `/dev/kvm` for acceptable speed) +- For `qemu` arm64: `qemu-system-arm`, `qemu-efi-aarch64`, `qemu-utils` — best built on an + arm64 host (native KVM); cross-building from x86 works but uses slow TCG emulation +- For `amazon-ebs`: AWS credentials with EC2 build permissions (arm64 builds on a Graviton + instance such as `t4g.small`) + +```bash +cd deploy/packer +packer init . +packer fmt -check . # formatting +packer validate . # both sources +``` + +## Build + +Build a specific release (recommended) or `latest`: + +```bash +# amd64 qcow2 (no cloud account needed) +packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' . + +# arm64 qcow2 (run on an arm64 host for native KVM) +packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' . + +# amd64 AWS AMI +packer build -only='amazon-ebs.x-ui' \ + -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' . + +# arm64 AWS AMI (Graviton) +packer build -only='amazon-ebs.x-ui' \ + -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' . +``` + +Outputs (per arch): +- `output-qemu/3x-ui-ubuntu-24.04-.qcow2` and `.raw` +- the AMI id (also recorded in `packer-manifest.json`) + +If `/dev/kvm` is unavailable, add `-var 'qemu_accelerator=tcg'` (much slower). + +## Key variables + +See [`variables.pkr.hcl`](variables.pkr.hcl) for the full list. + +| Variable | Default | Notes | +| --- | --- | --- | +| `xui_version` | `latest` | Release tag to install, e.g. `v3.3.1` | +| `xui_arch` | `amd64` | `amd64` or `arm64` (derives the base AMI / cloud image) | +| `region` | `eu-central-1` | AWS region (amazon-ebs) | +| `instance_type` | `t3.small` | EC2 build instance — must match the arch (`t4g.small` for arm64) | +| `qemu_accelerator` | `kvm` | `kvm` or `tcg` | +| `qemu_cpu` | `host` | arm64 `-cpu` model (`host` with KVM, `max` for TCG) | +| `ubuntu_version` | `24.04` | Base Ubuntu LTS (naming/tags) | + +The CI workflow builds both arches automatically: amd64 qcow2 on a standard runner, +arm64 qcow2 on a native `ubuntu-24.04-arm` runner, and both AMIs from a single runner +(the build instance runs in AWS). + +## First boot + +On the first boot of any instance launched from the image: + +1. `x-ui-firstboot.service` runs **before** `x-ui.service`. +2. It generates a unique admin username/password, a random panel port, a random + base path, and an API token. +3. Credentials are written to `/etc/x-ui/credentials.txt` (root-only) and shown + in `/etc/motd`. Retrieve them with `sudo cat /etc/x-ui/credentials.txt`. +4. The panel then starts on the random port. `admin/admin` never exists. + +## CI + +`.github/workflows/image.yml` runs this build on `release: published` (and via +`workflow_dispatch`), attaching the compressed `qcow2` to the release and +building the AMI when AWS credentials are configured. + +## A note on host firewalls + +`scripts/harden.sh` intentionally does **not** enable a restrictive host +firewall. 3x-ui opens Xray inbound ports on admin-chosen ports at runtime, which +a host firewall would block. Use your cloud provider's security groups/firewall +instead, and open the panel port + your inbound ports there. If you still want a +host firewall, add `ufw` rules in `harden.sh` allowing SSH, the panel port and +your inbound ports. diff --git a/deploy/packer/scripts/cleanup.sh b/deploy/packer/scripts/cleanup.sh new file mode 100644 index 000000000..9b8add987 --- /dev/null +++ b/deploy/packer/scripts/cleanup.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# cleanup.sh — strip all instance-specific state and secrets from the image. +# +# Runs LAST. The output image must contain no panel database, no credentials, +# no SSH host keys, and no baked authorized_keys. Fails the build if any of +# those survive. +set -euo pipefail + +echo "[cleanup] removing panel database, credentials and first-boot sentinel..." +rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* 2> /dev/null || true +rm -f /etc/x-ui/install-result.env /etc/x-ui/credentials.txt 2> /dev/null || true +rm -f /etc/x-ui/.firstboot-done 2> /dev/null || true + +echo "[cleanup] removing SSH host keys (regenerated on first boot)..." +rm -f /etc/ssh/ssh_host_* 2> /dev/null || true + +echo "[cleanup] removing any baked authorized_keys..." +rm -f /root/.ssh/authorized_keys 2> /dev/null || true +find /home -maxdepth 3 -name authorized_keys -type f -delete 2> /dev/null || true + +echo "[cleanup] resetting machine-id..." +truncate -s 0 /etc/machine-id 2> /dev/null || true +rm -f /var/lib/dbus/machine-id 2> /dev/null || true +ln -sf /etc/machine-id /var/lib/dbus/machine-id 2> /dev/null || true + +echo "[cleanup] resetting cloud-init so it re-runs on the real first boot..." +cloud-init clean --logs --seed > /dev/null 2>&1 || rm -rf /var/lib/cloud/* 2> /dev/null || true + +echo "[cleanup] truncating logs, history and package caches..." +find /var/log -type f -exec truncate -s 0 {} + 2> /dev/null || true +rm -rf /var/lib/x-ui /var/log/x-ui/* 2> /dev/null || true +apt-get clean || true +rm -rf /var/lib/apt/lists/* 2> /dev/null || true +rm -f /root/.bash_history 2> /dev/null || true +find /home -maxdepth 3 -name .bash_history -type f -delete 2> /dev/null || true +rm -rf /tmp/firstboot 2> /dev/null || true + +echo "[cleanup] verifying the image is clean..." +fail=0 +for f in /etc/x-ui/x-ui.db /etc/x-ui/credentials.txt /etc/x-ui/install-result.env /etc/x-ui/.firstboot-done; do + if [ -e "$f" ]; then + echo "[cleanup] FATAL: $f is present in the image" >&2 + fail=1 + fi +done +if ls /etc/ssh/ssh_host_* > /dev/null 2>&1; then + echo "[cleanup] FATAL: SSH host keys present in the image" >&2 + fail=1 +fi +if [ -e /root/.ssh/authorized_keys ]; then + echo "[cleanup] FATAL: /root/.ssh/authorized_keys present in the image" >&2 + fail=1 +fi +if [ "$fail" -ne 0 ]; then + exit 1 +fi + +echo "[cleanup] OK — no DB, no credentials, no host keys, no authorized_keys." diff --git a/deploy/packer/scripts/harden.sh b/deploy/packer/scripts/harden.sh new file mode 100644 index 000000000..c85e94c45 --- /dev/null +++ b/deploy/packer/scripts/harden.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# harden.sh — baseline OS hardening for AWS Marketplace AMI scanner compliance. +# +# Focus: the controls the scanner actually checks — key-only SSH, no root +# password login, and no default OS account passwords. A restrictive host +# firewall is intentionally NOT enforced by default because 3x-ui opens Xray +# inbound ports on admin-chosen ports at runtime (see README for the rationale +# and how to add ufw rules if you want them). +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +echo "[harden] applying SSH hardening..." +install -d -m 755 /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/99-3xui-hardening.conf << 'EOF' +# 3x-ui golden image hardening (AWS Marketplace scanner compliance) +PasswordAuthentication no +PermitRootLogin prohibit-password +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +EOF +chmod 644 /etc/ssh/sshd_config.d/99-3xui-hardening.conf + +echo "[harden] locking passwords on default OS accounts..." +# No account may ship with a usable password. Keys are provisioned per-instance +# by the cloud platform (EC2 metadata / cloud-init) on first boot. +# passwd -l locks the PASSWORD only; key-based login keeps working. +for u in root ubuntu admin; do + if id "$u" > /dev/null 2>&1; then + passwd -l "$u" > /dev/null 2>&1 || true + fi +done + +echo "[harden] enabling automatic security updates..." +apt-get update +apt-get install -y --no-install-recommends unattended-upgrades +systemctl enable unattended-upgrades > /dev/null 2>&1 || true + +echo "[harden] done." diff --git a/deploy/packer/scripts/provision.sh b/deploy/packer/scripts/provision.sh new file mode 100644 index 000000000..1d83346da --- /dev/null +++ b/deploy/packer/scripts/provision.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# provision.sh — install the 3x-ui panel into a golden image (Packer). +# +# Self-contained: mirrors install.sh's download/extract logic but DELIBERATELY +# does NOT run config_after_install and does NOT create a database. The image +# must ship without /etc/x-ui/x-ui.db so that deploy/firstboot generates unique +# per-instance credentials on first boot. Both x-ui.service and +# x-ui-firstboot.service are enabled but NOT started here. +# +# Inputs (from Packer environment_vars): +# XUI_VERSION release tag (e.g. v3.3.1) or 'latest' +# XUI_ARCH amd64 (default) or arm64 +set -euo pipefail + +XUI_VERSION="${XUI_VERSION:-latest}" +XUI_ARCH="${XUI_ARCH:-amd64}" +XUI_DIR="/usr/local/x-ui" +REPO="MHSanaei/3x-ui" +export DEBIAN_FRONTEND=noninteractive + +echo "[provision] installing base packages..." +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates curl tar tzdata socat openssl cron jq + +echo "[provision] resolving 3x-ui version..." +if [ "$XUI_VERSION" = "latest" ]; then + XUI_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name') +fi +if [ -z "$XUI_VERSION" ] || [ "$XUI_VERSION" = "null" ]; then + echo "[provision] ERROR: could not resolve 3x-ui release tag" >&2 + exit 1 +fi +echo "[provision] installing 3x-ui ${XUI_VERSION} (${XUI_ARCH})" + +tarball="x-ui-linux-${XUI_ARCH}.tar.gz" +url="https://github.com/${REPO}/releases/download/${XUI_VERSION}/${tarball}" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# Download the RELEASED binary tarball (no Go build inside the image). +curl -fL4 --retry 3 -o "${tmp}/${tarball}" "$url" + +# Extract into /usr/local/ (the tarball contains an x-ui/ directory). +systemctl stop x-ui > /dev/null 2>&1 || true +rm -rf "$XUI_DIR" +tar -xzf "${tmp}/${tarball}" -C /usr/local/ +chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh" +chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true + +# Install the x-ui management CLI. +if [ -f "${XUI_DIR}/x-ui.sh" ]; then + cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui +else + curl -fL4 -o /usr/bin/x-ui "https://raw.githubusercontent.com/${REPO}/main/x-ui.sh" +fi +chmod +x /usr/bin/x-ui +mkdir -p /var/log/x-ui + +# Panel systemd unit (Ubuntu base => debian variant). +install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service + +# First-boot per-instance credential unit + script (uploaded to /tmp/firstboot). +install -m 755 /tmp/firstboot/x-ui-firstboot.sh "${XUI_DIR}/x-ui-firstboot.sh" +install -m 644 /tmp/firstboot/x-ui-firstboot.service /etc/systemd/system/x-ui-firstboot.service + +systemctl daemon-reload +# Enable (start on next boot) but do NOT start now — there is no DB yet. +systemctl enable x-ui-firstboot.service +systemctl enable x-ui.service + +# Belt-and-braces: ensure no DB / sentinel was created during provisioning. +rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true + +echo "[provision] done — panel installed, services enabled, NO database initialized." diff --git a/deploy/packer/variables.pkr.hcl b/deploy/packer/variables.pkr.hcl new file mode 100644 index 000000000..7aa6eb636 --- /dev/null +++ b/deploy/packer/variables.pkr.hcl @@ -0,0 +1,109 @@ +// Input variables for the 3x-ui golden-image build. +// See README.md for usage. Override with -var / -var-file or env (PKR_VAR_*). + +variable "xui_version" { + type = string + description = "3x-ui release tag to install, e.g. v3.3.1. 'latest' resolves the newest GitHub release at build time." + default = "latest" +} + +variable "xui_arch" { + type = string + description = "CPU architecture to build for: amd64 or arm64." + default = "amd64" + validation { + condition = contains(["amd64", "arm64"], var.xui_arch) + error_message = "The xui_arch value must be 'amd64' or 'arm64'." + } +} + +variable "ubuntu_version" { + type = string + description = "Ubuntu LTS version label, used only for image naming/tags." + default = "24.04" +} + +// --- amazon-ebs (AMI) --------------------------------------------------------- + +variable "region" { + type = string + description = "AWS region the AMI is built in." + default = "eu-central-1" +} + +variable "instance_type" { + type = string + description = "EC2 instance type used to build the AMI. Must match xui_arch (e.g. t3.small for amd64, t4g.small for arm64/Graviton)." + default = "t3.small" +} + +variable "ami_name_prefix" { + type = string + description = "Prefix for the produced AMI name." + default = "3x-ui" +} + +variable "source_ami_filter_name" { + type = string + description = "Override for the Canonical Ubuntu base AMI name filter. Empty ⇒ derived from xui_arch (latest patched 24.04 LTS for that arch)." + default = "" +} + +variable "ssh_username" { + type = string + description = "Default SSH user on the base Ubuntu cloud image." + default = "ubuntu" +} + +// --- qemu (qcow2 / raw) ------------------------------------------------------- + +variable "qemu_iso_url" { + type = string + description = "Override for the Ubuntu cloud image used as the qemu base disk. Empty ⇒ derived from xui_arch (amd64/arm64 cloud image)." + default = "" +} + +variable "qemu_iso_checksum" { + type = string + description = "Checksum for the qemu base disk. 'file:' auto-fetches; 'none' skips verification." + default = "file:https://cloud-images.ubuntu.com/releases/24.04/release/SHA256SUMS" +} + +variable "qemu_accelerator" { + type = string + description = "QEMU accelerator: 'kvm' when /dev/kvm is available, else 'tcg' (slow software emulation)." + default = "kvm" +} + +variable "qemu_headless" { + type = bool + description = "Run QEMU without a display (required on CI runners)." + default = true +} + +variable "qemu_build_password" { + type = string + description = "Temporary password injected via cloud-init for Packer's build-time SSH. Locked/removed before the image is finalized." + default = "packer-build-temp-pw" + sensitive = true +} + +# --- qemu arm64-only knobs (ignored for amd64) ------------------------------- + +variable "qemu_cpu" { + type = string + description = "QEMU -cpu model for arm64 builds: 'host' with KVM on an arm64 host, 'max' for TCG emulation." + default = "host" +} + +variable "qemu_efi_code" { + type = string + description = "Path to the arm64 UEFI code firmware (AAVMF). Only used when xui_arch=arm64." + default = "/usr/share/AAVMF/AAVMF_CODE.fd" +} + +variable "qemu_efi_vars" { + type = string + description = "Path to the arm64 UEFI vars firmware template (AAVMF). Only used when xui_arch=arm64." + default = "/usr/share/AAVMF/AAVMF_VARS.fd" +} diff --git a/deploy/packer/x-ui.pkr.hcl b/deploy/packer/x-ui.pkr.hcl new file mode 100644 index 000000000..5ea2e4f2c --- /dev/null +++ b/deploy/packer/x-ui.pkr.hcl @@ -0,0 +1,160 @@ +// 3x-ui golden image — one build, two sources: +// * amazon-ebs : produces an AWS AMI (Marketplace-scannable) +// * qemu : produces a qcow2 (+ raw) for Hetzner/DO/Vultr/GCP/Azure/Oracle +// +// The image ships WITHOUT an initialized x-ui.db and WITHOUT any baked +// credentials. deploy/firstboot/x-ui-firstboot.{sh,service} generates unique +// per-instance credentials on first boot, before x-ui.service starts. +// +// Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh. + +packer { + required_plugins { + amazon = { + version = ">= 1.3.0" + source = "github.com/hashicorp/amazon" + } + qemu = { + version = ">= 1.1.0" + source = "github.com/hashicorp/qemu" + } + } +} + +locals { + build_stamp = formatdate("YYYYMMDD-hhmmss", timestamp()) + image_name = "${var.ami_name_prefix}-ubuntu-${var.ubuntu_version}-${var.xui_arch}" + is_arm = var.xui_arch == "arm64" + + # Base images are derived from xui_arch unless explicitly overridden. + source_ami_name = var.source_ami_filter_name != "" ? var.source_ami_filter_name : "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-${var.xui_arch}-server-*" + qemu_iso_url = var.qemu_iso_url != "" ? var.qemu_iso_url : "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-${var.xui_arch}.img" +} + +source "amazon-ebs" "x-ui" { + region = var.region + instance_type = var.instance_type + ssh_username = var.ssh_username + + ami_name = "${local.image_name}-${var.xui_version}-${local.build_stamp}" + ami_description = "3x-ui panel on Ubuntu ${var.ubuntu_version}. Per-instance credentials are generated on first boot." + + source_ami_filter { + filters = { + name = local.source_ami_name + root-device-type = "ebs" + virtualization-type = "hvm" + } + owners = ["099720109477"] // Canonical + most_recent = true + } + + launch_block_device_mappings { + device_name = "/dev/sda1" + volume_size = 8 + volume_type = "gp3" + delete_on_termination = true + } + + tags = { + Name = local.image_name + Project = "3x-ui" + XuiVersion = var.xui_version + BuildTool = "packer" + BaseOS = "ubuntu-${var.ubuntu_version}" + } +} + +source "qemu" "x-ui" { + iso_url = local.qemu_iso_url + iso_checksum = var.qemu_iso_checksum + disk_image = true + disk_size = "10G" + format = "qcow2" + + accelerator = var.qemu_accelerator + headless = var.qemu_headless + cpus = 2 + memory = 2048 + net_device = "virtio-net" + disk_interface = "virtio" + + // Arch-specific QEMU machine. amd64 uses Packer defaults (BIOS boot, x86_64); + // arm64 needs the aarch64 binary, the 'virt' machine and UEFI (AAVMF) firmware. + qemu_binary = local.is_arm ? "qemu-system-aarch64" : null + machine_type = local.is_arm ? "virt" : null + efi_boot = local.is_arm + efi_firmware_code = local.is_arm ? var.qemu_efi_code : null + efi_firmware_vars = local.is_arm ? var.qemu_efi_vars : null + qemuargs = local.is_arm ? [["-cpu", var.qemu_cpu]] : [] + + output_directory = "output-qemu" + vm_name = "${local.image_name}.qcow2" + + // Build-time access: a NoCloud seed sets a temporary password for the default + // user so Packer can SSH in. The seed is a separate CD-ROM (not part of the + // output disk); the password is locked by harden.sh and state wiped by cleanup.sh. + cd_label = "cidata" + cd_content = { + "meta-data" = "" + "user-data" = <<-EOT + #cloud-config + password: ${var.qemu_build_password} + chpasswd: { expire: false } + ssh_pwauth: true + EOT + } + + ssh_username = var.ssh_username + ssh_password = var.qemu_build_password + ssh_timeout = "20m" + boot_wait = "45s" + + shutdown_command = "sudo shutdown -P now" +} + +build { + name = "3x-ui" + sources = ["source.amazon-ebs.x-ui", "source.qemu.x-ui"] + + // Upload the first-boot unit + script so provision.sh can install them. + provisioner "shell" { + inline = ["mkdir -p /tmp/firstboot"] + } + provisioner "file" { + source = "${path.root}/../firstboot/x-ui-firstboot.sh" + destination = "/tmp/firstboot/x-ui-firstboot.sh" + } + provisioner "file" { + source = "${path.root}/../firstboot/x-ui-firstboot.service" + destination = "/tmp/firstboot/x-ui-firstboot.service" + } + + provisioner "shell" { + environment_vars = [ + "XUI_VERSION=${var.xui_version}", + "XUI_ARCH=${var.xui_arch}", + "DEBIAN_FRONTEND=noninteractive", + ] + execute_command = "chmod +x {{ .Path }}; sudo -E bash {{ .Path }}" + scripts = [ + "${path.root}/scripts/provision.sh", + "${path.root}/scripts/harden.sh", + "${path.root}/scripts/cleanup.sh", + ] + // give cloud-init time to release apt locks on the very first boot + pause_before = "10s" + } + + // Convert the qcow2 to raw for clouds that need it (qemu source only). + post-processor "shell-local" { + only = ["qemu.x-ui"] + inline = ["qemu-img convert -p -O raw output-qemu/${local.image_name}.qcow2 output-qemu/${local.image_name}.raw"] + } + + // Record the AMI id / artifacts for CI to surface. + post-processor "manifest" { + output = "packer-manifest.json" + strip_path = true + } +} diff --git a/deploy/test/smoke-firstboot.sh b/deploy/test/smoke-firstboot.sh new file mode 100644 index 000000000..4755addd9 --- /dev/null +++ b/deploy/test/smoke-firstboot.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# smoke-firstboot.sh — verify the first-boot per-instance credential script. +# +# Installs the released x-ui binary into a container WITHOUT a database, runs +# x-ui-firstboot.sh, and asserts: +# * fresh random credentials are generated (no admin/admin) +# * /etc/x-ui/credentials.txt (600) and /etc/motd are written +# * the sentinel is created and a second run is a no-op (creds unchanged) +# +# Requires Docker and network access. Usage: bash deploy/test/smoke-firstboot.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}" + +if ! command -v docker > /dev/null 2>&1; then + echo "ERROR: docker is required for this smoke test." >&2 + exit 1 +fi + +echo "== first-boot credential smoke test (image: $IMAGE) ==" + +docker run --rm \ + -v "${REPO_ROOT}/deploy/firstboot/x-ui-firstboot.sh:/root/x-ui-firstboot.sh:ro" \ + -e DEBIAN_FRONTEND=noninteractive \ + "$IMAGE" bash -euo pipefail -c ' + apt-get update -qq + apt-get install -y -qq curl tar openssl ca-certificates jq > /dev/null + + echo "--- installing released x-ui binary (no DB, no systemd) ---" + REPO=MHSanaei/3x-ui + ARCH=$(dpkg --print-architecture) # amd64 | arm64 + echo "container arch: $ARCH" + VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name) + [ -n "$VER" ] && [ "$VER" != "null" ] || { echo "FAIL: cannot resolve version"; exit 1; } + tmp=$(mktemp -d) + curl -fL4 -o "${tmp}/x.tar.gz" \ + "https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz" + tar -xzf "${tmp}/x.tar.gz" -C /usr/local/ + chmod +x /usr/local/x-ui/x-ui + install -m 755 /root/x-ui-firstboot.sh /usr/local/x-ui/x-ui-firstboot.sh + + # Guarantee a clean slate (the image must never ship a DB). + rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done + + echo "--- run 1: generate per-instance credentials ---" + /usr/local/x-ui/x-ui-firstboot.sh + + test -f /etc/x-ui/.firstboot-done || { echo "FAIL: sentinel not created"; exit 1; } + test -f /etc/x-ui/credentials.txt || { echo "FAIL: credentials.txt missing"; exit 1; } + perms=$(stat -c %a /etc/x-ui/credentials.txt) + [ "$perms" = "600" ] || { echo "FAIL: credentials.txt perms=$perms (want 600)"; exit 1; } + grep -q "3x-ui" /etc/motd || { echo "FAIL: motd not written"; exit 1; } + + # shellcheck disable=SC1090 + . /etc/x-ui/credentials.txt + [ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \ + || { echo "FAIL: username missing or still admin"; exit 1; } + first_user="$XUI_USERNAME" + + /usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \ + || { echo "FAIL: hasDefaultCredential is not false"; exit 1; } + + echo "--- run 2: must be a no-op (sentinel honored) ---" + /usr/local/x-ui/x-ui-firstboot.sh + # shellcheck disable=SC1090 + . /etc/x-ui/credentials.txt + [ "$XUI_USERNAME" = "$first_user" ] \ + || { echo "FAIL: credentials changed on re-run"; exit 1; } + + echo "SMOKE_PASS: firstboot user=$first_user (stable across re-run)" + ' + +echo "== first-boot smoke test PASSED ==" diff --git a/deploy/test/smoke-noninteractive.sh b/deploy/test/smoke-noninteractive.sh new file mode 100644 index 000000000..7b9d17886 --- /dev/null +++ b/deploy/test/smoke-noninteractive.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# smoke-noninteractive.sh — verify the non-interactive install path. +# +# Runs install.sh inside an Ubuntu container with NO TTY (piped) and +# XUI_NONINTERACTIVE=1, then asserts: +# * /etc/x-ui/install-result.env exists (mode 600) with random, non-default creds +# * the panel reports hasDefaultCredential: false (no admin/admin remains) +# * the panel HTTP server actually serves on the generated port/base path +# +# Requires Docker and network access (install.sh downloads the released binary). +# Usage: bash deploy/test/smoke-noninteractive.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}" + +if ! command -v docker > /dev/null 2>&1; then + echo "ERROR: docker is required for this smoke test." >&2 + exit 1 +fi + +echo "== non-interactive install smoke test (image: $IMAGE) ==" + +docker run --rm \ + -v "${REPO_ROOT}/install.sh:/root/install.sh:ro" \ + -e XUI_NONINTERACTIVE=1 \ + -e XUI_SSL_MODE=none \ + -e DEBIAN_FRONTEND=noninteractive \ + "$IMAGE" bash -euo pipefail -c ' + apt-get update -qq + apt-get install -y -qq curl tar openssl ca-certificates > /dev/null + + echo "--- running install.sh piped (no TTY) ---" + # Piping guarantees stdin is not a TTY, exercising the auto non-interactive path. + cat /root/install.sh | bash + + echo "--- assertions ---" + RESULT=/etc/x-ui/install-result.env + test -f "$RESULT" || { echo "FAIL: $RESULT missing"; exit 1; } + + perms=$(stat -c %a "$RESULT") + [ "$perms" = "600" ] || { echo "FAIL: $RESULT perms=$perms (want 600)"; exit 1; } + + # shellcheck disable=SC1090 + . "$RESULT" + [ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \ + || { echo "FAIL: username missing or still admin"; exit 1; } + [ -n "${XUI_PASSWORD:-}" ] && [ "$XUI_PASSWORD" != "admin" ] \ + || { echo "FAIL: password missing or still admin"; exit 1; } + [ -n "${XUI_PANEL_PORT:-}" ] || { echo "FAIL: port missing"; exit 1; } + + # No default admin in the DB. + /usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \ + || { echo "FAIL: hasDefaultCredential is not false"; exit 1; } + + echo "--- verifying the panel serves HTTP ---" + cd /usr/local/x-ui + ./x-ui > /tmp/xui.log 2>&1 & + xpid=$! + for _ in $(seq 1 15); do + code=$(curl -s -o /dev/null -w "%{http_code}" \ + "http://127.0.0.1:${XUI_PANEL_PORT}/${XUI_WEB_BASE_PATH}/" 2>/dev/null || true) + case "$code" in 200|301|302|307|308) break ;; esac + sleep 1 + done + kill "$xpid" 2>/dev/null || true + echo "panel HTTP status: ${code:-none}" + case "${code:-}" in + 200|301|302|307|308) : ;; + *) echo "FAIL: panel did not serve (status ${code:-none})"; tail -n 30 /tmp/xui.log; exit 1 ;; + esac + + echo "SMOKE_PASS: user=$XUI_USERNAME port=$XUI_PANEL_PORT path=$XUI_WEB_BASE_PATH" + ' + +echo "== non-interactive smoke test PASSED ==" diff --git a/install.sh b/install.sh index 6e0d4b909..5eb097835 100644 --- a/install.sh +++ b/install.sh @@ -42,6 +42,16 @@ arch() { echo "Arch: $(arch)" +# Non-interactive mode: triggered explicitly via XUI_NONINTERACTIVE=1, or +# implicitly when stdin is not a TTY (e.g. `curl ... | bash`, cloud-init). +# In this mode every prompt below is replaced by an env var or a sane default. +if [[ "${XUI_NONINTERACTIVE:-0}" == "1" ]] || [[ ! -t 0 ]]; then + NONINTERACTIVE=1 +else + NONINTERACTIVE=0 +fi +export NONINTERACTIVE + # Simple helpers is_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1 @@ -122,6 +132,54 @@ gen_random_string() { | head -c "$length" } +# prompt_or_default VARNAME "prompt text" "default" [ENV_NAME] +# Interactive: read into VARNAME. Non-interactive: VARNAME = ${ENV_NAME:-default}. +# ENV_NAME defaults to VARNAME when omitted. Keeps every interactive prompt +# string byte-for-byte identical to the original `read -rp`. +prompt_or_default() { + local __var="$1" __prompt="$2" __default="$3" __env="${4:-$1}" + if [[ "$NONINTERACTIVE" == "1" ]]; then + printf -v "$__var" '%s' "${!__env:-$__default}" + else + # shellcheck disable=SC2229 + read -rp "$__prompt" "$__var" + fi +} + +# write_install_result +# Persists a parseable, root-only credentials file consumed by cloud-init/MOTD. +# Values are written with printf '%q' so a pinned password/username containing +# spaces, quotes, $(...) or backticks is shell-escaped and the file stays safely +# source-able (consumers do '. install-result.env'). For the alphanumeric random +# values gen_random_string emits, %q is a no-op. This is a DIFFERENT file from the +# Postgres env file (/etc/default/x-ui). +write_install_result() { + local u="$1" p="$2" port="$3" wbp="$4" scheme="$5" host="$6" token="$7" dbtype="$8" + local result_file="/etc/x-ui/install-result.env" + local url_host="${host:-SERVER_IP_UNKNOWN}" + install -d -m 755 /etc/x-ui 2> /dev/null + local prev_umask + prev_umask=$(umask) + umask 077 + if ! { + printf 'XUI_USERNAME=%q\n' "$u" + printf 'XUI_PASSWORD=%q\n' "$p" + printf 'XUI_PANEL_PORT=%q\n' "$port" + printf 'XUI_WEB_BASE_PATH=%q\n' "$wbp" + printf 'XUI_ACCESS_URL=%q\n' "${scheme}://${url_host}:${port}/${wbp}" + printf 'XUI_API_TOKEN=%q\n' "$token" + printf 'XUI_DB_TYPE=%q\n' "$dbtype" + } > "$result_file"; then + umask "$prev_umask" + echo -e "${yellow}Warning: failed to write ${result_file}.${plain}" >&2 + return 1 + fi + umask "$prev_umask" + chmod 600 "$result_file" 2> /dev/null + chown root:root "$result_file" 2> /dev/null || true + echo -e "${green}Install result written to ${result_file} (mode 600).${plain}" +} + install_postgres_local() { local pg_user pg_pass pg_pass=$(gen_random_string 24) @@ -391,7 +449,7 @@ setup_ip_certificate() { # Choose port for HTTP-01 listener (default 80, prompt override) local WebPort="" - read -rp "Port to use for ACME HTTP-01 listener (default 80): " WebPort + prompt_or_default WebPort "Port to use for ACME HTTP-01 listener (default 80): " "80" XUI_ACME_HTTP_PORT WebPort="${WebPort:-80}" if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then echo -e "${red}Invalid port provided. Falling back to 80.${plain}" @@ -408,6 +466,10 @@ setup_ip_certificate() { echo -e "${yellow}Port ${WebPort} is in use.${plain}" local alt_port="" + if [[ "$NONINTERACTIVE" == "1" ]]; then + echo -e "${red}Port ${WebPort} is busy; cannot proceed in non-interactive mode.${plain}" + return 1 + fi read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port alt_port="${alt_port// /}" if [[ -z "${alt_port}" ]]; then @@ -429,6 +491,7 @@ setup_ip_certificate() { # Issue certificate with shortlived profile echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}" ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1 + [[ -n "${XUI_ACME_EMAIL:-}" ]] && ~/.acme.sh/acme.sh --register-account -m "${XUI_ACME_EMAIL}" > /dev/null 2>&1 ~/.acme.sh/acme.sh --issue \ ${domain_args} \ @@ -517,22 +580,30 @@ ssl_cert_issue() { # get the domain here, and we need to verify it local domain="" - while true; do - read -rp "Please enter your domain name: " domain - domain="${domain// /}" # Trim whitespace - - if [[ -z "$domain" ]]; then - echo -e "${red}Domain name cannot be empty. Please try again.${plain}" - continue + if [[ "$NONINTERACTIVE" == "1" ]]; then + domain="${XUI_DOMAIN// /}" + if [[ -z "$domain" ]] || ! is_domain "$domain"; then + echo -e "${red}XUI_SSL_MODE=domain requires a valid XUI_DOMAIN (got: '${XUI_DOMAIN:-}').${plain}" + return 1 fi + else + while true; do + read -rp "Please enter your domain name: " domain + domain="${domain// /}" # Trim whitespace - if ! is_domain "$domain"; then - echo -e "${red}Invalid domain format: ${domain}. Please enter a valid domain name.${plain}" - continue - fi + if [[ -z "$domain" ]]; then + echo -e "${red}Domain name cannot be empty. Please try again.${plain}" + continue + fi - break - done + if ! is_domain "$domain"; then + echo -e "${red}Invalid domain format: ${domain}. Please enter a valid domain name.${plain}" + continue + fi + + break + done + fi echo -e "${green}Your domain is: ${domain}, checking it...${plain}" SSL_ISSUED_DOMAIN="${domain}" @@ -574,7 +645,7 @@ ssl_cert_issue() { # get the port number for the standalone server local WebPort=80 - read -rp "Please choose which port to use (default is 80): " WebPort + prompt_or_default WebPort "Please choose which port to use (default is 80): " "80" XUI_ACME_HTTP_PORT if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then echo -e "${yellow}Your input ${WebPort} is invalid, will use default port 80.${plain}" WebPort=80 @@ -588,6 +659,7 @@ ssl_cert_issue() { if [[ ${cert_exists} -eq 0 ]]; then # issue the certificate ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force + [[ -n "${XUI_ACME_EMAIL:-}" ]] && ~/.acme.sh/acme.sh --register-account -m "${XUI_ACME_EMAIL}" > /dev/null 2>&1 ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force if [ $? -ne 0 ]; then echo -e "${red}Issuing certificate failed, please check logs.${plain}" @@ -605,7 +677,11 @@ ssl_cert_issue() { reloadCmd="systemctl restart x-ui || rc-service x-ui restart" echo -e "${green}Default --reloadcmd for ACME is: ${yellow}systemctl restart x-ui || rc-service x-ui restart${plain}" echo -e "${green}This command will run on every certificate issue and renew.${plain}" - read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd + if [[ "$NONINTERACTIVE" == "1" ]]; then + setReloadcmd="n" + else + read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd + fi if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; systemctl restart x-ui" echo -e "${green}\t2.${plain} Input your own command" @@ -671,7 +747,11 @@ ssl_cert_issue() { systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null # Prompt user to set panel paths after successful certificate installation - read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel + if [[ "$NONINTERACTIVE" == "1" ]]; then + setPanel="y" + else + read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel + fi if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then local webCertFile="/root/cert/${domain}/fullchain.pem" local webKeyFile="/root/cert/${domain}/privkey.pem" @@ -712,12 +792,24 @@ prompt_and_setup_ssl() { echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)" echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths." echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel." - read -rp "Choose an option (default 2 for IP): " ssl_choice - ssl_choice="${ssl_choice// /}" # Trim whitespace + if [[ "$NONINTERACTIVE" == "1" ]]; then + case "${XUI_SSL_MODE:-none}" in + domain) ssl_choice="1" ;; + ip) ssl_choice="2" ;; + none | "") ssl_choice="4" ;; + *) + echo -e "${yellow}Unknown XUI_SSL_MODE='${XUI_SSL_MODE}', defaulting to none (HTTP).${plain}" + ssl_choice="4" + ;; + esac + else + read -rp "Choose an option (default 2 for IP): " ssl_choice + ssl_choice="${ssl_choice// /}" # Trim whitespace - # Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4) - if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then - ssl_choice="2" + # Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4) + if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then + ssl_choice="2" + fi fi case "$ssl_choice" in @@ -748,7 +840,7 @@ prompt_and_setup_ssl() { # Ask for optional IPv6 local ipv6_addr="" - read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr + prompt_or_default ipv6_addr "Do you have an IPv6 address to include? (leave empty to skip): " "" XUI_SSL_IPV6 ipv6_addr="${ipv6_addr// /}" # Trim whitespace # Stop panel if running (port 80 needed) @@ -840,7 +932,12 @@ prompt_and_setup_ssl() { SSL_HOST="${server_ip}" local bind_local="" - read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local + if [[ "$NONINTERACTIVE" == "1" ]]; then + # Cloud images must stay reachable on their public interface. + bind_local="n" + else + read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local + fi if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then ${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1 SSL_HOST="127.0.0.1" @@ -895,22 +992,29 @@ config_after_install() { done if [[ -z "$server_ip" ]]; then - echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}" - while [[ -z "$server_ip" ]]; do - read -rp "Please enter your server's public IPv4 address: " server_ip - server_ip="${server_ip// /}" - if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo -e "${red}Invalid IPv4 address. Please try again.${plain}" - server_ip="" - fi - done + if [[ "$NONINTERACTIVE" == "1" ]]; then + # Panel binds 0.0.0.0 regardless; the IP is only used to compose the + # displayed access URL. Fall back to XUI_SERVER_IP or leave blank. + server_ip="${XUI_SERVER_IP:-}" + else + echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}" + while [[ -z "$server_ip" ]]; do + read -rp "Please enter your server's public IPv4 address: " server_ip + server_ip="${server_ip// /}" + if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${red}Invalid IPv4 address. Please try again.${plain}" + server_ip="" + fi + done + fi fi if [[ ${#existing_webBasePath} -lt 4 ]]; then if [[ "$existing_hasDefaultCredential" == "true" ]]; then - local config_webBasePath=$(gen_random_string 18) - local config_username=$(gen_random_string 10) - local config_password=$(gen_random_string 10) + local config_webBasePath="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}" + local config_username="${XUI_USERNAME:-$(gen_random_string 10)}" + local config_password="${XUI_PASSWORD:-$(gen_random_string 10)}" + local config_port="" local db_label="SQLite (/etc/x-ui/x-ui.db)" echo "" @@ -919,8 +1023,16 @@ config_after_install() { echo -e "${green}═══════════════════════════════════════════${plain}" echo -e " 1) SQLite (default — recommended for < 500 clients)" echo -e " 2) PostgreSQL (recommended for high client counts / many nodes)" - read -rp "Choose [1]: " db_choice - db_choice="${db_choice:-1}" + if [[ "$NONINTERACTIVE" == "1" ]]; then + if [[ "${XUI_DB_TYPE:-sqlite}" == "postgres" ]]; then + db_choice="2" + else + db_choice="1" + fi + else + read -rp "Choose [1]: " db_choice + db_choice="${db_choice:-1}" + fi if [[ "$db_choice" == "2" ]]; then local xui_env_file case "${release}" in @@ -939,6 +1051,30 @@ config_after_install() { local pg_mode="" local pg_local_installed=0 while [[ -z "$xui_dsn" ]]; do + if [[ "$NONINTERACTIVE" == "1" ]]; then + if [[ -n "${XUI_DB_DSN:-}" ]]; then + xui_dsn="${XUI_DB_DSN}" + db_label="PostgreSQL (external)" + break + fi + echo -e "${yellow}Installing PostgreSQL locally (non-interactive)...${plain}" + local pg_cred_file + pg_cred_file=$(mktemp 2> /dev/null) || pg_cred_file=$(mktemp -t x-ui-pg-creds.XXXXXXXX) + if [[ -n "${pg_cred_file}" ]] && xui_dsn=$(PG_CRED_FILE="${pg_cred_file}" install_postgres_local); then + pg_local_installed=1 + if [[ -r "${pg_cred_file}" ]]; then + # shellcheck disable=SC1090 + source "${pg_cred_file}" + fi + rm -f "${pg_cred_file}" + db_label="PostgreSQL (${PG_USER}@${PG_HOST}:${PG_PORT}/${PG_DB})" + break + fi + rm -f "${pg_cred_file}" + echo -e "${red}PostgreSQL installation failed in non-interactive mode; aborting.${plain}" + echo -e "${yellow}Set XUI_DB_DSN to use an existing server, or XUI_DB_TYPE=sqlite.${plain}" + exit 1 + fi echo "" echo -e " 1) Install PostgreSQL locally and create a dedicated user/db (recommended)" echo -e " 2) Use an existing PostgreSQL server (enter DSN)" @@ -1008,13 +1144,23 @@ EOF fi fi - read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm - if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then - read -rp "Please set up the panel port: " config_port - echo -e "${yellow}Your Panel Port is: ${config_port}${plain}" + if [[ "$NONINTERACTIVE" == "1" ]]; then + if [[ -n "${XUI_PANEL_PORT:-}" ]]; then + config_port="${XUI_PANEL_PORT}" + echo -e "${yellow}Your Panel Port is: ${config_port}${plain}" + else + config_port=$(shuf -i 1024-62000 -n 1) + echo -e "${yellow}Generated random port: ${config_port}${plain}" + fi else - local config_port=$(shuf -i 1024-62000 -n 1) - echo -e "${yellow}Generated random port: ${config_port}${plain}" + read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm + if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then + read -rp "Please set up the panel port: " config_port + echo -e "${yellow}Your Panel Port is: ${config_port}${plain}" + else + config_port=$(shuf -i 1024-62000 -n 1) + echo -e "${yellow}Generated random port: ${config_port}${plain}" + fi fi ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" @@ -1081,6 +1227,14 @@ EOF echo -e "${yellow}⚠ Save the password — it is not stored anywhere else in plain text.${plain}" unset PG_USER PG_PASS PG_HOST PG_PORT PG_DB fi + + # Persist a machine-parseable credentials file for cloud-init / MOTD. + : "${SSL_SCHEME:=https}" + : "${SSL_HOST:=${server_ip}}" + local db_type_out="sqlite" + [[ "$db_choice" == "2" ]] && db_type_out="postgres" + write_install_result "${config_username}" "${config_password}" "${config_port}" \ + "${config_webBasePath}" "${SSL_SCHEME}" "${SSL_HOST}" "${config_apiToken}" "${db_type_out}" else local config_webBasePath=$(gen_random_string 18) echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" @@ -1104,8 +1258,8 @@ EOF fi else if [[ "$existing_hasDefaultCredential" == "true" ]]; then - local config_username=$(gen_random_string 10) - local config_password=$(gen_random_string 10) + local config_username="${XUI_USERNAME:-$(gen_random_string 10)}" + local config_password="${XUI_PASSWORD:-$(gen_random_string 10)}" echo -e "${yellow}Default credentials detected. Security update required...${plain}" ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" @@ -1114,6 +1268,14 @@ EOF echo -e "${green}Username: ${config_username}${plain}" echo -e "${green}Password: ${config_password}${plain}" echo -e "###############################################" + + # Persist a machine-parseable credentials file for cloud-init / MOTD. + local config_apiToken + config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}') + : "${SSL_SCHEME:=https}" + : "${SSL_HOST:=${server_ip}}" + write_install_result "${config_username}" "${config_password}" "${existing_port}" \ + "${existing_webBasePath}" "${SSL_SCHEME}" "${SSL_HOST}" "${config_apiToken}" "${XUI_DB_TYPE:-sqlite}" else echo -e "${green}Username, Password, and WebBasePath are properly set.${plain}" fi