* fix(script): correct hardcoded menu option numbers in x-ui.sh
The error messages referenced option 19 for SSL Certificate Management
and option 16 for Logs Management, but the actual positions in show_menu
are 20 and 17 respectively.
* Update x-ui.sh
* fix(script): download the live x-ui.sh script atomically before replacing it
update_menu(), update_shell(), and update.sh's update_x-ui() all overwrote
/usr/bin/x-ui in place via `curl -o`, truncating and rewriting the same
inode a currently-running x-ui process may still be reading from. A
network hiccup or slow write during that overwrite leaves a
half-old/half-new script on disk, which then fails with bogus syntax
errors on the next run. Download to /usr/bin/x-ui-temp and `mv -f` into
place instead, matching the atomic pattern install.sh already uses.
Also fixes update_menu() checking chmod's exit code instead of curl's,
which meant a failed download could still report "Update successful."
* fix(script): close remaining gaps in the atomic script-update path
Code review of the previous commit found the atomic mv fix was itself
incomplete:
- None of the mv -f calls checked their exit status, so a failed move
fell through to chmod and "success" messaging while /usr/bin/x-ui
stayed on the old file.
- update_shell()'s `[[ -s x-ui-temp ]]` guard couldn't tell "curl -z
got a 304, nothing to do" from "a stale temp file survived an
earlier crashed run" -- the latter could get moved into place with
no freshness check.
- update_menu(), update_shell(), and update_x-ui() all hardcoded the
same /usr/bin/x-ui-temp path, so two concurrent updates (e.g. a
cron auto-update racing an interactive menu update) could collide.
- update.sh's update_x-ui() was missing the non-empty-file guard
update_shell() already had.
x-ui.sh's update_menu() and update_shell() now share a
replace_xui_script() helper that uses a PID-suffixed temp path
(/usr/bin/x-ui-temp.$$), pre-cleans it before every attempt, and
checks the exit status of curl, the non-empty test, and mv before
treating the update as successful. update.sh's update_x-ui() gets the
same sequence inlined (it's fetched as a standalone script and can't
call x-ui.sh's function), closing the missing-guard gap and using its
own unique temp path.
* fix(script,panel): harden the remaining self-update download paths
install.sh had the same unguarded /usr/bin/x-ui-temp overwrite the two
already-fixed scripts had: no exit-status check on mv, and a fixed temp
name shared with x-ui.sh/update.sh's (now-unique) temp files. Give it
its own PID-suffixed temp path, an empty-file guard, and an mv
exit-status check, matching the pattern used there.
Audited the web dashboard's Go-native updater (panel.go) for the same
bug class: it already uses os.CreateTemp for a genuinely unique temp
file and cleans up via both a deferred Remove and a shell EXIT trap, so
it was never exposed to the fixed-path race. It was missing a check
for a zero-byte download (a 200 OK with an empty body would chmod +x
and exec an empty script) -- added that alongside the existing size
cap.
Not addressed here: once startUpdate()'s child process starts, the Go
service releases it and returns success immediately. If update.sh
fails partway through, the still-running old panel keeps answering
/status, so the frontend's poll can report success with no update
having happened. Fixing that needs update.sh to signal completion
status back and the frontend to check it -- a separate follow-up.
* feat(panel): report real completion status for the web self-update
Fixes the fire-and-forget gap flagged in the atomic-overwrite fix: once
startUpdate() launches update.sh detached, the Go service had no way to
learn whether it actually succeeded. If update.sh failed partway
(network drop, disk full, permission denied), the still-running old
panel kept answering /status, so the frontend's poll reported success
with nothing having changed.
update.sh now writes its outcome to a small JSON status file
(/etc/x-ui/update-status.json by default) via `trap ... EXIT`, which
covers every exit path in the script -- including the two bare `exit 1`
call sites that don't go through the existing _fail() helper. The Go
service generates a run ID before launching, passes it and the status
path to update.sh via XUI_UPDATE_RUN_ID/XUI_UPDATE_STATUS_FILE, and a
new GET /panel/api/server/getUpdateStatus endpoint reports it back. The
frontend now polls that instead of blindly trusting HTTP reachability,
and shows a distinct error or "couldn't confirm" message instead of
silently reloading into a false success.
Adversarial review of this surfaced three more issues, fixed here:
- No lock stopped two concurrent /updatePanel calls from launching two
update.sh runs that would race each other on the actual update work
(tar extraction, service unit swap). Added an in-memory guard with a
5-minute self-expiring window, so a run that never reaches a terminal
state doesn't lock out retries indefinitely.
- XUI_UPDATE_RUN_ID is read from the environment and was interpolated
unquoted into the status JSON; a malformed value would produce
invalid JSON. Now validated as digits-only before use.
- The run ID is a UnixNano timestamp (19 digits), sent as a raw JSON
number it would lose precision in JavaScript (past
Number.MAX_SAFE_INTEGER), letting two different runs round to the
same value on the wire and defeat the whole comparison. It's now a
decimal string end to end (Go, the status file, and the generated
frontend type).
install.sh's equivalent temp-file/mv path and the Go-native
downloadPanelUpdater() path were audited for the same bug classes
during this work; findings from that audit were addressed separately.
* fix(panel): release the update lock as soon as the run finishes
An exhaustive multi-angle review of the whole branch (12 finder angles,
3-vote adversarial verification, a fresh-eyes sweep) surfaced a real
bug in the concurrency guard added in the previous commit, plus several
smaller issues; this fixes what's actionable now.
The bug: acquireUpdateSlot only ever released on the 5-minute stale
timeout or if launching itself failed. If update.sh launched fine but
failed fast (bad GitHub API response, "x-ui not installed", any of its
early exit paths), the status file correctly reported "failed" within
seconds, but a retry was still rejected with "a panel update is
already in progress" for up to 5 more minutes -- the guard never
looked at the very status file this branch built to know a run was
done. It now tracks which run ID currently holds the slot and checks
that run's own status before falling back to the timeout, so a fast
failure clears the way for an immediate retry. Added a regression test
for this, plus one confirming a stale, unrelated runID can't be
mistaken for the current run finishing.
Also:
- Added a genuinely concurrent test for the guard: 200 goroutines
racing acquireUpdateSlot, asserting exactly one wins. The previous
tests only ever called it from one goroutine, so they gave no signal
if the mutex's check-then-set were silently broken -- verified this
by temporarily removing the lock and confirming the old tests still
passed while the new one caught it immediately under -race.
- Removed the redundant upfront "pending" status write: GetUpdateStatus
already defaults a missing/stale file to pending, and the frontend
matches by run ID regardless, so the write changed no observable
behavior. Deleted writeUpdateStatus entirely since that was its only
caller.
- Renamed replace_xui_script()'s unclear "conditional" parameter to
use_if_modified_since, matching what it actually controls.
- Added HTTP-level tests for the new getUpdateStatus endpoint,
including a regression test that the runId wire format is a JSON
string (decoding into a Go string field fails outright if it were
ever a bare number). updatePanel's actual launch path is not
covered: on a Linux test runner it would make a real network call
and could exec a real update.sh, so only its non-Linux guard path is
safely testable without mocking.
Not fixed here, tracked separately: the same unsafe-overwrite pattern
this branch eliminated for /usr/bin/x-ui is still present for the
systemd unit file install in update.sh and install.sh (lower severity
since systemd only reads it on daemon-reload, not continuously); and
startUpdate's systemd-run-vs-detached-fallback branching has no test
coverage since testing it safely needs dependency injection this fix
doesn't warrant bundling in.
* fix(script): make systemd unit file installation atomic
Same anti-pattern as the /usr/bin/x-ui overwrite fixed earlier: every
site that lands the systemd unit at ${xui_service}/x-ui.service --
copying it from the extracted release tarball, or falling back to a
GitHub download per distro family -- wrote straight onto the live
path via cp/curl, no temp file, no verification. A network drop
mid-download or an interrupted cp leaves the unit file truncated;
systemd then fails to parse it on the next daemon-reload/start,
leaving the panel unable to come up until an operator manually
re-copies a good unit file.
Lower severity than the /usr/bin/x-ui case (systemd only reads this
file on demand at daemon-reload time, not continuously the way bash
interprets a running script line by line), but it's the identical
gap, just left uncovered when that fix landed.
Added a small shared helper in both update.sh and install.sh --
_install_xui_service_unit() -- covering both source types (cp from
the tarball, curl from GitHub): write to a PID-suffixed temp file,
verify the copy/download succeeded and the result is non-empty, then
mv -f into place and check that exit status too, matching the pattern
already used for /usr/bin/x-ui. All 4 cp sites and the 3-way curl
fallback in each file now go through it; verified no other site
writes new content to the unit path (the remaining ${xui_service}
references are a pre-install existence check, an rm during old-version
cleanup, and the chown/chmod that already ran after the file is safely
in place -- none of those need atomicity).
Verified with bash -n on both files, plus a standalone scratch test
exercising cp-success, cp-with-missing-source, cp-with-empty-source,
and curl-failure paths: on every failure the previous, good unit file
content is left untouched and no temp file is leaked behind.
* fix(script): make Alpine's OpenRC init script install atomic; drop a stray comment
A final maximum-rigor review of the whole PR (12 finder angles including
a repo-wide sweep for any remaining instance of the bug class this PR
fixes) found two more real issues:
- Alpine's /etc/init.d/x-ui startup script is downloaded via a bare
`curl -fLRo` straight onto the live path in both update.sh and
install.sh -- the exact same unguarded-overwrite pattern already
fixed for /usr/bin/x-ui and the systemd unit file, just left
uncovered on the OpenRC side. A network drop mid-download truncates
the live init script; OpenRC then fails to source/execute it on the
next start, leaving the panel unable to come up. Fixed with the same
temp-file + non-empty check + mv -f (with its own exit-status check)
pattern used everywhere else in this PR. Verified with bash -n and a
standalone scratch-script test covering success, empty-download, and
destination-preserved-on-failure paths.
- internal/web/service/panel/panel_test.go had one line-level `//`
comment on a call site, which the root CLAUDE.md's hard rule ("No //
line comments in committed Go/TS... rename instead of annotating")
explicitly prohibits. The comment duplicated context already stated
in the test's own doc comment two lines above, so it's simply
removed rather than reworded.
Also flagged, deliberately not bundled here since it's a different
subsystem: x-ui.sh's update_geofiles() downloads Xray's live
geoip.dat/geosite.dat with the same unguarded curl -o pattern. Tracked
as its own follow-up.
* fix(script): make geo-data file downloads atomic
Same anti-pattern as /usr/bin/x-ui, the systemd unit file, and the
Alpine init script fixed in prior PRs: update_geofiles() downloaded
Xray's live geoip.dat/geosite.dat (and the IR/RU variants) with curl
writing straight onto the exact path Xray reads at runtime
(internal/xray/process.go's GetGeoipPath/GetGeositePath), no temp
file, no verification. The existing check only inspected the reported
HTTP status via -w '%{http_code}', not file integrity, so a network
drop mid-download could leave a truncated .dat file on disk that
passes the status check. Xray then fails to parse it on the next
restart/reload, breaking any routing rules that reference geoip:/
geosite:.
The -z conditional-GET usage needed care here: the original code
pointed both -z and -o at the same live path. Fixed by pointing -z at
the live file (to keep the "already current" freshness check) while
-o writes to a PID-suffixed temp file, matching the pattern already
proven in x-ui.sh's replace_xui_script(). Verified with a local HTTP
server that a 304 response leaves the temp file untouched/nonexistent
(so the existing "already up to date" branch still works unchanged),
and added a non-empty check plus a checked mv -f before treating a
download as installed.
Verified with bash -n and an end-to-end scratch test against a local
server covering: fresh download, 304-not-modified, empty response
body, and a 404 -- confirming a failure at any stage leaves the
previous good .dat file completely untouched and no temp file behind.
* fix(script): verify the release tarball extraction, not just the download
The final maximum-rigor review found the most significant remaining gap
in this whole effort: update.sh and install.sh check the tarball
download's exit status, but never check tar's exit status, and never
verify the extracted x-ui binary actually exists before continuing.
Worse, by the time extraction runs, the previous installation has
already been stopped and deleted -- there's no rollback. A truncated
download that still passes curl's own check, or a tar failure (disk
full, killed process), left the panel silently in a broken half-state:
chmod/config/service-install all continued to run against a missing or
empty binary, with no error surfaced anywhere. This is the same bug
class as everything else in this PR (unverified write to a path
something then depends on), just for the tarball itself rather than a
single file -- and it also covers the geo-data files this PR already
fixed once for the interactive/cron path, since they ship inside this
same tarball on every panel update.
Added: a non-empty check on the downloaded archive (both files, both
install.sh call sites) and a check that tar succeeded and produced a
non-empty x-ui binary before proceeding, failing loudly with a message
that explicitly says the previous install is already gone, since
silently continuing here is worse than anywhere else in this PR.
This doesn't make the multi-file extraction fully atomic (that would
mean extracting to a temp directory and atomically swapping the whole
install tree into place, a materially larger restructuring than
anything else in this PR) -- but it closes the "fails silently, user
discovers it days later when Xray can't start" gap, which was the
actual reported problem this whole effort traces back to.
Also fixed, all much smaller:
- replace_xui_script() in x-ui.sh implicitly returned chmod's exit
status instead of success, so a successful atomic install could be
reported as failed if chmod transiently failed after the mv already
landed the new script. Added an explicit `return 0`.
- update_geofiles() had no default case branch; an unrecognized
argument would silently reuse whatever dat_files/dat_source values a
previous call left in the un-scoped globals instead of failing.
Currently unreachable (all three call sites pass fixed literals) but
cheap, defensive, and worth having.
- internal/web/controller/server.go's updatePanel has one branch (an
unparseable "dev" form value) that's both untested and safe to test
on any platform, since it's rejected before any real exec/network
call. Added the missing test case.
Verified: bash -n on all three scripts; an empirical scratch test
covering an empty downloaded archive, a corrupt (non-gzip) archive,
and a successfully-extracting-but-empty archive, confirming each is
caught before the script proceeds; full go build/vet/test -race
across the whole module; frontend generation confirmed still in sync.
* fix(panel): base the update-slot staleness fallback on process liveness
Addresses the automated review on the upstream PR (MHSanaei/3x-ui#5711).
Blocking finding: acquireUpdateSlot's staleness fallback freed the
update slot purely on elapsed wall-clock time (5 minutes), with no
check on whether the update.sh process it launched was actually still
running. update.sh runs install_base() (apt-get/dnf/pacman update and
install) before update_x-ui even starts, plus several GitHub
downloads (release tarball, x-ui.sh, and possibly a service unit or
x-ui.rc) -- on a slow or throttled host, a small VPS being the typical
deployment target for this project, that alone can plausibly exceed 5
minutes with nothing wrong. A second /updatePanel call arriving in
that window (an admin retrying after the frontend's 90s poll times
out, or overlapping master-node bulk-update calls) would launch a
second update.sh, racing the exact rm/tar/mv/systemctl sequence this
whole PR exists to make safe.
Fixed by recording the launched process's PID (detached-fallback path
only; the systemd-run path's own process has already exited by the
time startUpdate returns, so it never learns update.sh's real PID) and
checking it via the standard POSIX kill(pid, 0) liveness probe before
treating a run as stale, following the existing panel_unix.go /
panel_other.go platform-split pattern already used for
setDetachedProcess. A confirmed-alive process now keeps the slot held
past updateStaleAfter (raised from 5 to 20 minutes as a safer baseline
for the systemd-run path, which still has no way to check liveness
directly). updateHardCeiling (2 hours) is an absolute backstop so a
genuinely wedged run can never lock out retries permanently even on
the PID-tracked path.
Added two regression tests exercising the new logic (gated to Linux,
since processAlive is a no-op stub elsewhere): a live PID keeps the
slot held past the stale window, and the hard ceiling overrides
liveness. Traced both by hand against the new acquireUpdateSlot logic;
could not execute-verify processAlive itself on this Windows dev
machine (no WSL distro installed, and installing one felt
disproportionate to validate kill(pid, 0), an extremely well-established
POSIX primitive), but cross-compiled clean for linux/amd64 and this
repo's CI runs the real test suite on Linux.
Also fixed, both suggestions from the same review:
- install.sh: two failure paths right after tarball extraction were
exiting without cleaning up the already-downloaded x-ui.sh temp file
(xui_script_temp), leaving it behind. Every other new failure branch
in this PR removes its temp file before exiting; these two now do
too.
- frontend/src/pages/api-docs/endpoints.ts: updatePanel's doc entry
did not reflect that a successful response now carries an obj with
runId. Added an inline response example matching the existing
pattern used for other ad hoc (non-schema-backed) responses like
getWebCertFiles.
Verified: go build/vet clean on both windows (native) and a linux/amd64
cross-compile; full go test ./... clean; go test -race on the panel
and controller packages; bash -n on all three shell scripts; npm run
gen confirms the openapi.json diff is exactly the new response example
with no stray changes to src/generated; TestAPIRoutesDocumented still
passes.
acme.sh guards every non-install command behind _checkSudo: when a
non-root user runs the panel scripts via sudo, it prints the sudo wiki
warning and exits before doing anything, unless FORCE is set. All our
--issue calls already pass --force and were unaffected, but none of the
--installcert calls did, so issuance succeeded and installation then
aborted silently, ending in "Certificate files not found after
installation". FORCE has no other effect on the installcert path, so
mirror the --issue calls and pass --force everywhere we install certs.
Closes#5741
IP limit enforcement is gated on fail2ban being present (ce8b1bed), but the bare-metal install.sh/update.sh never installed it, so the feature stayed disabled until the user ran the IP Limit menu by hand. Docker already auto-configures it; bare-metal hosts did not.
Extract the fail2ban install + jail setup out of install_iplimit into a non-interactive setup_fail2ban_iplimit() (no exit/before_show_menu, returns a status) exposed via 'x-ui setup-fail2ban', and call it from install.sh and update.sh after the panel is up. update.sh is the primary update path (x-ui update and the panel self-updater both run it). Honors XUI_ENABLE_FAIL2BAN (proceed only when unset or true, matching the Go gate) and is non-fatal so a fail2ban failure never aborts the install/update.
The generated 3x-ipl fail2ban action only matched -p tcp, so UDP-based
inbounds (Hysteria2, TUIC, WireGuard) from a banned IP kept working,
bypassing IP-limit enforcement. Drop the protocol qualifier from the
chain jump and ban both tcp and udp, keeping the SSH/panel port exemption.
Updating geo files printed raw curl progress meters showing 0 bytes when
files were already current (curl -z conditional download), claimed success
unconditionally, and restarted xray even when nothing was downloaded —
confusing enough to be reported as a bug (#5230).
Now each file reports updated / already up to date / download failed,
failures no longer print a success message, and the restart (which drops
live connections) only happens when a file actually changed. Same for the
non-interactive 'x-ui update-all-geofiles' command.
- Issue acme.sh HTTP-01 over IPv4 unless the host has no global IPv4
address: the hardcoded --listen-v6 started a v6-only standalone
listener, so validation of a domain whose A record points at this
host always failed (#4994).
- Add a custom cert/key path option to the "Set Cert paths" menu so
certificates living outside /root/cert (e.g. certbot under
/etc/letsencrypt) can be wired to the panel from the CLI (#5010).
- Derive the displayed Access URL from the certificate's actual SAN
list instead of the cert folder name, list the other covered names,
and show the panel's custom-path certificate in "Show Existing
Domains" (#5070). Also silence find when /root/cert doesn't exist.
sysctl --system re-applies every sysctl file on the host, surfacing
unrelated "Invalid argument" errors from the distro's own defaults
(e.g. Ubuntu 22.04's 50-default.conf on kernels 5.14+). Apply only
/etc/sysctl.d/99-bbr-x-ui.conf on enable, and drop the redundant
re-apply on disable since sysctl -w already restores the live values.
Expose mtg's [domain-fronting] section (ip/port/proxy-protocol) plus
proxy-protocol-listener, prefer-ip, and debug on MTProto inbounds. Each
key is written to the generated mtg-<id>.toml only when set, so mtg's own
defaults apply otherwise. The instance fingerprint now covers these
fields, so editing an option restarts the sidecar.
Since MTProto is mtg-served (not Xray), sniffing does not apply: hide the
Sniffing tab and the Advanced sniffing sub-editor, drop it from the
Advanced "All" JSON view, and emit empty sniffing in the wire payload,
all gated by a new canEnableSniffing predicate.
* fix(logs): render journalctl output in the SysLog viewer
The log viewer's parseLogLine only understood the app-log format
(2006/01/02 15:04:05 LEVEL - body). With SysLog ticked the backend
returns journalctl lines (Mon DD HH:MM:SS host ident[pid]: LEVEL - body),
so the parser mistook the journal time for the level and dropped the
body, leaving only timestamps. Detect and strip the journald prefix,
keep the journal timestamp as the stamp, then parse the real level and
body from the remainder.
* feat(mtproto): surface mtg output and add status reporting
mtg's stdout/stderr was captured by a writer that kept only the last
line and showed it nowhere, so the reason a proxy could not reach
Telegram was invisible. Stream mtg output line-by-line into the x-ui
log, tagged per inbound, so it appears in the panel log viewer and
journald.
Also fix mangled log lines: logger.Info uses fmt.Sprint, which drops
the space between adjacent string operands, producing output like
'inbound3on0.0.0.0:8443'. Switch the affected mtproto calls to the
formatted (*f) variants.
Add show_mtproto_status to x-ui.sh so 'x-ui status' reports each
mtproto inbound's mtg process state and bind address.
* fix(logs): parse all journalctl message shapes in SysLog viewer
Real journalctl output mixes four message shapes after the
'Mon DD HH:MM:SS host ident[pid]:' prefix: go-logging 'LEVEL - msg'
(x-ui/xray), Go std-log with an embedded date (net/http, runtime),
telego's '[timestamp] LEVEL msg', and systemd lines. The viewer only
understood the first, so std-log and telego lines — which never contain
' - ' — collapsed to a bare timestamp (e.g. the 8s telego 409 spam).
Extract the parser into a pure, testable module and teach it the other
shapes: strip the redundant Go std-log date, lift the level out of
telego brackets, and always keep the message body. Add a unit test
covering each shape with real captured lines.
* fix(mtproto): reap orphaned mtg sidecars so a stale one can't break new clients
On Linux x-ui does not kill its mtg children when it dies (no kill-on-exit,
unlike the Windows job object). After a crash, OOM, kill -9, or update, a
stale mtg keeps holding the inbound port with an OLD secret, so new clients
fail the FakeTLS handshake and get silently domain-fronted to the fakeTLS
domain instead of proxied to Telegram (a few MB of traffic, never connects).
Sweep orphans at startup: on the first reconcile, before x-ui starts any of
its own mtg, scan /proc and SIGKILL any process whose executable is our
mtg-<goos>-<goarch> binary. x-ui is the sole owner of mtg, so anything alive
then is an orphan. Runs once per process (swept guard), survives the
binary-deleted-during-update case via /proc/<pid>/cmdline, and is a no-op on
Windows (job object) and other platforms.
Also clear stray mtg in update.sh/install.sh after stopping x-ui, anchored to
the 'mtg-linux-<arch> run ' invocation so the pattern can't match unrelated
command lines (e.g. x-ui.sh's own 'grep mtg-linux').
* fix(logs): drop dead body initializer flagged by eslint no-useless-assignment
* fix(mtproto): drop remark fragment from tg://proxy export link
The mtproto export link appended the inbound remark as a URL fragment
(tg://proxy?server=...&port=...&secret=...#remark). Telegram Desktop
rejects a proxy deep link with a trailing fragment as 'This proxy link
is invalid', breaking one-click import, and a remark is meaningless for
proxy links across clients. Stop adding it in both the panel link
(genMtprotoLink) and the subscription service. Fixes#5105.
* fix(x-ui.sh): remove unused check_mtproto_status helper
show_mtproto_status does its own process check, so check_mtproto_status
was dead code. Drop it (per Copilot review on #5107).
The SSL Certificate Management "Revoke" option only called acme.sh --revoke,
leaving local files under /root/cert/<domain>, acme.sh renewal state under
~/.acme.sh/<domain>, and the panel cert paths in the DB. Renamed to
"Revoke & Remove": it now also drops acme.sh tracking, deletes the local cert
directory and acme.sh state dirs (RSA + ECC), resolves the real IP address(es)
for IP certs so their renewal state is torn down too, and clears the panel cert
paths (x-ui cert -reset) + restarts when the deleted domain was in use.
* feat(x-ui.sh): add migrateDB command and menu for SQLite .db <-> .dump
Adds an "x-ui migrateDB <file>" subcommand and a PostgreSQL-menu option (9)
that convert between a SQLite .db and a portable .dump file. Direction is
auto-detected from the extension and delegated to the bundled binary
(x-ui migrate-db --dump/--restore), so no external sqlite3 client is needed.
Depends on the matching binary support, so it is only usable from the next
panel release.
* fix(x-ui.sh): address review feedback on migrateDB
Per Copilot review on PR #4910:
- Probe the bundled binary for migrate-db --dump support and fail with a clear
upgrade message instead of a raw "flag not defined" error on old builds.
- Prompt before overwriting an existing .dump in dump mode (parity with restore).
- Refuse to restore into the live database path while x-ui is running, to avoid
corrupting the running panel.
- Fix the usage/synopsis strings to show input is optional ([file] not <file>).
- Cleanup on issuance/install failure now also removes the acme.sh
${domain}_ecc (and ${ip}_ecc) directory, not just ${domain}, so a
failed run no longer leaves partial state behind.
- The 'existing certificate' check only reuses a cert when its
fullchain.cer and key files are actually present and non-empty;
otherwise the broken state is removed and issuance proceeds. This
fixes the 0-byte fullchain.pem produced by reusing failed state.
- Menu option 5 (set cert paths) now registers the acme.sh --installcert
hook with --reloadcmd 'x-ui restart' when acme.sh knows the domain, so
auto-renewal copies the renewed cert and reloads the panel.
The 3x-ipl action used iptables-allports, so a banned IP lost all TCP
access including SSH and the panel, locking admins out (especially with
dynamic-IP clients). The ban now blocks every TCP port except the SSH
and panel ports via a multiport negation, derived at jail-creation time
in both x-ui.sh and DockerEntrypoint.sh. This keeps IP-limit working for
all current and future inbounds without per-port config.
Binary: extend the migrate-db subcommand with --dump and --restore so a
SQLite database can be exported to a portable SQL text dump and rebuilt from
one, alongside the existing --dsn PostgreSQL copy. Implemented in Go via the
bundled sqlite driver (new database/dump_sqlite.go); no external sqlite3 client
is required. Add ExportPostgresToSQLite (reverse of MigrateData) to build a
SQLite .db from live PostgreSQL data, reusing the shared copyAllModels helper.
Overview: add a "Download Migration" item to Backup & Restore plus a
getMigration endpoint/service that returns a .dump on SQLite or a .db on
PostgreSQL, so the data can seed a panel on the other backend. Document the
endpoint in api-docs and translate the three new strings across all locales.
Tests: cover the destination-side copy (AutoMigrate + copyTable into SQLite)
and the dump/restore round-trip including quoted values. Ignore *.dump.
The x-ui.sh helper that drives this from the CLI is in PR #4910.
check_status() only recognized a systemd service or Alpine's
/etc/init.d/x-ui, neither of which exists in a container where the panel
runs as the foreground main process (PID 1 via "exec /app/x-ui"). Every
CLI command therefore failed with "Please install the panel first", and
restart/restart-xray relied on rc-service/systemctl that aren't present.
Detect the container (/.dockerenv or XUI_IN_DOCKER) and, when inside one:
- resolve the panel binary under /app instead of /usr/local/x-ui
- derive status from the running process instead of a service file
- restart via SIGHUP and restart-xray via SIGUSR1 to the panel process
- show Docker-appropriate guidance for start/stop/enable/disable
The Dockerfile sets XUI_IN_DOCKER/XUI_MAIN_FOLDER so detection is
explicit even though /.dockerenv alone suffices.
Closes#4817
Menu 20 only exported CF_Key/CF_Email, so a restricted Cloudflare API Token was misread as a Global Key and acme.sh failed with 'invalid domain'. Add a token-or-global-key prompt (default token): an API Token exports CF_Token, the Global Key keeps the previous CF_Key + CF_Email behavior. Also stop echoing the key/token value to the debug log.
Go's flag package parses '-resetTwoFactor false' as '-resetTwoFactor=true' with a dangling positional 'false', so two-factor auth was always wiped on username/password reset regardless of the prompt answer. Omit the flag in the preserve branch (default is false) and use '-resetTwoFactor=true' in the disable branch.
Add a self-contained 'PostgreSQL Management' submenu (main-menu option 27) so the panel can be set up and migrated without re-running the remote install script:
- Install PostgreSQL locally (server + client tools + dedicated xui user/db), ported from install.sh so x-ui.sh stays standalone
- Migrate SQLite to PostgreSQL via 'x-ui migrate-db', then write XUI_DB_TYPE/XUI_DB_DSN to the service env file and restart the panel; client tools are ensured first so in-panel backup/restore works for local and external databases
- Service control: status (clusters + port 5432), start, stop, restart, enable autostart, view log, with auto-detected cluster version
The IP certificate flow auto-set the panel cert path silently, unlike the
domain and Cloudflare flows which ask first. Add the same
"Would you like to set this certificate for the panel? (y/n)" prompt so
the IP flow is consistent and only configures the panel on confirmation.
* feat(install): prompt for SQLite vs PostgreSQL during install
* fix(install): write env file to per-distro path and handle pg-install failure
The env file was hardcoded to /etc/default/x-ui, but RHEL/Fedora units read
/etc/sysconfig/x-ui, Arch reads /etc/conf.d/x-ui, and Alpine OpenRC auto-
sources /etc/conf.d/x-ui. PostgreSQL selection was silently dropped on every
distro except Debian. Also initdb on openSUSE (service wouldn't start) and
prompt the operator on local-install failure instead of silently demoting
to SQLite.
* fix(scripts): make x-ui.sh and update.sh PostgreSQL-aware
update.sh ran setting -show and migrate without sourcing the env file, so
PostgreSQL users had migrations applied to the SQLite default and settings
introspection read the wrong DB. Sourcing the per-distro env file at the
start of update_x-ui exports XUI_DB_TYPE/XUI_DB_DSN to all binary calls.
x-ui.sh now shows the active backend in View Current Settings (password
masked) and removes the env file on uninstall so a later reinstall doesn't
inherit a stale DSN.
`14. Restart Xray` failed on Alpine with `systemctl: command not found` —
restart_xray was the only service action missing an Alpine branch. While
fixing it, the OpenRC reload action was passing the pidfile path to `kill`
instead of the PID inside it, so `rc-service x-ui reload` would have
failed too.
Fail2ban parses % as variable interpolation in action.d configs, so the
unescaped %Y/%m/%d %H:%M:%S in the date command crashed fail2ban on
startup. Double the %s in the heredoc so the rendered action file
contains %% and fail2ban collapses it back to a literal % when invoking
the shell command.
Try six IPv4 providers in turn, accept only HTTP 200 + IPv4-shaped body,
and prompt the user to enter their IP if every provider fails.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- DockerEntrypoint.sh: create jail.d/filter.d/action.d config files
before starting fail2ban so Docker containers no longer start with
0 active jails (fixes#4134)
- x-ui.sh create_iplimit_jails: lower maxretry from 2 to 1 so
fail2ban bans on the first log entry; with maxretry=2 and the
partitionLiveIps logic the second occurrence could arrive after the
32 s findtime window, silently preventing any ban (fixes#4163)
- x-ui.sh: fix datepattern (%%Y -> %Y) so fail2ban parses the Go
log timestamp correctly instead of looking for a literal %%Y string
- x-ui.sh / DockerEntrypoint.sh: fix date command in actionban /
actionunban echo (%%Y -> %Y) so the ban log records actual dates
- check_client_ip_job.go: replace log.SetOutput / log.SetFlags on
the global standard-library logger with a local log.New instance,
eliminating the dangling closed-file-handle between calls and
stopping unrelated stdlib log output from polluting 3xipl.log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On fresh Debian 12+, Ubuntu 24+ and recent RHEL-family minimal images
the fail2ban package ships with `banaction = nftables-multiport` as
the default in /etc/fail2ban/jail.conf but does not pull in the
`nftables` package as a dependency. The first SSH brute-force attempt
hits the default sshd jail and fail2ban logs
stderr: /bin/sh: 1: nft: not found
returned 127 -- HINT on 127: "Command not found"
repeatedly, which users mistake for a 3x-ui regression (see the
discussion on #4083). The 3x-ipl jail itself is unaffected — it uses
an iptables-based action configured in create_iplimit_jails — so this
is only stray noise, but noisy enough to look like a real failure on
first install.
Add `nftables` to the package list in every branch of install_iplimit
so new installs end up with a working default sshd jail out of the
box. Existing installs where `nftables` is already present are a
no-op.
it should be 'default' runlevel when add x-ui service to openrc, default is 'sysinit' runlevel. 'sysinit' runlevel is unnecessary,maybe.
if not, there is an error when call to function 'check_enabled()' as command 'grep default -c' can`t print 'default' runlevel.
check_enabled() {
if [[ $release == "alpine" ]]; then
if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
return 0
else
return 1
fi
Replace simple curl+trim checks with a response+http_code parse to ensure the remote URL returns HTTP 200 and a non-empty body before assigning server_ip. Changes applied to install.sh, update.sh and x-ui.sh: use curl -w to append the status code, extract http_code and ip_result, and only set server_ip when http_code == 200 and ip_result is non-empty. This makes the IP discovery more robust against error pages or partial responses while keeping the existing timeout behavior.
Introduces user prompts to select the port for ACME HTTP-01 certificate validation (default 80), checks if the chosen port is available, and provides guidance for port forwarding. Adds is_port_in_use helper to all scripts and improves messaging for certificate issuance and error handling.