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.
This commit is contained in:
Sanaei
2026-06-14 18:08:35 +02:00
committed by GitHub
parent 1c0fdb4527
commit 7c2598fae9
25 changed files with 2167 additions and 47 deletions
+208 -46
View File
@@ -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