Selaa lähdekoodia

feat: release-driven golden-image & unattended-install deployment pipeline (#5323)

* feat(install): add non-interactive install path for cloud/golden-image use

Trigger non-interactive mode when XUI_NONINTERACTIVE=1 or stdin is not a
TTY (curl | bash, cloud-init). Every prompt is then replaced by an env var
or a sane default; interactive prompts stay byte-for-byte identical.

Honored env vars: XUI_USERNAME, XUI_PASSWORD, XUI_PANEL_PORT,
XUI_WEB_BASE_PATH (unset => random, as before), XUI_SSL_MODE=none|ip|domain
(default none), XUI_DOMAIN, XUI_ACME_EMAIL, XUI_DB_TYPE/XUI_DB_DSN, plus
additive XUI_ACME_HTTP_PORT, XUI_SSL_IPV6, XUI_SERVER_IP.

On success, write /etc/x-ui/install-result.env (mode 600) with the panel
creds + access URL + api token, in both interactive and non-interactive
modes, so cloud-init/MOTD can surface them. Postgres in non-interactive
mode requires XUI_DB_DSN or installs locally; never silently downgrades.

* feat(deploy): add first-boot per-instance credential generation

Golden images ship with no x-ui.db. x-ui-firstboot.sh runs once (guarded by
/etc/x-ui/.firstboot-done), before x-ui.service, and replaces the seeded
admin/admin with fresh random username/password on a random high port,
regenerates the session secret/panel GUID via 'x-ui setting -reset', mints an
API token, and writes the creds to /etc/x-ui/credentials.txt (600) + /etc/motd.

Idempotent: skips regeneration if a non-default admin already exists. The
oneshot unit is ordered After=network-online/cloud-init and Before=x-ui.service
so the panel never serves default credentials.

* chore(deploy): force LF for cloud-image deploy assets (.service/.hcl/.yaml)

* feat(deploy): add Packer config + provisioning scripts for golden image

One build, two sources: amazon-ebs (AWS AMI, Canonical Ubuntu 24.04 base via
source_ami_filter) and qemu (qcow2 + raw, NoCloud-seeded for build-time SSH).
Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.

- provision.sh: downloads the released x-ui tarball (no Go build), installs the
  panel + firstboot unit, enables but does NOT start services, creates NO DB.
- harden.sh: key-only SSH, no root password login, locks default account
  passwords, enables unattended-upgrades (scanner-compliant).
- cleanup.sh: wipes any DB/creds, SSH host keys, authorized_keys, machine-id,
  cloud-init state, logs and history; fails the build if any secret survives.

packer fmt -check clean; packer validate passes for both sources.

* feat(deploy): add generic cloud-init user-data for unattended install

cloud-init.yaml installs the latest 3x-ui non-interactively (XUI_NONINTERACTIVE=1)
on any cloud-init platform, generating unique per-instance credentials and
surfacing them via /etc/x-ui/install-result.env, serial console and MOTD.
README documents per-provider usage (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
and all XUI_* knobs.

* ci: add image.yml to build cloud images on release

On release: published (or workflow_dispatch with a tag), waits for the
x-ui-linux-amd64.tar.gz asset (handles the release-matrix upload race), then:
- qemu-image (always): builds the qcow2 with Packer and attaches a compressed
  .qcow2.xz + sha256 to the GitHub release. Uses KVM when /dev/kvm exists,
  else TCG.
- ami-image (gated): builds the AWS AMI only when AWS creds exist (OIDC role
  preferred, else access keys), so forks skip cleanly. Prints the AMI ID to the
  job summary. No secrets or AMI IDs are committed.

* test(deploy): add container smoke tests for install + firstboot

smoke-noninteractive.sh: runs install.sh piped (no TTY) with
XUI_NONINTERACTIVE=1 in an Ubuntu container; asserts install-result.env (600)
holds random non-default creds, hasDefaultCredential is false, and the panel
serves HTTP.

smoke-firstboot.sh: installs the released binary with no DB, runs
x-ui-firstboot.sh; asserts per-instance creds + credentials.txt (600) + MOTD,
no admin/admin, and that a second run is a no-op (sentinel honored).

smoke.yml runs both as gated jobs on PRs/pushes touching install.sh or deploy/**.
Both pass locally against the v3.3.1 release binary.

* docs(deploy): add Packer/marketplace docs and link from README

- deploy/README.md: index of the cloud-deploy tooling and the two models
- deploy/packer/README.md: how to build locally, variables, first-boot behavior
- deploy/marketplace/aws/README.md: seller registration -> AMI scan ->
  limited-visibility preview -> go-public checklist
- deploy/marketplace/hetzner/README.md: cloud-init-first guidance + snapshot
  caveat (delete x-ui.db first) + hetznercloud/apps reference
- README.md: link the unattended-install / cloud-image docs from Quick Start

* feat(deploy): build golden images for arm64 as well as amd64

The install path was already multi-arch (install.sh auto-detects arch); this
extends the golden image + CI to arm64:

- packer: xui_arch (amd64|arm64, validated) now derives the base AMI filter and
  the Ubuntu cloud image; the qemu source switches to qemu-system-aarch64 + virt
  machine + AAVMF UEFI firmware for arm64. amd64 path unchanged.
- image.yml: arch matrix. AMIs for amd64 (t3.small) + arm64 (t4g.small/Graviton)
  from one runner; qcow2 for amd64 on a standard runner and arm64 on a native
  ubuntu-24.04-arm runner. Waits for both release tarballs.
- smoke.yml: run install + firstboot smoke tests on amd64 and arm64 runners;
  smoke-firstboot.sh now resolves the arch tarball via dpkg.
- docs updated for both arches.

packer fmt/validate pass for amd64 and arm64; actionlint + shellcheck clean.
Verified locally: non-interactive install AND firstboot run on the real arm64
release binary under emulation (ELF aarch64, no admin/admin).

* chore(deploy): default AWS region to eu-central-1 (Frankfurt)

Replace the us-east-1 fallback in image.yml (4 sites) and the Packer 'region'
default + doc examples. Still overridable via the AWS_REGION repo variable / the
-var 'region=...' flag.

* feat(deploy): add Amazon Lightsail support (launch script + snapshot builder)

Lightsail can't launch from an EC2 AMI and its blueprint list isn't
self-publishable, so add the two self-service paths instead:

- launch-script.sh: paste into Lightsail 'Add launch script' (or --user-data) to
  install 3x-ui non-interactively with unique per-instance credentials.
- snapshot-userdata.sh + build-snapshot.sh: AWS CLI pipeline that provisions a
  build instance (panel installed, NO DB, firstboot enabled), runs the shared
  cleanup.sh, then snapshots it. Instances launched from the snapshot mint their
  own credentials on first boot. Optional --panel-port pins a known port for the
  Lightsail firewall.
- README documents both paths, the firewall caveat, and the blueprint reality.

EC2 AMI / Marketplace path kept untouched alongside. All scripts shellcheck-clean.

* fix(deploy): address Copilot PR review findings

- install.sh + firstboot: write install-result.env / credentials.txt values with
  printf %q so the files stay safe to source even if creds are pinned with shell
  metacharacters (no-op for the alphanumeric random defaults).
- firstboot: fail closed if 'x-ui setting -show' can't be parsed to true/false —
  exit without writing the sentinel so the next boot retries, instead of silently
  skipping regeneration and risking admin/admin.
- firstboot + cloud-init + lightsail launch-script: keep secrets out of the
  world-readable /etc/motd (show URL + username only; full creds via the mode-600
  file / serial console).
- lightsail build-snapshot: handle download-default-key-pair returning either a
  PEM or base64, and assert a valid PEM before using it for SSH.
- image.yml: pin hashicorp/setup-packer@v3 (was @main).
- deploy/README: document XUI_ACME_HTTP_PORT / XUI_SSL_IPV6 / XUI_SERVER_IP.

Both container smoke tests still pass; shellcheck + actionlint clean.
Sanaei 5 tuntia sitten
vanhempi
sitoutus
7c2598fae9

+ 7 - 1
.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
+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

+ 260 - 0
.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"

+ 41 - 0
.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

+ 12 - 0
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.

+ 38 - 0
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).

+ 71 - 0
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
+```

+ 78 - 0
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="[email protected]"
+      # 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."

+ 22 - 0
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

+ 166 - 0
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 "<server-ip>"
+}
+
+# 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 <server-ip> 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

+ 94 - 0
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@<public-ip> '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-<stamp> \
+  --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`.

+ 192 - 0
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 <r>            AWS region (default: $AWS_REGION or eu-central-1)
+#   --blueprint-id <id>     Lightsail blueprint (default: ubuntu_24_04)
+#   --bundle-id <id>        Lightsail bundle/size (default: small_3_0)
+#   --availability-zone <z> AZ (default: <region>a)
+#   --panel-port <p>        Pin the panel port in the snapshot so you can pre-open
+#                           it in the Lightsail firewall (default: random per instance)
+#   --snapshot-name <n>     Snapshot name (default: 3x-ui-ubuntu-24.04-<timestamp>)
+#   --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 "================================================================"

+ 51 - 0
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="[email protected]"
+# ----------------------------------------------------------------------------
+
+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

+ 59 - 0
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."

+ 92 - 0
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: <https://docs.aws.amazon.com/marketplace/latest/userguide/>
+- AMI-based product requirements: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html>
+- Self-service AMI scanning: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-submission.html>

+ 58 - 0
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@<server-ip> '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).

+ 7 - 0
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

+ 116 - 0
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-<arch>.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.

+ 59 - 0
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."

+ 39 - 0
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."

+ 76 - 0
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."

+ 109 - 0
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:<SHA256SUMS url>' 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"
+}

+ 160 - 0
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
+  }
+}

+ 75 - 0
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 =="

+ 77 - 0
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 =="

+ 208 - 46
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 <user> <pass> <port> <webpath> <scheme> <host> <token> <dbtype>
+# 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