mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-04 03:44:22 +00:00
9e13b32c34
* 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.
3455 lines
124 KiB
Bash
3455 lines
124 KiB
Bash
#!/bin/bash
|
|
|
|
red='\033[0;31m'
|
|
green='\033[0;32m'
|
|
blue='\033[0;34m'
|
|
yellow='\033[0;33m'
|
|
plain='\033[0m'
|
|
|
|
#Add some basic function here
|
|
function LOGD() {
|
|
echo -e "${yellow}[DEG] $* ${plain}"
|
|
}
|
|
|
|
function LOGE() {
|
|
echo -e "${red}[ERR] $* ${plain}"
|
|
}
|
|
|
|
function LOGI() {
|
|
echo -e "${green}[INF] $* ${plain}"
|
|
}
|
|
|
|
# Port helpers: detect listener and owning process (best effort)
|
|
is_port_in_use() {
|
|
local port="$1"
|
|
if command -v ss > /dev/null 2>&1; then
|
|
ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
|
|
return
|
|
fi
|
|
if command -v netstat > /dev/null 2>&1; then
|
|
netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
|
|
return
|
|
fi
|
|
if command -v lsof > /dev/null 2>&1; then
|
|
lsof -nP -iTCP:${port} -sTCP:LISTEN > /dev/null 2>&1 && return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Simple helpers for domain/IP validation
|
|
is_ipv4() {
|
|
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1
|
|
}
|
|
is_ipv6() {
|
|
[[ "$1" =~ : ]] && return 0 || return 1
|
|
}
|
|
is_ip() {
|
|
is_ipv4 "$1" || is_ipv6 "$1"
|
|
}
|
|
is_domain() {
|
|
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
|
|
}
|
|
|
|
# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
|
|
# v6-only, which breaks HTTP-01 validation when the domain's A record points
|
|
# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
|
|
# IPv4 address at all.
|
|
acme_listen_flag() {
|
|
if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
|
|
echo ""
|
|
else
|
|
echo "--listen-v6"
|
|
fi
|
|
}
|
|
|
|
# check root
|
|
[[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1
|
|
|
|
# Check OS and set release variable
|
|
if [[ -f /etc/os-release ]]; then
|
|
source /etc/os-release
|
|
release=$ID
|
|
elif [[ -f /usr/lib/os-release ]]; then
|
|
source /usr/lib/os-release
|
|
release=$ID
|
|
else
|
|
echo "Failed to check the system OS, please contact the author!" >&2
|
|
exit 1
|
|
fi
|
|
echo "The OS release is: $release"
|
|
|
|
os_version=""
|
|
os_version=$(grep "^VERSION_ID" /etc/os-release | cut -d '=' -f2 | tr -d '"' | tr -d '.')
|
|
|
|
running_in_docker="false"
|
|
if [[ -f /.dockerenv ]] || [[ "${XUI_IN_DOCKER}" == "true" ]]; then
|
|
running_in_docker="true"
|
|
fi
|
|
|
|
# Declare Variables
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
xui_folder="${XUI_MAIN_FOLDER:=/app}"
|
|
else
|
|
xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
|
|
fi
|
|
xui_service="${XUI_SERVICE:=/etc/systemd/system}"
|
|
log_folder="${XUI_LOG_FOLDER:=/var/log/x-ui}"
|
|
mkdir -p "${log_folder}"
|
|
iplimit_log_path="${log_folder}/3xipl.log"
|
|
iplimit_banned_log_path="${log_folder}/3xipl-banned.log"
|
|
|
|
confirm() {
|
|
if [[ $# > 1 ]]; then
|
|
echo && read -rp "$1 [Default $2]: " temp
|
|
if [[ "${temp}" == "" ]]; then
|
|
temp=$2
|
|
fi
|
|
else
|
|
read -rp "$1 [y/n]: " temp
|
|
fi
|
|
if [[ "${temp}" == "y" || "${temp}" == "Y" ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
confirm_restart() {
|
|
confirm "Restart the panel, Attention: Restarting the panel will also restart xray" "y"
|
|
if [[ $? == 0 ]]; then
|
|
restart
|
|
else
|
|
show_menu
|
|
fi
|
|
}
|
|
|
|
before_show_menu() {
|
|
echo && echo -n -e "${yellow}Press enter to return to the main menu: ${plain}" && read -r temp
|
|
show_menu
|
|
}
|
|
|
|
install() {
|
|
bash <(curl -Ls https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh)
|
|
if [[ $? == 0 ]]; then
|
|
if [[ $# == 0 ]]; then
|
|
start
|
|
else
|
|
start 0
|
|
fi
|
|
fi
|
|
}
|
|
|
|
update() {
|
|
confirm "This function will update all x-ui components to the latest version, and the data will not be lost. Do you want to continue?" "y"
|
|
if [[ $? != 0 ]]; then
|
|
LOGE "Cancelled"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
bash <(curl -Ls https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh)
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "Update is complete, Panel has automatically restarted "
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
update_dev() {
|
|
confirm "This will update x-ui to the latest DEV commit (the rolling 'dev-latest' build, not a stable release). Your data is preserved. Continue?" "y"
|
|
if [[ $? != 0 ]]; then
|
|
LOGE "Cancelled"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
# XUI_UPDATE_TAG tells update.sh to install the dev-latest pre-release
|
|
# instead of the latest stable tag.
|
|
XUI_UPDATE_TAG="dev-latest" bash <(curl -Ls https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh)
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "Dev update is complete, Panel has automatically restarted "
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
replace_xui_script() {
|
|
local url="$1"
|
|
local use_if_modified_since="$2"
|
|
local temp_file="/usr/bin/x-ui-temp.$$"
|
|
|
|
rm -f "$temp_file"
|
|
if [[ "$use_if_modified_since" == "true" ]]; then
|
|
curl -fLRo "$temp_file" -z /usr/bin/x-ui "$url"
|
|
else
|
|
curl -fLRo "$temp_file" "$url"
|
|
fi
|
|
if [[ $? != 0 ]]; then
|
|
rm -f "$temp_file"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -s "$temp_file" ]]; then
|
|
rm -f "$temp_file"
|
|
# -z above means "not modified since /usr/bin/x-ui" rather than a
|
|
# real failure, so an empty download here is success, not an error.
|
|
[[ "$use_if_modified_since" == "true" ]] && return 0
|
|
return 1
|
|
fi
|
|
|
|
mv -f "$temp_file" /usr/bin/x-ui
|
|
if [[ $? != 0 ]]; then
|
|
rm -f "$temp_file"
|
|
return 1
|
|
fi
|
|
# The move already landed the new script; a transient chmod failure here
|
|
# shouldn't make callers think the whole replace failed.
|
|
chmod +x /usr/bin/x-ui
|
|
return 0
|
|
}
|
|
|
|
update_menu() {
|
|
echo -e "${yellow}Updating Menu${plain}"
|
|
confirm "This function will update the menu to the latest changes." "y"
|
|
if [[ $? != 0 ]]; then
|
|
LOGE "Cancelled"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
if replace_xui_script "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh" "false"; then
|
|
chmod +x ${xui_folder}/x-ui.sh
|
|
echo -e "${green}Update successful. The panel has automatically restarted.${plain}"
|
|
exit 0
|
|
else
|
|
echo -e "${red}Failed to update the menu.${plain}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
legacy_version() {
|
|
echo -n "Enter the panel version (like 2.4.0):"
|
|
read -r tag_version
|
|
|
|
if [ -z "$tag_version" ]; then
|
|
echo "Panel version cannot be empty. Exiting."
|
|
exit 1
|
|
fi
|
|
# Use the entered panel version in the download link
|
|
install_command="bash <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/v$tag_version/install.sh") v$tag_version"
|
|
|
|
echo "Downloading and installing panel version $tag_version..."
|
|
eval $install_command
|
|
}
|
|
|
|
# Function to handle the deletion of the script file
|
|
delete_script() {
|
|
rm "$0" # Remove the script file itself
|
|
exit 1
|
|
}
|
|
|
|
xui_env_file_path() {
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
echo "/etc/default/x-ui"
|
|
;;
|
|
arch | manjaro | parch | alpine)
|
|
echo "/etc/conf.d/x-ui"
|
|
;;
|
|
*)
|
|
echo "/etc/sysconfig/x-ui"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
uninstall() {
|
|
confirm "Are you sure you want to uninstall the panel? xray will also uninstalled!" "n"
|
|
if [[ $? != 0 ]]; then
|
|
if [[ $# == 0 ]]; then
|
|
show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service x-ui stop
|
|
rc-update del x-ui
|
|
rm /etc/init.d/x-ui -f
|
|
else
|
|
systemctl stop x-ui
|
|
systemctl disable x-ui
|
|
rm ${xui_service}/x-ui.service -f
|
|
systemctl daemon-reload
|
|
systemctl reset-failed
|
|
fi
|
|
|
|
local panel_used_postgres="false"
|
|
local db_env_file
|
|
db_env_file="$(xui_env_file_path)"
|
|
if [[ -r "$db_env_file" ]] && grep -q '^XUI_DB_TYPE=postgres' "$db_env_file"; then
|
|
panel_used_postgres="true"
|
|
fi
|
|
|
|
rm /etc/x-ui/ -rf
|
|
rm ${xui_folder}/ -rf
|
|
rm -f "$db_env_file"
|
|
|
|
if [[ "$panel_used_postgres" == "true" ]] && postgresql_installed; then
|
|
purge_postgresql
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "Uninstalled Successfully.\n"
|
|
echo "If you need to install this panel again, you can use below command:"
|
|
echo -e "${green}bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)${plain}"
|
|
echo ""
|
|
# Trap the SIGTERM signal
|
|
trap delete_script SIGTERM
|
|
delete_script
|
|
}
|
|
|
|
reset_user() {
|
|
confirm "Are you sure to reset the username and password of the panel?" "n"
|
|
if [[ $? != 0 ]]; then
|
|
if [[ $# == 0 ]]; then
|
|
show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
read -rp "Please set the login username [default is a random username]: " config_account
|
|
[[ -z $config_account ]] && config_account=$(gen_random_string 10)
|
|
read -rp "Please set the login password [default is a random password]: " config_password
|
|
[[ -z $config_password ]] && config_password=$(gen_random_string 18)
|
|
|
|
read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm
|
|
if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then
|
|
${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" > /dev/null 2>&1
|
|
else
|
|
${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor=true > /dev/null 2>&1
|
|
echo -e "Two factor authentication has been disabled."
|
|
fi
|
|
|
|
echo -e "Panel login username has been reset to: ${green} ${config_account} ${plain}"
|
|
echo -e "Panel login password has been reset to: ${green} ${config_password} ${plain}"
|
|
echo -e "${green} Please use the new login username and password to access the X-UI panel. Also remember them! ${plain}"
|
|
confirm_restart
|
|
}
|
|
|
|
gen_random_string() {
|
|
local length="$1"
|
|
openssl rand -base64 $((length * 2)) \
|
|
| tr -dc 'a-zA-Z0-9' \
|
|
| head -c "$length"
|
|
}
|
|
|
|
reset_webbasepath() {
|
|
echo -e "${yellow}Resetting Web Base Path${plain}"
|
|
|
|
read -rp "Are you sure you want to reset the web base path? (y/n): " confirm
|
|
if [[ $confirm != "y" && $confirm != "Y" ]]; then
|
|
echo -e "${yellow}Operation canceled.${plain}"
|
|
return
|
|
fi
|
|
|
|
config_webBasePath=$(gen_random_string 18)
|
|
|
|
# Apply the new web base path setting
|
|
${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" > /dev/null 2>&1
|
|
|
|
echo -e "Web base path has been reset to: ${green}${config_webBasePath}${plain}"
|
|
echo -e "${green}Please use the new web base path to access the panel.${plain}"
|
|
restart
|
|
}
|
|
|
|
reset_config() {
|
|
confirm "Are you sure you want to reset all panel settings, Account data will not be lost, Username and password will not change" "n"
|
|
if [[ $? != 0 ]]; then
|
|
if [[ $# == 0 ]]; then
|
|
show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
${xui_folder}/x-ui setting -reset
|
|
echo -e "All panel settings have been reset to default."
|
|
restart
|
|
}
|
|
|
|
check_config() {
|
|
local info=$(${xui_folder}/x-ui setting -show true)
|
|
if [[ $? != 0 ]]; then
|
|
LOGE "get current settings error, please check logs"
|
|
show_menu
|
|
return
|
|
fi
|
|
LOGI "${info}"
|
|
|
|
local db_env_file
|
|
db_env_file="$(xui_env_file_path)"
|
|
if [[ -r "$db_env_file" ]] && grep -q '^XUI_DB_TYPE=postgres' "$db_env_file"; then
|
|
local dsn
|
|
dsn="$(grep -E '^XUI_DB_DSN=' "$db_env_file" | head -1 | cut -d= -f2-)"
|
|
local dsn_safe
|
|
dsn_safe="$(echo "$dsn" | sed -E 's|(://[^:/@]+:)[^@]+@|\1****@|')"
|
|
echo -e "${green}Database: PostgreSQL — ${dsn_safe}${plain}"
|
|
else
|
|
echo -e "${green}Database: SQLite (/etc/x-ui/x-ui.db)${plain}"
|
|
fi
|
|
|
|
local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
|
local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
|
|
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
|
local URL_lists=(
|
|
"https://api4.ipify.org"
|
|
"https://ipv4.icanhazip.com"
|
|
"https://v4.api.ipinfo.io/ip"
|
|
"https://ipv4.myexternalip.com/raw"
|
|
"https://4.ident.me"
|
|
"https://check-host.net/ip"
|
|
)
|
|
local server_ip=""
|
|
for ip_address in "${URL_lists[@]}"; do
|
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
|
local http_code=$(echo "$response" | tail -n1)
|
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
|
|
if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
server_ip="${ip_result}"
|
|
break
|
|
fi
|
|
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
|
|
fi
|
|
|
|
if [[ -n "$existing_cert" ]]; then
|
|
local domain=$(basename "$(dirname "$existing_cert")")
|
|
# The cert folder name is only the certificate's first domain. A
|
|
# multidomain (SAN) certificate may be served under any name it covers,
|
|
# so read the real names from the certificate itself (#5070).
|
|
local cert_sans=""
|
|
if [[ -f "$existing_cert" ]] && command -v openssl > /dev/null 2>&1; then
|
|
cert_sans=$(openssl x509 -in "$existing_cert" -noout -ext subjectAltName 2> /dev/null \
|
|
| grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2)
|
|
if [[ -n "$cert_sans" ]] && ! echo "$cert_sans" | grep -qx "$domain"; then
|
|
domain=$(echo "$cert_sans" | head -n1)
|
|
fi
|
|
fi
|
|
|
|
if [[ "$domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
|
echo -e "${green}Access URL: https://${domain}:${existing_port}${existing_webBasePath}${plain}"
|
|
else
|
|
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
|
fi
|
|
if [[ -n "$cert_sans" && $(echo "$cert_sans" | wc -l) -gt 1 ]]; then
|
|
echo -e "${yellow}The certificate also covers:${plain} $(echo "$cert_sans" | grep -vx "$domain" | tr '\n' ' ')"
|
|
fi
|
|
else
|
|
echo -e "${red}⚠ WARNING: No SSL certificate configured!${plain}"
|
|
echo -e "${yellow}You can get a Let's Encrypt certificate for your IP address (valid ~6 days, auto-renews).${plain}"
|
|
read -rp "Generate SSL certificate for IP now? [y/N]: " gen_ssl
|
|
if [[ "$gen_ssl" == "y" || "$gen_ssl" == "Y" ]]; then
|
|
stop 0 > /dev/null 2>&1
|
|
ssl_cert_issue_for_ip
|
|
if [[ $? -eq 0 ]]; then
|
|
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
|
# ssl_cert_issue_for_ip already restarts the panel, but ensure it's running
|
|
start 0 > /dev/null 2>&1
|
|
else
|
|
LOGE "IP certificate setup failed."
|
|
echo -e "${yellow}You can try again via option 19 (SSL Certificate Management).${plain}"
|
|
start 0 > /dev/null 2>&1
|
|
fi
|
|
else
|
|
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
|
echo -e "${yellow}For security, please configure SSL certificate using option 19 (SSL Certificate Management)${plain}"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
set_port() {
|
|
echo -n "Enter port number[1-65535]: "
|
|
read -r port
|
|
if [[ -z "${port}" ]]; then
|
|
LOGD "Cancelled"
|
|
before_show_menu
|
|
else
|
|
${xui_folder}/x-ui setting -port ${port}
|
|
echo -e "The port is set, Please restart the panel now, and use the new port ${green}${port}${plain} to access web panel"
|
|
confirm_restart
|
|
fi
|
|
}
|
|
|
|
start() {
|
|
check_status
|
|
if [[ $? == 0 ]]; then
|
|
echo ""
|
|
LOGI "Panel is running, No need to start again, If you need to restart, please select restart"
|
|
else
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
LOGE "Panel process is not running inside this container."
|
|
LOGI "In Docker the panel is the container's main process. Restart the container to bring it back up:"
|
|
LOGI " docker restart <container_name>"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service x-ui start
|
|
else
|
|
systemctl start x-ui
|
|
fi
|
|
sleep 2
|
|
check_status
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "x-ui Started Successfully"
|
|
else
|
|
LOGE "panel Failed to start, Probably because it takes longer than two seconds to start, Please check the log information later"
|
|
fi
|
|
fi
|
|
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
stop() {
|
|
check_status
|
|
if [[ $? == 1 ]]; then
|
|
echo ""
|
|
LOGI "Panel stopped, No need to stop again!"
|
|
else
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
LOGI "In Docker the panel runs as the container's main process."
|
|
LOGI "To stop it, stop the container from the host:"
|
|
LOGI " docker stop <container_name>"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service x-ui stop
|
|
else
|
|
systemctl stop x-ui
|
|
fi
|
|
sleep 2
|
|
check_status
|
|
if [[ $? == 1 ]]; then
|
|
LOGI "x-ui and xray stopped successfully"
|
|
else
|
|
LOGE "Panel stop failed, Probably because the stop time exceeds two seconds, Please check the log information later"
|
|
fi
|
|
fi
|
|
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
restart() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
if signal_xui HUP; then
|
|
sleep 1
|
|
signal_xui USR1
|
|
LOGI "Restart signal sent to the panel and xray-core."
|
|
else
|
|
LOGE "Could not find the running panel process to signal."
|
|
fi
|
|
sleep 2
|
|
check_status
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "x-ui and xray Restarted successfully"
|
|
else
|
|
LOGE "Panel restart failed, Please check the log information later"
|
|
fi
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service x-ui restart
|
|
else
|
|
systemctl restart x-ui
|
|
fi
|
|
sleep 2
|
|
check_status
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "x-ui and xray Restarted successfully"
|
|
else
|
|
LOGE "Panel restart failed, Probably because it takes longer than two seconds to start, Please check the log information later"
|
|
fi
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
restart_xray() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
if signal_xui USR1; then
|
|
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
|
|
else
|
|
LOGE "Could not find the running panel process to signal."
|
|
fi
|
|
sleep 2
|
|
show_xray_status
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service x-ui reload
|
|
else
|
|
systemctl reload x-ui
|
|
fi
|
|
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
|
|
sleep 2
|
|
show_xray_status
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
status() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
show_status
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service x-ui status
|
|
else
|
|
systemctl status x-ui -l
|
|
fi
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
enable() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
LOGI "Autostart is controlled by the Docker restart policy (e.g. 'restart: unless-stopped' in docker-compose.yml)."
|
|
LOGI "There is no service to enable inside the container."
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-update add x-ui default
|
|
else
|
|
systemctl enable x-ui
|
|
fi
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "x-ui Set to boot automatically on startup successfully"
|
|
else
|
|
LOGE "x-ui Failed to set Autostart"
|
|
fi
|
|
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
disable() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
LOGI "Autostart is controlled by the Docker restart policy (e.g. 'restart: unless-stopped' in docker-compose.yml)."
|
|
LOGI "Set 'restart: no' for the container on the host to disable autostart."
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 0
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-update del x-ui
|
|
else
|
|
systemctl disable x-ui
|
|
fi
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "x-ui Autostart Cancelled successfully"
|
|
else
|
|
LOGE "x-ui Failed to cancel autostart"
|
|
fi
|
|
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
show_log() {
|
|
if [[ $release == "alpine" ]]; then
|
|
echo -e "${green}\t1.${plain} Debug Log"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
grep -F 'x-ui[' /var/log/messages
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
show_log
|
|
;;
|
|
esac
|
|
else
|
|
echo -e "${green}\t1.${plain} Debug Log"
|
|
echo -e "${green}\t2.${plain} Clear All logs"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
journalctl -u x-ui -e --no-pager -f -p debug
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
;;
|
|
2)
|
|
sudo journalctl --rotate
|
|
sudo journalctl --vacuum-time=1s
|
|
echo "All Logs cleared."
|
|
restart
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
show_log
|
|
;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
bbr_menu() {
|
|
echo -e "${green}\t1.${plain} Enable BBR"
|
|
echo -e "${green}\t2.${plain} Disable BBR"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
enable_bbr
|
|
bbr_menu
|
|
;;
|
|
2)
|
|
disable_bbr
|
|
bbr_menu
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
bbr_menu
|
|
;;
|
|
esac
|
|
}
|
|
|
|
disable_bbr() {
|
|
|
|
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) != "bbr" ]] || [[ ! $(sysctl -n net.core.default_qdisc) =~ ^(fq|cake)$ ]]; then
|
|
echo -e "${yellow}BBR is not currently enabled.${plain}"
|
|
before_show_menu
|
|
fi
|
|
|
|
if [ -f "/etc/sysctl.d/99-bbr-x-ui.conf" ]; then
|
|
old_settings=$(head -1 /etc/sysctl.d/99-bbr-x-ui.conf | tr -d '#')
|
|
# sysctl -w already restores the live values, so no `sysctl --system`
|
|
# afterwards — it would re-apply every sysctl file on the host and
|
|
# surface unrelated errors from the distro's own defaults (see issue #5160)
|
|
sysctl -w net.core.default_qdisc="${old_settings%:*}"
|
|
sysctl -w net.ipv4.tcp_congestion_control="${old_settings#*:}"
|
|
rm /etc/sysctl.d/99-bbr-x-ui.conf
|
|
else
|
|
# Replace BBR with CUBIC configurations
|
|
if [ -f "/etc/sysctl.conf" ]; then
|
|
sed -i 's/net.core.default_qdisc=fq/net.core.default_qdisc=pfifo_fast/' /etc/sysctl.conf
|
|
sed -i 's/net.ipv4.tcp_congestion_control=bbr/net.ipv4.tcp_congestion_control=cubic/' /etc/sysctl.conf
|
|
sysctl -p
|
|
fi
|
|
fi
|
|
|
|
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) != "bbr" ]]; then
|
|
echo -e "${green}BBR has been replaced with CUBIC successfully.${plain}"
|
|
else
|
|
echo -e "${red}Failed to replace BBR with CUBIC. Please check your system configuration.${plain}"
|
|
fi
|
|
}
|
|
|
|
enable_bbr() {
|
|
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" ]] && [[ $(sysctl -n net.core.default_qdisc) =~ ^(fq|cake)$ ]]; then
|
|
echo -e "${green}BBR is already enabled!${plain}"
|
|
before_show_menu
|
|
fi
|
|
|
|
# Enable BBR
|
|
if [ -d "/etc/sysctl.d/" ]; then
|
|
{
|
|
echo "#$(sysctl -n net.core.default_qdisc):$(sysctl -n net.ipv4.tcp_congestion_control)"
|
|
echo "net.core.default_qdisc = fq"
|
|
echo "net.ipv4.tcp_congestion_control = bbr"
|
|
} > "/etc/sysctl.d/99-bbr-x-ui.conf"
|
|
if [ -f "/etc/sysctl.conf" ]; then
|
|
# Backup old settings from sysctl.conf, if any
|
|
sed -i 's/^net.core.default_qdisc/# &/' /etc/sysctl.conf
|
|
sed -i 's/^net.ipv4.tcp_congestion_control/# &/' /etc/sysctl.conf
|
|
fi
|
|
# Apply only our config file; `sysctl --system` would re-apply every
|
|
# sysctl file on the host and surface unrelated errors from the distro's
|
|
# own defaults (see issue #5160)
|
|
sysctl -p /etc/sysctl.d/99-bbr-x-ui.conf
|
|
else
|
|
sed -i '/net.core.default_qdisc/d' /etc/sysctl.conf
|
|
sed -i '/net.ipv4.tcp_congestion_control/d' /etc/sysctl.conf
|
|
echo "net.core.default_qdisc=fq" | tee -a /etc/sysctl.conf
|
|
echo "net.ipv4.tcp_congestion_control=bbr" | tee -a /etc/sysctl.conf
|
|
sysctl -p
|
|
fi
|
|
|
|
# Verify that BBR is enabled
|
|
if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" ]]; then
|
|
echo -e "${green}BBR has been enabled successfully.${plain}"
|
|
else
|
|
echo -e "${red}Failed to enable BBR. Please check your system configuration.${plain}"
|
|
fi
|
|
}
|
|
|
|
update_shell() {
|
|
if replace_xui_script "https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh" "true"; then
|
|
LOGI "Upgrade script succeeded, Please rerun the script"
|
|
before_show_menu
|
|
else
|
|
echo ""
|
|
LOGE "Failed to download script, Please check whether the machine can connect Github"
|
|
before_show_menu
|
|
fi
|
|
}
|
|
|
|
xui_pid() {
|
|
ps -ef 2> /dev/null | grep -F "${xui_folder}/x-ui" | grep -v grep | awk 'NR==1 {print $1}'
|
|
}
|
|
|
|
signal_xui() {
|
|
local sig="$1" pid
|
|
pid="$(xui_pid)"
|
|
if [[ -z "${pid}" ]]; then
|
|
return 1
|
|
fi
|
|
kill -"${sig}" "${pid}" 2> /dev/null
|
|
}
|
|
|
|
# 0: running, 1: not running, 2: not installed
|
|
check_status() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
if [[ ! -x "${xui_folder}/x-ui" ]]; then
|
|
return 2
|
|
fi
|
|
if [[ -n "$(xui_pid)" ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
fi
|
|
if [[ $release == "alpine" ]]; then
|
|
if [[ ! -f /etc/init.d/x-ui ]]; then
|
|
return 2
|
|
fi
|
|
if [[ $(rc-service x-ui status | grep -F 'status: started' -c) == 1 ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
else
|
|
if [[ ! -f ${xui_service}/x-ui.service ]]; then
|
|
return 2
|
|
fi
|
|
temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
|
|
if [[ "${temp}" == "running" ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
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
|
|
else
|
|
temp=$(systemctl is-enabled x-ui)
|
|
if [[ "${temp}" == "enabled" ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
check_uninstall() {
|
|
check_status
|
|
if [[ $? != 2 ]]; then
|
|
echo ""
|
|
LOGE "Panel installed, Please do not reinstall"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 1
|
|
else
|
|
return 0
|
|
fi
|
|
}
|
|
|
|
check_install() {
|
|
check_status
|
|
if [[ $? == 2 ]]; then
|
|
echo ""
|
|
LOGE "Please install the panel first"
|
|
if [[ $# == 0 ]]; then
|
|
before_show_menu
|
|
fi
|
|
return 1
|
|
else
|
|
return 0
|
|
fi
|
|
}
|
|
|
|
show_status() {
|
|
check_status
|
|
case $? in
|
|
0)
|
|
echo -e "Panel state: ${green}Running${plain}"
|
|
show_enable_status
|
|
;;
|
|
1)
|
|
echo -e "Panel state: ${yellow}Not Running${plain}"
|
|
show_enable_status
|
|
;;
|
|
2)
|
|
echo -e "Panel state: ${red}Not Installed${plain}"
|
|
;;
|
|
esac
|
|
show_xray_status
|
|
show_mtproto_status
|
|
}
|
|
|
|
show_enable_status() {
|
|
if [[ "${running_in_docker}" == "true" ]]; then
|
|
echo -e "Start automatically: ${green}Managed by Docker${plain}"
|
|
return
|
|
fi
|
|
check_enabled
|
|
if [[ $? == 0 ]]; then
|
|
echo -e "Start automatically: ${green}Yes${plain}"
|
|
else
|
|
echo -e "Start automatically: ${red}No${plain}"
|
|
fi
|
|
}
|
|
|
|
check_xray_status() {
|
|
count=$(ps -ef | grep "xray-linux" | grep -v "grep" | wc -l)
|
|
if [[ count -ne 0 ]]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
show_xray_status() {
|
|
check_xray_status
|
|
if [[ $? == 0 ]]; then
|
|
echo -e "xray state: ${green}Running${plain}"
|
|
else
|
|
echo -e "xray state: ${red}Not Running${plain}"
|
|
fi
|
|
}
|
|
|
|
# show_mtproto_status reports each mtproto inbound's mtg sidecar (one process per
|
|
# inbound, run outside xray). Silent when no mtproto inbound is configured.
|
|
show_mtproto_status() {
|
|
local cfg_dir="${xui_folder}/bin/mtproto"
|
|
local cfgs=()
|
|
if [[ -d "${cfg_dir}" ]]; then
|
|
for f in "${cfg_dir}"/mtg-*.toml; do
|
|
[[ -e "$f" ]] && cfgs+=("$f")
|
|
done
|
|
fi
|
|
[[ ${#cfgs[@]} -eq 0 ]] && return
|
|
|
|
local running
|
|
running=$(ps -ef | grep "mtg-linux" | grep -v "grep" | grep -oE 'mtg-[0-9]+\.toml')
|
|
for f in "${cfgs[@]}"; do
|
|
local name id bind
|
|
name=$(basename "$f")
|
|
id=$(echo "${name}" | sed -E 's/mtg-([0-9]+)\.toml/\1/')
|
|
bind=$(grep -E '^[[:space:]]*bind-to' "$f" | head -1 | cut -d'"' -f2)
|
|
if echo "${running}" | grep -qx "${name}"; then
|
|
echo -e "mtproto inbound ${id} (${bind}): ${green}Running${plain}"
|
|
else
|
|
echo -e "mtproto inbound ${id} (${bind}): ${red}Not Running${plain}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
firewall_menu() {
|
|
echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall"
|
|
echo -e "${green}\t2.${plain} Port List [numbered]"
|
|
echo -e "${green}\t3.${plain} ${green}Open${plain} Ports"
|
|
echo -e "${green}\t4.${plain} ${red}Delete${plain} Ports from List"
|
|
echo -e "${green}\t5.${plain} ${green}Enable${plain} Firewall"
|
|
echo -e "${green}\t6.${plain} ${red}Disable${plain} Firewall"
|
|
echo -e "${green}\t7.${plain} Firewall Status"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
install_firewall
|
|
firewall_menu
|
|
;;
|
|
2)
|
|
ufw status numbered
|
|
firewall_menu
|
|
;;
|
|
3)
|
|
open_ports
|
|
firewall_menu
|
|
;;
|
|
4)
|
|
delete_ports
|
|
firewall_menu
|
|
;;
|
|
5)
|
|
ufw enable
|
|
firewall_menu
|
|
;;
|
|
6)
|
|
ufw disable
|
|
firewall_menu
|
|
;;
|
|
7)
|
|
ufw status verbose
|
|
firewall_menu
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
firewall_menu
|
|
;;
|
|
esac
|
|
}
|
|
|
|
install_firewall() {
|
|
if ! command -v ufw &> /dev/null; then
|
|
echo "ufw firewall is not installed. Installing now..."
|
|
apt-get update
|
|
apt-get install -y ufw
|
|
else
|
|
echo "ufw firewall is already installed"
|
|
fi
|
|
|
|
# Check if the firewall is inactive
|
|
if ufw status | grep -q "Status: active"; then
|
|
echo "Firewall is already active"
|
|
else
|
|
echo "Activating firewall..."
|
|
# Open the necessary ports
|
|
ufw allow ssh
|
|
ufw allow http
|
|
ufw allow https
|
|
ufw allow 2053/tcp #webPort
|
|
ufw allow 2096/tcp #subport
|
|
|
|
# Enable the firewall
|
|
ufw --force enable
|
|
fi
|
|
}
|
|
|
|
open_ports() {
|
|
# Prompt the user to enter the ports they want to open
|
|
read -rp "Enter the ports you want to open (e.g. 80,443,2053 or range 400-500): " ports
|
|
|
|
# Check if the input is valid
|
|
if ! [[ $ports =~ ^([0-9]+|[0-9]+-[0-9]+)(,([0-9]+|[0-9]+-[0-9]+))*$ ]]; then
|
|
echo "Error: Invalid input. Please enter a comma-separated list of ports or a range of ports (e.g. 80,443,2053 or 400-500)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Open the specified ports using ufw
|
|
IFS=',' read -ra PORT_LIST <<< "$ports"
|
|
for port in "${PORT_LIST[@]}"; do
|
|
if [[ $port == *-* ]]; then
|
|
# Split the range into start and end ports
|
|
start_port=$(echo $port | cut -d'-' -f1)
|
|
end_port=$(echo $port | cut -d'-' -f2)
|
|
# Open the port range
|
|
ufw allow $start_port:$end_port/tcp
|
|
ufw allow $start_port:$end_port/udp
|
|
else
|
|
# Open the single port
|
|
ufw allow "$port"
|
|
fi
|
|
done
|
|
|
|
# Confirm that the ports are opened
|
|
echo "Opened the specified ports:"
|
|
for port in "${PORT_LIST[@]}"; do
|
|
if [[ $port == *-* ]]; then
|
|
start_port=$(echo $port | cut -d'-' -f1)
|
|
end_port=$(echo $port | cut -d'-' -f2)
|
|
# Check if the port range has been successfully opened
|
|
(ufw status | grep -q "$start_port:$end_port") && echo "$start_port-$end_port"
|
|
else
|
|
# Check if the individual port has been successfully opened
|
|
(ufw status | grep -q "$port") && echo "$port"
|
|
fi
|
|
done
|
|
}
|
|
|
|
delete_ports() {
|
|
# Display current rules with numbers
|
|
echo "Current UFW rules:"
|
|
ufw status numbered
|
|
|
|
# Ask the user how they want to delete rules
|
|
echo "Do you want to delete rules by:"
|
|
echo "1) Rule numbers"
|
|
echo "2) Ports"
|
|
read -rp "Enter your choice (1 or 2): " choice
|
|
|
|
if [[ $choice -eq 1 ]]; then
|
|
# Deleting by rule numbers
|
|
read -rp "Enter the rule numbers you want to delete (1, 2, etc.): " rule_numbers
|
|
|
|
# Validate the input
|
|
if ! [[ $rule_numbers =~ ^([0-9]+)(,[0-9]+)*$ ]]; then
|
|
echo "Error: Invalid input. Please enter a comma-separated list of rule numbers." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Split numbers into an array
|
|
IFS=',' read -ra RULE_NUMBERS <<< "$rule_numbers"
|
|
for rule_number in "${RULE_NUMBERS[@]}"; do
|
|
# Delete the rule by number
|
|
ufw delete "$rule_number" || echo "Failed to delete rule number $rule_number"
|
|
done
|
|
|
|
echo "Selected rules have been deleted."
|
|
|
|
elif [[ $choice -eq 2 ]]; then
|
|
# Deleting by ports
|
|
read -rp "Enter the ports you want to delete (e.g. 80,443,2053 or range 400-500): " ports
|
|
|
|
# Validate the input
|
|
if ! [[ $ports =~ ^([0-9]+|[0-9]+-[0-9]+)(,([0-9]+|[0-9]+-[0-9]+))*$ ]]; then
|
|
echo "Error: Invalid input. Please enter a comma-separated list of ports or a range of ports (e.g. 80,443,2053 or 400-500)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Split ports into an array
|
|
IFS=',' read -ra PORT_LIST <<< "$ports"
|
|
for port in "${PORT_LIST[@]}"; do
|
|
if [[ $port == *-* ]]; then
|
|
# Split the port range
|
|
start_port=$(echo $port | cut -d'-' -f1)
|
|
end_port=$(echo $port | cut -d'-' -f2)
|
|
# Delete the port range
|
|
ufw delete allow $start_port:$end_port/tcp
|
|
ufw delete allow $start_port:$end_port/udp
|
|
else
|
|
# Delete a single port
|
|
ufw delete allow "$port"
|
|
fi
|
|
done
|
|
|
|
# Confirmation of deletion
|
|
echo "Deleted the specified ports:"
|
|
for port in "${PORT_LIST[@]}"; do
|
|
if [[ $port == *-* ]]; then
|
|
start_port=$(echo $port | cut -d'-' -f1)
|
|
end_port=$(echo $port | cut -d'-' -f2)
|
|
# Check if the port range has been deleted
|
|
(ufw status | grep -q "$start_port:$end_port") || echo "$start_port-$end_port"
|
|
else
|
|
# Check if the individual port has been deleted
|
|
(ufw status | grep -q "$port") || echo "$port"
|
|
fi
|
|
done
|
|
else
|
|
echo "${red}Error:${plain} Invalid choice. Please enter 1 or 2." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
update_all_geofiles() {
|
|
local failed=0
|
|
update_geofiles "main" || failed=1
|
|
update_geofiles "IR" || failed=1
|
|
update_geofiles "RU" || failed=1
|
|
return $failed
|
|
}
|
|
|
|
update_geofiles() {
|
|
case "${1}" in
|
|
"main")
|
|
dat_files=(geoip geosite)
|
|
dat_source="Loyalsoldier/v2ray-rules-dat"
|
|
;;
|
|
"IR")
|
|
dat_files=(geoip_IR geosite_IR)
|
|
dat_source="chocolate4u/Iran-v2ray-rules"
|
|
;;
|
|
"RU")
|
|
dat_files=(geoip_RU geosite_RU)
|
|
dat_source="runetfreedom/russia-v2ray-rules-dat"
|
|
;;
|
|
*)
|
|
echo -e "${red}update_geofiles: unknown dataset '${1}'${plain}"
|
|
return 1
|
|
;;
|
|
esac
|
|
local failed=0 http_code
|
|
for dat in "${dat_files[@]}"; do
|
|
# Remove suffix for remote filename (e.g., geoip_IR -> geoip)
|
|
remote_file="${dat%%_*}"
|
|
local dest="${xui_folder}/bin/${dat}.dat"
|
|
local temp_file="${dest}.tmp.$$"
|
|
rm -f "$temp_file"
|
|
# -z (against the live file, not the temp file) skips the download
|
|
# (server answers 304) when the local copy is already current.
|
|
http_code=$(curl -sSfLRo "$temp_file" -z "$dest" -w '%{http_code}' \
|
|
https://github.com/${dat_source}/releases/latest/download/${remote_file}.dat)
|
|
if [[ $? -ne 0 ]]; then
|
|
echo -e "${red}${dat}.dat: download failed${plain}"
|
|
rm -f "$temp_file"
|
|
failed=1
|
|
elif [[ "$http_code" == "304" ]]; then
|
|
echo -e "${dat}.dat: already up to date"
|
|
rm -f "$temp_file"
|
|
elif [[ ! -s "$temp_file" ]]; then
|
|
echo -e "${red}${dat}.dat: downloaded file is empty${plain}"
|
|
rm -f "$temp_file"
|
|
failed=1
|
|
else
|
|
mv -f "$temp_file" "$dest"
|
|
if [[ $? -ne 0 ]]; then
|
|
echo -e "${red}${dat}.dat: failed to install${plain}"
|
|
rm -f "$temp_file"
|
|
failed=1
|
|
else
|
|
echo -e "${green}${dat}.dat: updated${plain}"
|
|
geo_updated=1
|
|
fi
|
|
fi
|
|
done
|
|
return $failed
|
|
}
|
|
|
|
run_geo_update() {
|
|
local name="$1"
|
|
shift
|
|
geo_updated=0
|
|
"$@"
|
|
if [[ $? -ne 0 ]]; then
|
|
echo -e "${red}Some ${name} could not be updated. Check the errors above.${plain}"
|
|
elif [[ $geo_updated -eq 1 ]]; then
|
|
echo -e "${green}${name} have been updated successfully!${plain}"
|
|
restart
|
|
else
|
|
echo -e "${green}${name} are already up to date, restart is not needed.${plain}"
|
|
fi
|
|
}
|
|
|
|
update_geo() {
|
|
echo -e "${green}\t1.${plain} Loyalsoldier (geoip.dat, geosite.dat)"
|
|
echo -e "${green}\t2.${plain} chocolate4u (geoip_IR.dat, geosite_IR.dat)"
|
|
echo -e "${green}\t3.${plain} runetfreedom (geoip_RU.dat, geosite_RU.dat)"
|
|
echo -e "${green}\t4.${plain} All"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
run_geo_update "Loyalsoldier datasets" update_geofiles "main"
|
|
;;
|
|
2)
|
|
run_geo_update "chocolate4u datasets" update_geofiles "IR"
|
|
;;
|
|
3)
|
|
run_geo_update "runetfreedom datasets" update_geofiles "RU"
|
|
;;
|
|
4)
|
|
run_geo_update "geo files" update_all_geofiles
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
update_geo
|
|
;;
|
|
esac
|
|
|
|
before_show_menu
|
|
}
|
|
|
|
install_acme() {
|
|
# Check if acme.sh is already installed
|
|
if command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
|
LOGI "acme.sh is already installed."
|
|
return 0
|
|
fi
|
|
|
|
LOGI "Installing acme.sh..."
|
|
cd ~ || return 1 # Ensure you can change to the home directory
|
|
|
|
curl -s https://get.acme.sh | sh
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Installation of acme.sh failed."
|
|
return 1
|
|
else
|
|
LOGI "Installation of acme.sh succeeded."
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
ssl_cert_issue_main() {
|
|
echo -e "${green}\t1.${plain} Get SSL (Domain)"
|
|
echo -e "${green}\t2.${plain} Revoke & Remove"
|
|
echo -e "${green}\t3.${plain} Force Renew"
|
|
echo -e "${green}\t4.${plain} Show Existing Domains"
|
|
echo -e "${green}\t5.${plain} Set Cert paths for the panel"
|
|
echo -e "${green}\t6.${plain} Get SSL for IP Address (6-day cert, auto-renews)"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
ssl_cert_issue
|
|
ssl_cert_issue_main
|
|
;;
|
|
2)
|
|
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
|
if [ -z "$domains" ]; then
|
|
echo "No certificates found to revoke."
|
|
else
|
|
echo "Existing domains:"
|
|
echo "$domains"
|
|
read -rp "Please enter a domain from the list to revoke and remove the certificate: " domain
|
|
if echo "$domains" | grep -qw "$domain"; then
|
|
# The IP-cert flow (option 6) stores files under /root/cert/ip, but acme.sh
|
|
# tracks the cert under the actual IP address(es). Resolve those so renewal
|
|
# state is torn down too; otherwise the acme.sh cron re-creates the deleted cert.
|
|
local acme_ids="${domain}"
|
|
if [[ "${domain}" == "ip" ]]; then
|
|
acme_ids=$(~/.acme.sh/acme.sh --list 2> /dev/null | awk 'NR>1 {print $1}' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$|:')
|
|
fi
|
|
for id in ${acme_ids}; do
|
|
# Best-effort revoke at the CA, then drop acme.sh renewal tracking.
|
|
~/.acme.sh/acme.sh --revoke -d "${id}" 2> /dev/null
|
|
~/.acme.sh/acme.sh --remove -d "${id}" 2> /dev/null
|
|
# --remove leaves the cert files on disk, so delete the state dirs (RSA + ECC).
|
|
rm -rf ~/.acme.sh/"${id}" ~/.acme.sh/"${id}_ecc"
|
|
done
|
|
# Delete the local certificate files for this domain.
|
|
rm -rf "/root/cert/${domain}"
|
|
LOGI "Certificate revoked and removed for domain: ${domain}"
|
|
|
|
# If the panel currently serves this domain's cert, clear the stored paths
|
|
# so it stops loading the now-deleted files, then restart.
|
|
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
|
if [[ "${existing_cert}" == "/root/cert/${domain}/"* ]]; then
|
|
${xui_folder}/x-ui cert -reset
|
|
LOGI "Cleared panel certificate paths referencing ${domain}; restarting panel."
|
|
restart
|
|
fi
|
|
else
|
|
echo "Invalid domain entered."
|
|
fi
|
|
fi
|
|
ssl_cert_issue_main
|
|
;;
|
|
3)
|
|
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
|
if [ -z "$domains" ]; then
|
|
echo "No certificates found to renew."
|
|
else
|
|
echo "Existing domains:"
|
|
echo "$domains"
|
|
read -rp "Please enter a domain from the list to renew the SSL certificate: " domain
|
|
if echo "$domains" | grep -qw "$domain"; then
|
|
~/.acme.sh/acme.sh --renew -d ${domain} --force
|
|
LOGI "Certificate forcefully renewed for domain: $domain"
|
|
else
|
|
echo "Invalid domain entered."
|
|
fi
|
|
fi
|
|
ssl_cert_issue_main
|
|
;;
|
|
4)
|
|
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
|
if [ -z "$domains" ]; then
|
|
echo "No certificates found under /root/cert."
|
|
else
|
|
echo "Existing domains and their paths:"
|
|
for domain in $domains; do
|
|
local cert_path="/root/cert/${domain}/fullchain.pem"
|
|
local key_path="/root/cert/${domain}/privkey.pem"
|
|
if [[ -f "${cert_path}" && -f "${key_path}" ]]; then
|
|
echo -e "Domain: ${domain}"
|
|
echo -e "\tCertificate Path: ${cert_path}"
|
|
echo -e "\tPrivate Key Path: ${key_path}"
|
|
else
|
|
echo -e "Domain: ${domain} - Certificate or Key missing."
|
|
fi
|
|
done
|
|
fi
|
|
# The panel's configured certificate may live outside /root/cert
|
|
# (e.g. certbot under /etc/letsencrypt) — show it too (#5070).
|
|
local panel_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
|
if [[ -n "${panel_cert}" && "${panel_cert}" != /root/cert/* ]]; then
|
|
echo -e "Panel certificate (custom path): ${panel_cert}"
|
|
if [[ -f "${panel_cert}" ]] && command -v openssl > /dev/null 2>&1; then
|
|
local panel_sans=$(openssl x509 -in "${panel_cert}" -noout -ext subjectAltName 2> /dev/null \
|
|
| grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2 | tr '\n' ' ')
|
|
[[ -n "${panel_sans}" ]] && echo -e "\tCovers: ${panel_sans}"
|
|
fi
|
|
fi
|
|
ssl_cert_issue_main
|
|
;;
|
|
5)
|
|
echo -e "${green}\t1.${plain} Use a certificate from /root/cert"
|
|
echo -e "${green}\t2.${plain} Enter custom certificate file paths (e.g. certbot, /etc/letsencrypt/...)"
|
|
read -rp "Choose an option: " pathChoice
|
|
if [[ "$pathChoice" == "2" ]]; then
|
|
read -rp "Certificate file path (fullchain): " webCertFile
|
|
read -rp "Private key file path: " webKeyFile
|
|
if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
|
|
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
|
echo "Panel certificate paths set:"
|
|
echo " - Certificate File: $webCertFile"
|
|
echo " - Private Key File: $webKeyFile"
|
|
restart
|
|
else
|
|
echo "Certificate or private key file not found."
|
|
fi
|
|
ssl_cert_issue_main
|
|
return
|
|
fi
|
|
local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
|
|
if [ -z "$domains" ]; then
|
|
echo "No certificates found."
|
|
else
|
|
echo "Available domains:"
|
|
echo "$domains"
|
|
read -rp "Please choose a domain to set the panel paths: " domain
|
|
|
|
if echo "$domains" | grep -qw "$domain"; then
|
|
local webCertFile="/root/cert/${domain}/fullchain.pem"
|
|
local webKeyFile="/root/cert/${domain}/privkey.pem"
|
|
|
|
if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
|
|
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
|
echo "Panel paths set for domain: $domain"
|
|
echo " - Certificate File: $webCertFile"
|
|
echo " - Private Key File: $webKeyFile"
|
|
# Register the acme.sh install-cert hook so auto-renewal copies the
|
|
# renewed cert to these paths and reloads the panel. Without it acme.sh
|
|
# renews but never updates /root/cert, silently serving a stale cert.
|
|
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
|
~/.acme.sh/acme.sh --installcert --force -d "${domain}" \
|
|
--key-file "${webKeyFile}" \
|
|
--fullchain-file "${webCertFile}" \
|
|
--reloadcmd "x-ui restart" 2>&1 || true
|
|
echo "Registered acme.sh auto-renewal hook for ${domain}."
|
|
fi
|
|
restart
|
|
else
|
|
echo "Certificate or private key not found for domain: $domain."
|
|
fi
|
|
else
|
|
echo "Invalid domain entered."
|
|
fi
|
|
fi
|
|
ssl_cert_issue_main
|
|
;;
|
|
6)
|
|
echo -e "${yellow}Let's Encrypt SSL Certificate for IP Address${plain}"
|
|
echo -e "This will obtain a certificate for your server's IP using the shortlived profile."
|
|
echo -e "${yellow}Certificate valid for ~6 days, auto-renews via acme.sh cron job.${plain}"
|
|
echo -e "${yellow}Port 80 must be open and accessible from the internet.${plain}"
|
|
confirm "Do you want to proceed?" "y"
|
|
if [[ $? == 0 ]]; then
|
|
ssl_cert_issue_for_ip
|
|
fi
|
|
ssl_cert_issue_main
|
|
;;
|
|
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
ssl_cert_issue_main
|
|
;;
|
|
esac
|
|
}
|
|
|
|
ssl_cert_issue_for_ip() {
|
|
LOGI "Starting automatic SSL certificate generation for server IP..."
|
|
LOGI "Using Let's Encrypt shortlived profile (~6 days validity, auto-renews)"
|
|
|
|
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
|
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
|
|
|
# Get server IP
|
|
local URL_lists=(
|
|
"https://api4.ipify.org"
|
|
"https://ipv4.icanhazip.com"
|
|
"https://v4.api.ipinfo.io/ip"
|
|
"https://ipv4.myexternalip.com/raw"
|
|
"https://4.ident.me"
|
|
"https://check-host.net/ip"
|
|
)
|
|
local server_ip=""
|
|
for ip_address in "${URL_lists[@]}"; do
|
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
|
local http_code=$(echo "$response" | tail -n1)
|
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
|
|
if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
server_ip="${ip_result}"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -z "$server_ip" ]]; then
|
|
LOGI "Could not auto-detect server IP from any provider."
|
|
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
|
|
LOGE "Invalid IPv4 address. Please try again."
|
|
server_ip=""
|
|
fi
|
|
done
|
|
fi
|
|
|
|
LOGI "Server IP detected: ${server_ip}"
|
|
|
|
# Ask for optional IPv6
|
|
local ipv6_addr=""
|
|
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
|
|
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
|
|
|
|
# check for acme.sh first
|
|
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
|
LOGI "acme.sh not found, installing..."
|
|
install_acme
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Failed to install acme.sh"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# install socat
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
apt-get update > /dev/null 2>&1 && apt-get install socat -y > /dev/null 2>&1
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf -y update > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum -y update > /dev/null 2>&1 && yum -y install socat > /dev/null 2>&1
|
|
else
|
|
dnf -y update > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
|
fi
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Sy --noconfirm socat > /dev/null 2>&1
|
|
;;
|
|
opensuse-tumbleweed | opensuse-leap)
|
|
zypper refresh > /dev/null 2>&1 && zypper -q install -y socat > /dev/null 2>&1
|
|
;;
|
|
alpine)
|
|
apk add socat curl openssl > /dev/null 2>&1
|
|
;;
|
|
*)
|
|
LOGW "Unsupported OS for automatic socat installation"
|
|
;;
|
|
esac
|
|
|
|
# Create certificate directory
|
|
certPath="/root/cert/ip"
|
|
mkdir -p "$certPath"
|
|
|
|
# Build domain arguments
|
|
local domain_args="-d ${server_ip}"
|
|
if [[ -n "$ipv6_addr" ]] && is_ipv6 "$ipv6_addr"; then
|
|
domain_args="${domain_args} -d ${ipv6_addr}"
|
|
LOGI "Including IPv6 address: ${ipv6_addr}"
|
|
fi
|
|
|
|
# Choose port for HTTP-01 listener (default 80, allow override)
|
|
local WebPort=""
|
|
read -rp "Port to use for ACME HTTP-01 listener (default 80): " WebPort
|
|
WebPort="${WebPort:-80}"
|
|
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
|
|
LOGE "Invalid port provided. Falling back to 80."
|
|
WebPort=80
|
|
fi
|
|
LOGI "Using port ${WebPort} to issue certificate for IP: ${server_ip}"
|
|
if [[ "${WebPort}" -ne 80 ]]; then
|
|
LOGI "Reminder: Let's Encrypt still reaches port 80; forward external port 80 to ${WebPort} for validation."
|
|
fi
|
|
|
|
while true; do
|
|
if is_port_in_use "${WebPort}"; then
|
|
LOGI "Port ${WebPort} is currently in use."
|
|
|
|
local alt_port=""
|
|
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
|
|
LOGE "Port ${WebPort} is busy; cannot proceed with issuance."
|
|
return 1
|
|
fi
|
|
if ! [[ "${alt_port}" =~ ^[0-9]+$ ]] || ((alt_port < 1 || alt_port > 65535)); then
|
|
LOGE "Invalid port provided."
|
|
return 1
|
|
fi
|
|
WebPort="${alt_port}"
|
|
continue
|
|
else
|
|
LOGI "Port ${WebPort} is free and ready for standalone validation."
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Reload command - restarts panel after renewal
|
|
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null"
|
|
|
|
# issue the certificate for IP with shortlived profile
|
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
|
~/.acme.sh/acme.sh --issue \
|
|
${domain_args} \
|
|
--standalone \
|
|
--server letsencrypt \
|
|
--certificate-profile shortlived \
|
|
--days 6 \
|
|
--httpport ${WebPort} \
|
|
--force
|
|
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Failed to issue certificate for IP: ${server_ip}"
|
|
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
|
|
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
|
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
|
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
|
rm -rf ${certPath} 2> /dev/null
|
|
return 1
|
|
else
|
|
LOGI "Certificate issued successfully for IP: ${server_ip}"
|
|
fi
|
|
|
|
# Install the certificate
|
|
# Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
|
|
# but the cert files are still installed. We check for files instead of exit code.
|
|
~/.acme.sh/acme.sh --installcert --force -d ${server_ip} \
|
|
--key-file "${certPath}/privkey.pem" \
|
|
--fullchain-file "${certPath}/fullchain.pem" \
|
|
--reloadcmd "${reloadCmd}" 2>&1 || true
|
|
|
|
# Verify certificate files exist (don't rely on exit code - reloadcmd failure causes non-zero)
|
|
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
|
LOGE "Certificate files not found after installation"
|
|
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
|
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
|
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
|
rm -rf ${certPath} 2> /dev/null
|
|
return 1
|
|
fi
|
|
|
|
LOGI "Certificate files installed successfully"
|
|
|
|
# enable auto-renew
|
|
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
|
|
chmod 600 $certPath/privkey.pem 2> /dev/null
|
|
chmod 644 $certPath/fullchain.pem 2> /dev/null
|
|
|
|
# Prompt user to set panel paths after successful certificate installation
|
|
local webCertFile="${certPath}/fullchain.pem"
|
|
local webKeyFile="${certPath}/privkey.pem"
|
|
|
|
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
|
|
if [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
|
|
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
|
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
|
LOGI "Panel paths set for IP: $server_ip"
|
|
LOGI " - Certificate File: $webCertFile"
|
|
LOGI " - Private Key File: $webKeyFile"
|
|
LOGI " - Validity: ~6 days (auto-renews via acme.sh cron)"
|
|
echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
|
|
LOGI "Panel will restart to apply SSL certificate..."
|
|
restart
|
|
else
|
|
LOGE "Error: Certificate or private key file not found for IP: $server_ip."
|
|
return 1
|
|
fi
|
|
else
|
|
LOGI "Skipping panel path setting."
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
ssl_cert_issue() {
|
|
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
|
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
|
# check for acme.sh first
|
|
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
|
echo "acme.sh could not be found. we will install it"
|
|
install_acme
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "install acme failed, please check logs"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# install socat
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
apt-get update > /dev/null 2>&1 && apt-get install socat -y > /dev/null 2>&1
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf -y update > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum -y update > /dev/null 2>&1 && yum -y install socat > /dev/null 2>&1
|
|
else
|
|
dnf -y update > /dev/null 2>&1 && dnf -y install socat > /dev/null 2>&1
|
|
fi
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Sy --noconfirm socat > /dev/null 2>&1
|
|
;;
|
|
opensuse-tumbleweed | opensuse-leap)
|
|
zypper refresh > /dev/null 2>&1 && zypper -q install -y socat > /dev/null 2>&1
|
|
;;
|
|
alpine)
|
|
apk add socat curl openssl > /dev/null 2>&1
|
|
;;
|
|
*)
|
|
LOGW "Unsupported OS for automatic socat installation"
|
|
;;
|
|
esac
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "install socat failed, please check logs"
|
|
exit 1
|
|
else
|
|
LOGI "install socat succeed..."
|
|
fi
|
|
|
|
# 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
|
|
LOGE "Domain name cannot be empty. Please try again."
|
|
continue
|
|
fi
|
|
|
|
if ! is_domain "$domain"; then
|
|
LOGE "Invalid domain format: ${domain}. Please enter a valid domain name."
|
|
continue
|
|
fi
|
|
|
|
break
|
|
done
|
|
LOGD "Your domain is: ${domain}, checking it..."
|
|
SSL_ISSUED_DOMAIN="${domain}"
|
|
|
|
# detect existing certificate and reuse it only if its files are actually
|
|
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
|
|
# certs under ${domain}; a failed issuance can leave a domain entry in --list
|
|
# with no usable cert files, which must not be reused (it produces a 0-byte
|
|
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
|
|
local cert_exists=0
|
|
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
|
local acmeCertDir=""
|
|
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
|
|
acmeCertDir=~/.acme.sh/${domain}_ecc
|
|
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
|
|
acmeCertDir=~/.acme.sh/${domain}
|
|
fi
|
|
if [[ -n "${acmeCertDir}" ]]; then
|
|
cert_exists=1
|
|
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
|
LOGI "Existing certificate found for ${domain}, will reuse it."
|
|
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
|
else
|
|
LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
|
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
|
fi
|
|
fi
|
|
if [[ ${cert_exists} -eq 0 ]]; then
|
|
LOGI "Your domain is ready for issuing certificates now..."
|
|
fi
|
|
|
|
# create a directory for the certificate
|
|
certPath="/root/cert/${domain}"
|
|
if [ ! -d "$certPath" ]; then
|
|
mkdir -p "$certPath"
|
|
else
|
|
rm -rf "$certPath"
|
|
mkdir -p "$certPath"
|
|
fi
|
|
|
|
# get the port number for the standalone server
|
|
local WebPort=80
|
|
read -rp "Please choose which port to use (default is 80): " WebPort
|
|
if [[ ${WebPort} -gt 65535 || ${WebPort} -lt 1 ]]; then
|
|
LOGE "Your input ${WebPort} is invalid, will use default port 80."
|
|
WebPort=80
|
|
fi
|
|
LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open."
|
|
|
|
if [[ ${cert_exists} -eq 0 ]]; then
|
|
# issue the certificate
|
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
|
~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Issuing certificate failed, please check logs."
|
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
|
exit 1
|
|
else
|
|
LOGE "Issuing certificate succeeded, installing certificates..."
|
|
fi
|
|
else
|
|
LOGI "Using existing certificate, installing certificates..."
|
|
fi
|
|
|
|
reloadCmd="x-ui restart"
|
|
|
|
LOGI "Default --reloadcmd for ACME is: ${yellow}x-ui restart"
|
|
LOGI "This command will run on every certificate issue and renew."
|
|
read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd
|
|
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
|
|
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; x-ui restart"
|
|
echo -e "${green}\t2.${plain} Input your own command"
|
|
echo -e "${green}\t0.${plain} Keep default reloadcmd"
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
1)
|
|
LOGI "Reloadcmd is: systemctl reload nginx ; x-ui restart"
|
|
reloadCmd="systemctl reload nginx ; x-ui restart"
|
|
;;
|
|
2)
|
|
LOGD "It's recommended to put x-ui restart at the end, so it won't raise an error if other services fails"
|
|
read -rp "Please enter your reloadcmd (example: systemctl reload nginx ; x-ui restart): " reloadCmd
|
|
LOGI "Your reloadcmd is: ${reloadCmd}"
|
|
;;
|
|
*)
|
|
LOGI "Keep default reloadcmd"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# install the certificate
|
|
local installOutput=""
|
|
installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
|
|
--key-file /root/cert/${domain}/privkey.pem \
|
|
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
|
local installRc=$?
|
|
echo "${installOutput}"
|
|
|
|
local installWroteFiles=0
|
|
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
|
installWroteFiles=1
|
|
fi
|
|
|
|
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && (${installRc} -eq 0 || ${installWroteFiles} -eq 1) ]]; then
|
|
LOGI "Installing certificate succeeded, enabling auto renew..."
|
|
else
|
|
LOGE "Installing certificate failed, exiting."
|
|
if [[ ${cert_exists} -eq 0 ]]; then
|
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
# enable auto-renew
|
|
~/.acme.sh/acme.sh --upgrade --auto-upgrade
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Auto renew failed, certificate details:"
|
|
ls -lah cert/*
|
|
chmod 600 $certPath/privkey.pem
|
|
chmod 644 $certPath/fullchain.pem
|
|
exit 1
|
|
else
|
|
LOGI "Auto renew succeeded, certificate details:"
|
|
ls -lah cert/*
|
|
chmod 600 $certPath/privkey.pem
|
|
chmod 644 $certPath/fullchain.pem
|
|
fi
|
|
|
|
# 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 [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
|
|
local webCertFile="/root/cert/${domain}/fullchain.pem"
|
|
local webKeyFile="/root/cert/${domain}/privkey.pem"
|
|
|
|
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
|
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
|
LOGI "Panel paths set for domain: $domain"
|
|
LOGI " - Certificate File: $webCertFile"
|
|
LOGI " - Private Key File: $webKeyFile"
|
|
echo -e "${green}Access URL: https://${domain}:${existing_port}${existing_webBasePath}${plain}"
|
|
restart
|
|
else
|
|
LOGE "Error: Certificate or private key file not found for domain: $domain."
|
|
fi
|
|
else
|
|
LOGI "Skipping panel path setting."
|
|
fi
|
|
}
|
|
|
|
ssl_cert_issue_CF() {
|
|
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
|
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
|
LOGI "****** Instructions for Use ******"
|
|
LOGI "Follow the steps below to complete the process:"
|
|
LOGI "1. A Cloudflare API Token (recommended, scoped to Zone:DNS:Edit) or the Global API Key + registered email."
|
|
LOGI "2. The Domain Name."
|
|
LOGI "3. Once the certificate is issued, you will be prompted to set the certificate for the panel (optional)."
|
|
LOGI "4. The script also supports automatic renewal of the SSL certificate after installation."
|
|
|
|
confirm "Do you confirm the information and wish to proceed? [y/n]" "y"
|
|
|
|
if [ $? -eq 0 ]; then
|
|
# Check for acme.sh first
|
|
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
|
|
echo "acme.sh could not be found. We will install it."
|
|
install_acme
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Install acme failed, please check logs."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
CF_Domain=""
|
|
|
|
LOGD "Please set a domain name:"
|
|
read -rp "Input your domain here: " CF_Domain
|
|
LOGD "Your domain name is set to: ${CF_Domain}"
|
|
|
|
# Cloudflare API credentials: an API Token (recommended, scoped to a
|
|
# single zone) or the account-wide Global API Key. acme.sh reads
|
|
# CF_Token for tokens, or CF_Key + CF_Email for the Global Key.
|
|
CF_KeyType=""
|
|
read -rp "Are you using a Cloudflare API Token or Global API Key? (t/g) [Default t]: " CF_KeyType
|
|
CF_KeyType=${CF_KeyType:-t}
|
|
|
|
if [[ "$CF_KeyType" == "g" || "$CF_KeyType" == "G" ]]; then
|
|
CF_GlobalKey=""
|
|
CF_AccountEmail=""
|
|
LOGD "Please set the Global API Key:"
|
|
read -rp "Input your key here: " CF_GlobalKey
|
|
LOGD "Please set up the registered email:"
|
|
read -rp "Input your email here: " CF_AccountEmail
|
|
export CF_Key="${CF_GlobalKey}"
|
|
export CF_Email="${CF_AccountEmail}"
|
|
else
|
|
CF_ApiToken=""
|
|
LOGD "Please set the API Token:"
|
|
read -rp "Input your token here: " CF_ApiToken
|
|
export CF_Token="${CF_ApiToken}"
|
|
fi
|
|
|
|
# Set the default CA to Let's Encrypt
|
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Default CA, Let'sEncrypt fail, script exiting..."
|
|
exit 1
|
|
fi
|
|
|
|
# Issue the certificate using Cloudflare DNS
|
|
~/.acme.sh/acme.sh --issue --dns dns_cf -d ${CF_Domain} -d *.${CF_Domain} --log --force
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Certificate issuance failed, script exiting..."
|
|
exit 1
|
|
else
|
|
LOGI "Certificate issued successfully, Installing..."
|
|
fi
|
|
|
|
# Install the certificate
|
|
certPath="/root/cert/${CF_Domain}"
|
|
if [ -d "$certPath" ]; then
|
|
rm -rf ${certPath}
|
|
fi
|
|
|
|
mkdir -p ${certPath}
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Failed to create directory: ${certPath}"
|
|
exit 1
|
|
fi
|
|
|
|
reloadCmd="x-ui restart"
|
|
|
|
LOGI "Default --reloadcmd for ACME is: ${yellow}x-ui restart"
|
|
LOGI "This command will run on every certificate issue and renew."
|
|
read -rp "Would you like to modify --reloadcmd for ACME? (y/n): " setReloadcmd
|
|
if [[ "$setReloadcmd" == "y" || "$setReloadcmd" == "Y" ]]; then
|
|
echo -e "\n${green}\t1.${plain} Preset: systemctl reload nginx ; x-ui restart"
|
|
echo -e "${green}\t2.${plain} Input your own command"
|
|
echo -e "${green}\t0.${plain} Keep default reloadcmd"
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
1)
|
|
LOGI "Reloadcmd is: systemctl reload nginx ; x-ui restart"
|
|
reloadCmd="systemctl reload nginx ; x-ui restart"
|
|
;;
|
|
2)
|
|
LOGD "It's recommended to put x-ui restart at the end, so it won't raise an error if other services fails"
|
|
read -rp "Please enter your reloadcmd (example: systemctl reload nginx ; x-ui restart): " reloadCmd
|
|
LOGI "Your reloadcmd is: ${reloadCmd}"
|
|
;;
|
|
*)
|
|
LOGI "Keep default reloadcmd"
|
|
;;
|
|
esac
|
|
fi
|
|
~/.acme.sh/acme.sh --installcert --force -d ${CF_Domain} -d *.${CF_Domain} \
|
|
--key-file ${certPath}/privkey.pem \
|
|
--fullchain-file ${certPath}/fullchain.pem --reloadcmd "${reloadCmd}"
|
|
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Certificate installation failed, script exiting..."
|
|
exit 1
|
|
else
|
|
LOGI "Certificate installed successfully, Turning on automatic updates..."
|
|
fi
|
|
|
|
# Enable auto-update
|
|
~/.acme.sh/acme.sh --upgrade --auto-upgrade
|
|
if [ $? -ne 0 ]; then
|
|
LOGE "Auto update setup failed, script exiting..."
|
|
exit 1
|
|
else
|
|
LOGI "The certificate is installed and auto-renewal is turned on. Specific information is as follows:"
|
|
ls -lah ${certPath}/*
|
|
chmod 600 ${certPath}/privkey.pem
|
|
chmod 644 ${certPath}/fullchain.pem
|
|
fi
|
|
|
|
# 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 [[ "$setPanel" == "y" || "$setPanel" == "Y" ]]; then
|
|
local webCertFile="${certPath}/fullchain.pem"
|
|
local webKeyFile="${certPath}/privkey.pem"
|
|
|
|
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
|
|
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
|
|
LOGI "Panel paths set for domain: $CF_Domain"
|
|
LOGI " - Certificate File: $webCertFile"
|
|
LOGI " - Private Key File: $webKeyFile"
|
|
echo -e "${green}Access URL: https://${CF_Domain}:${existing_port}${existing_webBasePath}${plain}"
|
|
restart
|
|
else
|
|
LOGE "Error: Certificate or private key file not found for domain: $CF_Domain."
|
|
fi
|
|
else
|
|
LOGI "Skipping panel path setting."
|
|
fi
|
|
else
|
|
show_menu
|
|
fi
|
|
}
|
|
|
|
run_speedtest() {
|
|
# Check if Speedtest is already installed
|
|
if ! command -v speedtest &> /dev/null; then
|
|
# If not installed, determine installation method
|
|
if command -v snap &> /dev/null; then
|
|
# Use snap to install Speedtest
|
|
echo "Installing Speedtest using snap..."
|
|
snap install speedtest
|
|
else
|
|
# Fallback to using package managers
|
|
local pkg_manager=""
|
|
local speedtest_install_script=""
|
|
|
|
if command -v dnf &> /dev/null; then
|
|
pkg_manager="dnf"
|
|
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.rpm.sh"
|
|
elif command -v yum &> /dev/null; then
|
|
pkg_manager="yum"
|
|
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.rpm.sh"
|
|
elif command -v apt-get &> /dev/null; then
|
|
pkg_manager="apt-get"
|
|
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh"
|
|
elif command -v apt &> /dev/null; then
|
|
pkg_manager="apt"
|
|
speedtest_install_script="https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh"
|
|
fi
|
|
|
|
if [[ -z $pkg_manager ]]; then
|
|
echo "Error: Package manager not found. You may need to install Speedtest manually."
|
|
return 1
|
|
else
|
|
echo "Installing Speedtest using $pkg_manager..."
|
|
curl -s $speedtest_install_script | bash
|
|
$pkg_manager install -y speedtest
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
speedtest
|
|
}
|
|
|
|
ip_validation() {
|
|
ipv6_regex="^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$"
|
|
ipv4_regex="^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)$"
|
|
}
|
|
|
|
iplimit_main() {
|
|
echo -e "\n${green}\t1.${plain} Install Fail2ban and configure IP Limit"
|
|
echo -e "${green}\t2.${plain} Change Ban Duration"
|
|
echo -e "${green}\t3.${plain} Unban Everyone"
|
|
echo -e "${green}\t4.${plain} Ban Logs"
|
|
echo -e "${green}\t5.${plain} Ban an IP Address"
|
|
echo -e "${green}\t6.${plain} Unban an IP Address"
|
|
echo -e "${green}\t7.${plain} Real-Time Logs"
|
|
echo -e "${green}\t8.${plain} Service Status"
|
|
echo -e "${green}\t9.${plain} Service Restart"
|
|
echo -e "${green}\t10.${plain} Uninstall Fail2ban and IP Limit"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
confirm "Proceed with installation of Fail2ban & IP Limit?" "y"
|
|
if [[ $? == 0 ]]; then
|
|
install_iplimit
|
|
else
|
|
iplimit_main
|
|
fi
|
|
;;
|
|
2)
|
|
read -rp "Please enter new Ban Duration in Minutes [default 30]: " NUM
|
|
if [[ $NUM =~ ^[0-9]+$ ]]; then
|
|
create_iplimit_jails ${NUM}
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service fail2ban restart
|
|
else
|
|
systemctl restart fail2ban
|
|
fi
|
|
else
|
|
echo -e "${red}${NUM} is not a number! Please, try again.${plain}"
|
|
fi
|
|
iplimit_main
|
|
;;
|
|
3)
|
|
confirm "Proceed with Unbanning everyone from IP Limit jail?" "y"
|
|
if [[ $? == 0 ]]; then
|
|
fail2ban-client reload --restart --unban 3x-ipl
|
|
truncate -s 0 "${iplimit_banned_log_path}"
|
|
echo -e "${green}All users Unbanned successfully.${plain}"
|
|
iplimit_main
|
|
else
|
|
echo -e "${yellow}Cancelled.${plain}"
|
|
fi
|
|
iplimit_main
|
|
;;
|
|
4)
|
|
show_banlog
|
|
iplimit_main
|
|
;;
|
|
5)
|
|
read -rp "Enter the IP address you want to ban: " ban_ip
|
|
ip_validation
|
|
if [[ $ban_ip =~ $ipv4_regex || $ban_ip =~ $ipv6_regex ]]; then
|
|
fail2ban-client set 3x-ipl banip "$ban_ip"
|
|
echo -e "${green}IP Address ${ban_ip} has been banned successfully.${plain}"
|
|
else
|
|
echo -e "${red}Invalid IP address format! Please try again.${plain}"
|
|
fi
|
|
iplimit_main
|
|
;;
|
|
6)
|
|
read -rp "Enter the IP address you want to unban: " unban_ip
|
|
ip_validation
|
|
if [[ $unban_ip =~ $ipv4_regex || $unban_ip =~ $ipv6_regex ]]; then
|
|
fail2ban-client set 3x-ipl unbanip "$unban_ip"
|
|
echo -e "${green}IP Address ${unban_ip} has been unbanned successfully.${plain}"
|
|
else
|
|
echo -e "${red}Invalid IP address format! Please try again.${plain}"
|
|
fi
|
|
iplimit_main
|
|
;;
|
|
7)
|
|
tail -f /var/log/fail2ban.log
|
|
iplimit_main
|
|
;;
|
|
8)
|
|
service fail2ban status
|
|
iplimit_main
|
|
;;
|
|
9)
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service fail2ban restart
|
|
else
|
|
systemctl restart fail2ban
|
|
fi
|
|
iplimit_main
|
|
;;
|
|
10)
|
|
remove_iplimit
|
|
iplimit_main
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
iplimit_main
|
|
;;
|
|
esac
|
|
}
|
|
|
|
setup_fail2ban_iplimit() {
|
|
# Honor the same toggle the panel uses (isFail2BanEnabled): enabled when the
|
|
# var is unset or exactly "true"; any other explicit value means the operator
|
|
# opted out, so do nothing rather than install a fail2ban the panel ignores.
|
|
if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
|
|
echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban setup.${plain}\n"
|
|
return 0
|
|
fi
|
|
|
|
if ! command -v fail2ban-client &> /dev/null; then
|
|
echo -e "${green}Fail2ban is not installed. Installing now...!${plain}\n"
|
|
|
|
# Install fail2ban together with nftables. Recent fail2ban packages
|
|
# default to `banaction = nftables-multiport` in /etc/fail2ban/jail.conf,
|
|
# but the `nftables` package isn't pulled in as a dependency on most
|
|
# minimal server images (Debian 12+, Ubuntu 24+, fresh RHEL-family).
|
|
# Without `nft` in PATH the default sshd jail fails to ban with
|
|
# stderr: '/bin/sh: 1: nft: not found'
|
|
# even though our own 3x-ipl jail uses iptables. Bundling the binary
|
|
# at install time prevents that confusing log spam for new installs.
|
|
case "${release}" in
|
|
ubuntu)
|
|
apt-get update
|
|
if [[ "${os_version}" -ge 2400 ]]; then
|
|
apt-get install python3-pip -y
|
|
python3 -m pip install pyasynchat --break-system-packages
|
|
fi
|
|
apt-get install fail2ban nftables -y
|
|
;;
|
|
debian)
|
|
apt-get update
|
|
if [ "$os_version" -ge 12 ]; then
|
|
apt-get install -y python3-systemd
|
|
fi
|
|
apt-get install -y fail2ban nftables
|
|
;;
|
|
armbian)
|
|
apt-get update && apt-get install fail2ban nftables -y
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf -y update && dnf -y install fail2ban nftables
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum update -y && yum install epel-release -y
|
|
yum -y install fail2ban nftables
|
|
else
|
|
dnf -y update && dnf -y install fail2ban nftables
|
|
fi
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Syu --noconfirm fail2ban nftables
|
|
;;
|
|
alpine)
|
|
apk add fail2ban nftables
|
|
;;
|
|
*)
|
|
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
if ! command -v fail2ban-client &> /dev/null; then
|
|
echo -e "${red}Fail2ban installation failed.${plain}\n"
|
|
return 1
|
|
fi
|
|
|
|
echo -e "${green}Fail2ban installed successfully!${plain}\n"
|
|
else
|
|
echo -e "${yellow}Fail2ban is already installed.${plain}\n"
|
|
fi
|
|
|
|
echo -e "${green}Configuring IP Limit...${plain}\n"
|
|
|
|
# make sure there's no conflict for jail files
|
|
iplimit_remove_conflicts
|
|
|
|
# Check if log file exists
|
|
if ! test -f "${iplimit_banned_log_path}"; then
|
|
touch ${iplimit_banned_log_path}
|
|
fi
|
|
|
|
# Check if service log file exists so fail2ban won't return error
|
|
if ! test -f "${iplimit_log_path}"; then
|
|
touch ${iplimit_log_path}
|
|
fi
|
|
|
|
# Create the iplimit jail files
|
|
# we didn't pass the bantime here to use the default value
|
|
create_iplimit_jails
|
|
|
|
# Launching fail2ban
|
|
if [[ $release == "alpine" ]]; then
|
|
if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
|
|
rc-service fail2ban start
|
|
else
|
|
rc-service fail2ban restart
|
|
fi
|
|
rc-update add fail2ban
|
|
else
|
|
if ! systemctl is-active --quiet fail2ban; then
|
|
systemctl start fail2ban
|
|
else
|
|
systemctl restart fail2ban
|
|
fi
|
|
systemctl enable fail2ban
|
|
fi
|
|
|
|
echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
|
|
return 0
|
|
}
|
|
|
|
# install_iplimit is the interactive (menu) entry point: it runs the shared
|
|
# setup and then returns to the menu. The non-interactive installer path uses
|
|
# setup_fail2ban_iplimit directly via `x-ui setup-fail2ban`.
|
|
install_iplimit() {
|
|
setup_fail2ban_iplimit
|
|
before_show_menu
|
|
}
|
|
|
|
remove_iplimit() {
|
|
echo -e "${green}\t1.${plain} Only remove IP Limit configurations"
|
|
echo -e "${green}\t2.${plain} Uninstall Fail2ban and IP Limit"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " num
|
|
case "$num" in
|
|
1)
|
|
rm -f /etc/fail2ban/filter.d/3x-ipl.conf
|
|
rm -f /etc/fail2ban/action.d/3x-ipl.conf
|
|
rm -f /etc/fail2ban/jail.d/3x-ipl.conf
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service fail2ban restart
|
|
else
|
|
systemctl restart fail2ban
|
|
fi
|
|
echo -e "${green}IP Limit removed successfully!${plain}\n"
|
|
before_show_menu
|
|
;;
|
|
2)
|
|
rm -rf /etc/fail2ban
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service fail2ban stop
|
|
else
|
|
systemctl stop fail2ban
|
|
fi
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
apt-get remove -y fail2ban
|
|
apt-get purge -y fail2ban -y
|
|
apt-get autoremove -y
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf remove fail2ban -y
|
|
dnf autoremove -y
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum remove fail2ban -y
|
|
yum autoremove -y
|
|
else
|
|
dnf remove fail2ban -y
|
|
dnf autoremove -y
|
|
fi
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Rns --noconfirm fail2ban
|
|
;;
|
|
alpine)
|
|
apk del fail2ban
|
|
;;
|
|
*)
|
|
echo -e "${red}Unsupported operating system. Please uninstall Fail2ban manually.${plain}\n"
|
|
exit 1
|
|
;;
|
|
esac
|
|
echo -e "${green}Fail2ban and IP Limit removed successfully!${plain}\n"
|
|
before_show_menu
|
|
;;
|
|
0)
|
|
show_menu
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
remove_iplimit
|
|
;;
|
|
esac
|
|
}
|
|
|
|
show_banlog() {
|
|
local system_log="/var/log/fail2ban.log"
|
|
|
|
echo -e "${green}Checking ban logs...${plain}\n"
|
|
|
|
if [[ $release == "alpine" ]]; then
|
|
if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
|
|
echo -e "${red}Fail2ban service is not running!${plain}\n"
|
|
return 1
|
|
fi
|
|
else
|
|
if ! systemctl is-active --quiet fail2ban; then
|
|
echo -e "${red}Fail2ban service is not running!${plain}\n"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
if [[ -f "$system_log" ]]; then
|
|
echo -e "${green}Recent system ban activities from fail2ban.log:${plain}"
|
|
grep "3x-ipl" "$system_log" | grep -E "Ban|Unban" | tail -n 10 || echo -e "${yellow}No recent system ban activities found${plain}"
|
|
echo ""
|
|
fi
|
|
|
|
if [[ -f "${iplimit_banned_log_path}" ]]; then
|
|
echo -e "${green}3X-IPL ban log entries:${plain}"
|
|
if [[ -s "${iplimit_banned_log_path}" ]]; then
|
|
grep -v "INIT" "${iplimit_banned_log_path}" | tail -n 10 || echo -e "${yellow}No ban entries found${plain}"
|
|
else
|
|
echo -e "${yellow}Ban log file is empty${plain}"
|
|
fi
|
|
else
|
|
echo -e "${red}Ban log file not found at: ${iplimit_banned_log_path}${plain}"
|
|
fi
|
|
|
|
echo -e "\n${green}Current jail status:${plain}"
|
|
fail2ban-client status 3x-ipl || echo -e "${yellow}Unable to get jail status${plain}"
|
|
}
|
|
|
|
create_iplimit_jails() {
|
|
# Use default bantime if not passed => 30 minutes
|
|
local bantime="${1:-30}"
|
|
|
|
# Uncomment 'allowipv6 = auto' in fail2ban.conf
|
|
sed -i 's/#allowipv6 = auto/allowipv6 = auto/g' /etc/fail2ban/fail2ban.conf
|
|
|
|
# On Debian 12+ and Ubuntu 22.04+ fail2ban's default backend should be changed to systemd
|
|
if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]]; then
|
|
sed -i '0,/action =/s/backend = auto/backend = systemd/' /etc/fail2ban/jail.conf
|
|
fi
|
|
|
|
cat << EOF > /etc/fail2ban/jail.d/3x-ipl.conf
|
|
[3x-ipl]
|
|
enabled=true
|
|
backend=auto
|
|
filter=3x-ipl
|
|
action=3x-ipl
|
|
logpath=${iplimit_log_path}
|
|
maxretry=1
|
|
findtime=32
|
|
bantime=${bantime}m
|
|
EOF
|
|
|
|
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
|
[Definition]
|
|
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
|
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
|
ignoreregex =
|
|
EOF
|
|
|
|
# Ports to exempt from the ban so an over-limit proxy client can never lock
|
|
# the administrator out of SSH or the panel. The ban still covers every other
|
|
# TCP and UDP port (including all Xray inbounds, e.g. UDP-based Hysteria2), so
|
|
# IP-limit keeps working for inbounds added later without regenerating these files.
|
|
local ssh_ports
|
|
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
|
|
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
|
|
local panel_port
|
|
panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
|
|
local exempt_ports="${ssh_ports}"
|
|
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
|
|
|
|
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
|
|
[INCLUDES]
|
|
before = iptables-allports.conf
|
|
|
|
[Definition]
|
|
actionstart = <iptables> -N f2b-<name>
|
|
<iptables> -A f2b-<name> -j <returntype>
|
|
<iptables> -I <chain> -j f2b-<name>
|
|
|
|
actionstop = <iptables> -D <chain> -j f2b-<name>
|
|
<actionflush>
|
|
<iptables> -X f2b-<name>
|
|
|
|
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
|
|
|
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
|
|
<iptables> -I f2b-<name> 1 -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
|
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
|
|
|
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
|
|
<iptables> -D f2b-<name> -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
|
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
|
|
|
[Init]
|
|
name = default
|
|
chain = INPUT
|
|
exemptports = ${exempt_ports}
|
|
EOF
|
|
|
|
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
|
|
}
|
|
|
|
iplimit_remove_conflicts() {
|
|
local jail_files=(
|
|
/etc/fail2ban/jail.conf
|
|
/etc/fail2ban/jail.local
|
|
)
|
|
|
|
for file in "${jail_files[@]}"; do
|
|
# Check for [3x-ipl] config in jail file then remove it
|
|
if test -f "${file}" && grep -qw '3x-ipl' ${file}; then
|
|
sed -i "/\[3x-ipl\]/,/^$/d" ${file}
|
|
echo -e "${yellow}Removing conflicts of [3x-ipl] in jail (${file})!${plain}\n"
|
|
fi
|
|
done
|
|
}
|
|
|
|
SSH_port_forwarding() {
|
|
local URL_lists=(
|
|
"https://api4.ipify.org"
|
|
"https://ipv4.icanhazip.com"
|
|
"https://v4.api.ipinfo.io/ip"
|
|
"https://ipv4.myexternalip.com/raw"
|
|
"https://4.ident.me"
|
|
"https://check-host.net/ip"
|
|
)
|
|
local server_ip=""
|
|
for ip_address in "${URL_lists[@]}"; do
|
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
|
|
local http_code=$(echo "$response" | tail -n1)
|
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
|
|
if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
server_ip="${ip_result}"
|
|
break
|
|
fi
|
|
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
|
|
fi
|
|
|
|
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
|
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
|
local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')
|
|
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'cert: .+' | awk '{print $2}')
|
|
local existing_key=$(${xui_folder}/x-ui setting -getCert true | grep -Eo 'key: .+' | awk '{print $2}')
|
|
|
|
local config_listenIP=""
|
|
local listen_choice=""
|
|
|
|
if [[ -n "$existing_cert" && -n "$existing_key" ]]; then
|
|
echo -e "${green}Panel is secure with SSL.${plain}"
|
|
before_show_menu
|
|
fi
|
|
if [[ -z "$existing_cert" && -z "$existing_key" && (-z "$existing_listenIP" || "$existing_listenIP" == "0.0.0.0") ]]; then
|
|
echo -e "\n${red}Warning: No Cert and Key found! The panel is not secure.${plain}"
|
|
echo "Please obtain a certificate or set up SSH port forwarding."
|
|
fi
|
|
|
|
if [[ -n "$existing_listenIP" && "$existing_listenIP" != "0.0.0.0" && (-z "$existing_cert" && -z "$existing_key") ]]; then
|
|
echo -e "\n${green}Current SSH Port Forwarding Configuration:${plain}"
|
|
echo -e "Standard SSH command:"
|
|
echo -e "${yellow}ssh -L 2222:${existing_listenIP}:${existing_port} root@${server_ip}${plain}"
|
|
echo -e "\nIf using SSH key:"
|
|
echo -e "${yellow}ssh -i <sshkeypath> -L 2222:${existing_listenIP}:${existing_port} root@${server_ip}${plain}"
|
|
echo -e "\nAfter connecting, access the panel at:"
|
|
echo -e "${yellow}http://localhost:2222${existing_webBasePath}${plain}"
|
|
fi
|
|
|
|
echo -e "\nChoose an option:"
|
|
echo -e "${green}1.${plain} Set listen IP"
|
|
echo -e "${green}2.${plain} Clear listen IP"
|
|
echo -e "${green}0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " num
|
|
|
|
case "$num" in
|
|
1)
|
|
if [[ -z "$existing_listenIP" || "$existing_listenIP" == "0.0.0.0" ]]; then
|
|
echo -e "\nNo listenIP configured. Choose an option:"
|
|
echo -e "1. Use default IP (127.0.0.1)"
|
|
echo -e "2. Set a custom IP"
|
|
read -rp "Select an option (1 or 2): " listen_choice
|
|
|
|
config_listenIP="127.0.0.1"
|
|
[[ "$listen_choice" == "2" ]] && read -rp "Enter custom IP to listen on: " config_listenIP
|
|
|
|
${xui_folder}/x-ui setting -listenIP "${config_listenIP}" > /dev/null 2>&1
|
|
echo -e "${green}listen IP has been set to ${config_listenIP}.${plain}"
|
|
echo -e "\n${green}SSH Port Forwarding Configuration:${plain}"
|
|
echo -e "Standard SSH command:"
|
|
echo -e "${yellow}ssh -L 2222:${config_listenIP}:${existing_port} root@${server_ip}${plain}"
|
|
echo -e "\nIf using SSH key:"
|
|
echo -e "${yellow}ssh -i <sshkeypath> -L 2222:${config_listenIP}:${existing_port} root@${server_ip}${plain}"
|
|
echo -e "\nAfter connecting, access the panel at:"
|
|
echo -e "${yellow}http://localhost:2222${existing_webBasePath}${plain}"
|
|
restart
|
|
else
|
|
config_listenIP="${existing_listenIP}"
|
|
echo -e "${green}Current listen IP is already set to ${config_listenIP}.${plain}"
|
|
fi
|
|
;;
|
|
2)
|
|
${xui_folder}/x-ui setting -listenIP 0.0.0.0 > /dev/null 2>&1
|
|
echo -e "${green}Listen IP has been cleared.${plain}"
|
|
restart
|
|
;;
|
|
0)
|
|
show_menu
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
SSH_port_forwarding
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# PostgreSQL service management (for panels configured with XUI_DB_TYPE=postgres).
|
|
|
|
postgresql_installed() {
|
|
command -v pg_lsclusters > /dev/null 2>&1 || command -v psql > /dev/null 2>&1 || command -v postgres > /dev/null 2>&1
|
|
}
|
|
|
|
# Prints "VER CLUSTER" of the first configured cluster on Debian-style installs (e.g. "16 main").
|
|
pg_cluster_info() {
|
|
if command -v pg_lsclusters > /dev/null 2>&1; then
|
|
pg_lsclusters 2> /dev/null | awk '$1 ~ /^[0-9]+$/ {print $1, $2; exit}'
|
|
fi
|
|
}
|
|
|
|
# Resolves the systemd unit used to manage the PostgreSQL server.
|
|
pg_systemd_unit() {
|
|
local info ver cluster
|
|
info="$(pg_cluster_info)"
|
|
if [[ -n "$info" ]]; then
|
|
ver="${info%% *}"
|
|
cluster="${info##* }"
|
|
echo "postgresql@${ver}-${cluster}"
|
|
else
|
|
echo "postgresql"
|
|
fi
|
|
}
|
|
|
|
postgresql_status() {
|
|
if ! postgresql_installed; then
|
|
LOGE "PostgreSQL does not appear to be installed on this system."
|
|
return 1
|
|
fi
|
|
if command -v pg_lsclusters > /dev/null 2>&1; then
|
|
pg_lsclusters
|
|
else
|
|
systemctl status "$(pg_systemd_unit)" --no-pager
|
|
fi
|
|
echo ""
|
|
if command -v ss > /dev/null 2>&1; then
|
|
local listening
|
|
listening=$(ss -ltnp 2> /dev/null | grep ':5432')
|
|
if [[ -n "$listening" ]]; then
|
|
echo -e "${green}PostgreSQL is listening on port 5432:${plain}"
|
|
echo "$listening"
|
|
else
|
|
echo -e "${red}Nothing is listening on port 5432 - the database is not running.${plain}"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
postgresql_start() {
|
|
pg_require_installed || return 1
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service postgresql start
|
|
else
|
|
systemctl start "$(pg_systemd_unit)"
|
|
fi
|
|
sleep 1
|
|
postgresql_status
|
|
}
|
|
|
|
postgresql_stop() {
|
|
pg_require_installed || return 1
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service postgresql stop
|
|
else
|
|
systemctl stop "$(pg_systemd_unit)"
|
|
fi
|
|
LOGI "PostgreSQL stop signal sent."
|
|
}
|
|
|
|
postgresql_restart() {
|
|
pg_require_installed || return 1
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service postgresql restart
|
|
else
|
|
systemctl restart "$(pg_systemd_unit)"
|
|
fi
|
|
sleep 1
|
|
postgresql_status
|
|
}
|
|
|
|
postgresql_enable() {
|
|
pg_require_installed || return 1
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-update add postgresql default
|
|
else
|
|
systemctl enable "$(pg_systemd_unit)"
|
|
fi
|
|
if [[ $? == 0 ]]; then
|
|
LOGI "PostgreSQL set to start automatically on boot."
|
|
else
|
|
LOGE "Failed to enable PostgreSQL autostart."
|
|
fi
|
|
}
|
|
|
|
postgresql_log() {
|
|
pg_require_installed || return 1
|
|
local info ver cluster logfile
|
|
info="$(pg_cluster_info)"
|
|
if [[ -n "$info" ]]; then
|
|
ver="${info%% *}"
|
|
cluster="${info##* }"
|
|
logfile="/var/log/postgresql/postgresql-${ver}-${cluster}.log"
|
|
fi
|
|
if [[ -n "$logfile" && -f "$logfile" ]]; then
|
|
tail -n 40 "$logfile"
|
|
elif command -v journalctl > /dev/null 2>&1; then
|
|
journalctl -u "$(pg_systemd_unit)" -n 40 --no-pager
|
|
else
|
|
LOGE "No PostgreSQL log found."
|
|
fi
|
|
}
|
|
|
|
pg_require_installed() {
|
|
if ! postgresql_installed; then
|
|
LOGE "PostgreSQL is not installed. Use option 1 (Install PostgreSQL) in this menu first."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Completely removes the PostgreSQL server and ALL of its databases from the system.
|
|
# Gated behind an explicit confirmation because this is system-wide and irreversible:
|
|
# any other application sharing this PostgreSQL instance loses its data too. Mirrors the
|
|
# package names used by pg_install_local() so the right packages are removed per distro.
|
|
purge_postgresql() {
|
|
echo ""
|
|
echo -e "${yellow}This panel was using PostgreSQL.${plain}"
|
|
echo -e "${red}WARNING:${plain} purging removes the PostgreSQL server and ${red}ALL${plain} of its databases on"
|
|
echo -e "this machine, including any used by other applications. This cannot be undone."
|
|
confirm "Also purge PostgreSQL and delete all of its data?" "n"
|
|
if [[ $? != 0 ]]; then
|
|
LOGI "Left PostgreSQL installed; its data was not removed."
|
|
return 0
|
|
fi
|
|
|
|
if [[ $release == "alpine" ]]; then
|
|
rc-service postgresql stop 2> /dev/null
|
|
rc-update del postgresql 2> /dev/null
|
|
else
|
|
systemctl stop "$(pg_systemd_unit)" 2> /dev/null
|
|
systemctl disable "$(pg_systemd_unit)" 2> /dev/null
|
|
fi
|
|
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
apt-get -y --purge remove 'postgresql*'
|
|
apt-get -y autoremove --purge
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf remove -y postgresql postgresql-server postgresql-contrib
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum remove -y postgresql postgresql-server postgresql-contrib
|
|
else
|
|
dnf remove -y postgresql postgresql-server postgresql-contrib
|
|
fi
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Rns --noconfirm postgresql
|
|
;;
|
|
opensuse-tumbleweed | opensuse-leap)
|
|
zypper -q remove -y postgresql postgresql-server postgresql-contrib
|
|
;;
|
|
alpine)
|
|
apk del postgresql postgresql-contrib postgresql-client
|
|
;;
|
|
*)
|
|
LOGE "Unsupported distro for automatic PostgreSQL purge: ${release}. Remove it manually."
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
rm -rf /var/lib/postgresql /var/lib/pgsql /var/lib/postgres /etc/postgresql
|
|
LOGI "PostgreSQL has been purged."
|
|
}
|
|
|
|
# Installs a local PostgreSQL server and creates a dedicated xui user/database.
|
|
# Progress goes to stderr; on success the connection DSN is printed to stdout so
|
|
# callers can capture it. Mirrors install_postgres_local() from install.sh, so the
|
|
# panel can be set up without re-running the remote install script.
|
|
pg_install_local() {
|
|
local pg_user pg_pass pg_db pg_host pg_port
|
|
pg_pass=$(gen_random_string 24)
|
|
pg_db="xui"
|
|
pg_host="127.0.0.1"
|
|
pg_port="5432"
|
|
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
apt-get update >&2 && apt-get install -y -q postgresql >&2 || return 1
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
|
|
[[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum install -y postgresql-server postgresql-contrib >&2 || return 1
|
|
else
|
|
dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
|
|
fi
|
|
[[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Syu --noconfirm postgresql >&2 || return 1
|
|
if [[ ! -f /var/lib/postgres/data/PG_VERSION ]]; then
|
|
sudo -u postgres initdb -D /var/lib/postgres/data >&2 || return 1
|
|
fi
|
|
;;
|
|
opensuse-tumbleweed | opensuse-leap)
|
|
zypper -q install -y postgresql-server postgresql-contrib >&2 || return 1
|
|
if [[ ! -f /var/lib/pgsql/data/PG_VERSION ]]; then
|
|
install -d -o postgres -g postgres -m 700 /var/lib/pgsql/data >&2 || return 1
|
|
su - postgres -c "initdb -D /var/lib/pgsql/data" >&2 || return 1
|
|
fi
|
|
;;
|
|
alpine)
|
|
apk add --no-cache postgresql postgresql-contrib >&2 || return 1
|
|
if [[ ! -f /var/lib/postgresql/data/PG_VERSION ]]; then
|
|
/etc/init.d/postgresql setup >&2 || return 1
|
|
fi
|
|
rc-update add postgresql default >&2 2> /dev/null || true
|
|
rc-service postgresql start >&2 || return 1
|
|
;;
|
|
*)
|
|
echo -e "${red}Unsupported distro for automatic PostgreSQL install: ${release}${plain}" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
if [[ "${release}" != "alpine" ]]; then
|
|
systemctl enable --now postgresql >&2 || return 1
|
|
fi
|
|
|
|
local i
|
|
for i in 1 2 3 4 5; do
|
|
sudo -u postgres psql -tAc 'SELECT 1' > /dev/null 2>&1 && break
|
|
sleep 1
|
|
done
|
|
|
|
local existing_owner=""
|
|
existing_owner=$(sudo -u postgres psql -tAc \
|
|
"SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
|
|
| tr -d '[:space:]')
|
|
if [[ -n "${existing_owner}" && "${existing_owner}" != "postgres" ]]; then
|
|
pg_user="${existing_owner}"
|
|
else
|
|
pg_user=$(gen_random_string 8)
|
|
fi
|
|
|
|
sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${pg_user}'" 2> /dev/null \
|
|
| grep -q 1 \
|
|
|| sudo -u postgres psql -c "CREATE USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
|
|
|
|
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${pg_db}'" 2> /dev/null \
|
|
| grep -q 1 \
|
|
|| sudo -u postgres psql -c "CREATE DATABASE \"${pg_db}\" OWNER \"${pg_user}\";" >&2 || return 1
|
|
|
|
sudo -u postgres psql -c "ALTER USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
|
|
|
|
local pg_pass_enc
|
|
pg_pass_enc=$(printf '%s' "${pg_pass}" | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/@/%40/g' -e 's|/|%2F|g' -e 's/?/%3F/g' -e 's/#/%23/g')
|
|
|
|
echo "postgres://${pg_user}:${pg_pass_enc}@${pg_host}:${pg_port}/${pg_db}?sslmode=disable"
|
|
return 0
|
|
}
|
|
|
|
# Installs the PostgreSQL client tools (pg_dump/pg_restore) used by in-panel backup.
|
|
pg_ensure_client() {
|
|
if command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
echo -e "${yellow}Installing PostgreSQL client tools (pg_dump/pg_restore)...${plain}" >&2
|
|
case "${release}" in
|
|
ubuntu | debian | armbian)
|
|
apt-get update >&2 && apt-get install -y -q postgresql-client >&2 || return 1
|
|
;;
|
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
|
dnf install -y -q postgresql >&2 || return 1
|
|
;;
|
|
centos)
|
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
|
yum install -y postgresql >&2 || return 1
|
|
else
|
|
dnf install -y -q postgresql >&2 || return 1
|
|
fi
|
|
;;
|
|
arch | manjaro | parch)
|
|
pacman -Sy --noconfirm postgresql >&2 || return 1
|
|
;;
|
|
opensuse-tumbleweed | opensuse-leap)
|
|
zypper -q install -y postgresql >&2 || return 1
|
|
;;
|
|
alpine)
|
|
apk add --no-cache postgresql-client >&2 || return 1
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
command -v pg_dump > /dev/null 2>&1 && command -v pg_restore > /dev/null 2>&1
|
|
}
|
|
|
|
# Writes XUI_DB_TYPE/XUI_DB_DSN into the service env file, preserving other entries.
|
|
pg_write_env() {
|
|
local dsn="$1" envfile
|
|
envfile="$(xui_env_file_path)"
|
|
install -d -m 755 "$(dirname "$envfile")"
|
|
touch "$envfile"
|
|
sed -i '/^XUI_DB_TYPE=/d; /^XUI_DB_DSN=/d' "$envfile"
|
|
{
|
|
echo "XUI_DB_TYPE=postgres"
|
|
echo "XUI_DB_DSN=${dsn}"
|
|
} >> "$envfile"
|
|
chmod 600 "$envfile"
|
|
}
|
|
|
|
pg_install_server_action() {
|
|
if postgresql_installed; then
|
|
LOGI "PostgreSQL already appears to be installed on this system."
|
|
confirm "Run setup anyway (ensures the xui database/user exist)?" "n" || return 0
|
|
fi
|
|
LOGI "Installing PostgreSQL server and creating a dedicated user/database..."
|
|
local dsn
|
|
dsn=$(pg_install_local)
|
|
if [[ $? -ne 0 || -z "$dsn" ]]; then
|
|
LOGE "PostgreSQL installation failed."
|
|
return 1
|
|
fi
|
|
PG_LAST_DSN="$dsn"
|
|
pg_ensure_client || LOGE "Could not install pg_dump/pg_restore (panel DB backup may be unavailable)."
|
|
echo ""
|
|
LOGI "PostgreSQL is installed and ready."
|
|
echo -e "${green}Connection DSN:${plain} ${dsn}"
|
|
echo -e "${yellow}Use option 2 to migrate your SQLite data and switch the panel to PostgreSQL.${plain}"
|
|
}
|
|
|
|
# Copies the current SQLite data into PostgreSQL, then switches the panel over.
|
|
migrate_to_postgres() {
|
|
if [[ ! -x "${xui_folder}/x-ui" ]]; then
|
|
LOGE "x-ui is not installed."
|
|
return 1
|
|
fi
|
|
echo ""
|
|
echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
|
|
echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
|
|
echo -e "${red}Any existing panel tables in the destination will be cleared and overwritten.${plain}"
|
|
confirm "Continue?" "n" || return 0
|
|
|
|
local dsn="" pg_mode
|
|
if [[ -n "$PG_LAST_DSN" ]]; then
|
|
echo -e "A PostgreSQL database was created in this session:"
|
|
echo -e " ${green}${PG_LAST_DSN}${plain}"
|
|
confirm "Migrate into this database?" "y" && dsn="$PG_LAST_DSN"
|
|
fi
|
|
|
|
if [[ -z "$dsn" ]]; then
|
|
echo ""
|
|
echo -e "${green}\t1.${plain} Install PostgreSQL locally and create a dedicated user/db (recommended)"
|
|
echo -e "${green}\t2.${plain} Use an existing PostgreSQL server (enter DSN)"
|
|
read -rp "Choose [1]: " pg_mode
|
|
pg_mode="${pg_mode:-1}"
|
|
if [[ "$pg_mode" == "2" ]]; then
|
|
while [[ -z "$dsn" ]]; do
|
|
read -rp "Enter PostgreSQL DSN (postgres://user:pass@host:port/dbname?sslmode=disable): " dsn
|
|
dsn="${dsn// /}"
|
|
done
|
|
else
|
|
LOGI "Installing PostgreSQL locally (this may take a moment)..."
|
|
dsn=$(pg_install_local)
|
|
if [[ $? -ne 0 || -z "$dsn" ]]; then
|
|
LOGE "PostgreSQL installation failed. Aborting migration."
|
|
return 1
|
|
fi
|
|
PG_LAST_DSN="$dsn"
|
|
fi
|
|
fi
|
|
|
|
pg_ensure_client || LOGE "Could not install pg_dump/pg_restore (in-panel DB backup/restore may be unavailable)."
|
|
|
|
LOGI "Stopping panel to take a consistent snapshot..."
|
|
stop 0 > /dev/null 2>&1
|
|
|
|
echo ""
|
|
LOGI "Migrating data into PostgreSQL..."
|
|
if ! ${xui_folder}/x-ui migrate-db --dsn "$dsn"; then
|
|
LOGE "Migration failed. The panel was NOT switched to PostgreSQL."
|
|
start 0 > /dev/null 2>&1
|
|
return 1
|
|
fi
|
|
|
|
pg_write_env "$dsn"
|
|
LOGI "Wrote database settings to $(xui_env_file_path) (XUI_DB_TYPE=postgres)."
|
|
LOGI "Restarting panel on PostgreSQL..."
|
|
restart 0
|
|
sleep 1
|
|
if check_status; then
|
|
LOGI "Migration complete. The panel is now running on PostgreSQL."
|
|
else
|
|
LOGE "Panel did not come up. Check logs (option 16). Your SQLite data is left intact."
|
|
fi
|
|
}
|
|
|
|
postgresql_menu() {
|
|
echo -e "${green}\t1.${plain} ${green}Install${plain} PostgreSQL (server + client + xui db)"
|
|
echo -e "${green}\t2.${plain} Migrate SQLite ${green}->${plain} PostgreSQL"
|
|
echo -e "${green}\t3.${plain} Status (clusters & port 5432)"
|
|
echo -e "${green}\t4.${plain} ${green}Start${plain} PostgreSQL"
|
|
echo -e "${green}\t5.${plain} ${red}Stop${plain} PostgreSQL"
|
|
echo -e "${green}\t6.${plain} Restart PostgreSQL"
|
|
echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
|
|
echo -e "${green}\t8.${plain} View PostgreSQL Log"
|
|
echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
|
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
|
read -rp "Choose an option: " choice
|
|
case "$choice" in
|
|
0)
|
|
show_menu
|
|
;;
|
|
1)
|
|
pg_install_server_action
|
|
postgresql_menu
|
|
;;
|
|
2)
|
|
migrate_to_postgres
|
|
postgresql_menu
|
|
;;
|
|
3)
|
|
postgresql_status
|
|
postgresql_menu
|
|
;;
|
|
4)
|
|
postgresql_start
|
|
postgresql_menu
|
|
;;
|
|
5)
|
|
postgresql_stop
|
|
postgresql_menu
|
|
;;
|
|
6)
|
|
postgresql_restart
|
|
postgresql_menu
|
|
;;
|
|
7)
|
|
postgresql_enable
|
|
postgresql_menu
|
|
;;
|
|
8)
|
|
postgresql_log
|
|
postgresql_menu
|
|
;;
|
|
9)
|
|
migrate_db_prompt
|
|
postgresql_menu
|
|
;;
|
|
*)
|
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
|
postgresql_menu
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Convert between the panel's SQLite database and a portable .dump (SQL text)
|
|
# file using the bundled x-ui binary. With no arguments it dumps the installed
|
|
# panel database; an optional second argument overrides the output path.
|
|
# x-ui migrateDB [file.db|file.dump] [output]
|
|
migrate_db() {
|
|
local input="$1" output="$2"
|
|
local default_db="/etc/x-ui/x-ui.db"
|
|
local bin="${xui_folder}/x-ui"
|
|
|
|
[[ -z "$input" ]] && input="$default_db"
|
|
|
|
if [[ ! -x "$bin" ]]; then
|
|
LOGE "x-ui binary not found at ${bin}. Is the panel installed?"
|
|
return 1
|
|
fi
|
|
|
|
if ! "$bin" migrate-db -h 2>&1 | grep -q -- '-dump'; then
|
|
LOGE "This x-ui build does not support .db <-> .dump conversion yet."
|
|
LOGE "Update the panel first (x-ui update) to a version with 'migrate-db --dump/--restore'."
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "$input" ]]; then
|
|
LOGE "Input file not found: ${input}"
|
|
echo -e "Usage: ${green}x-ui migrateDB [file.db|file.dump] [output]${plain}"
|
|
return 1
|
|
fi
|
|
|
|
local mode
|
|
case "$input" in
|
|
*.db | *.sqlite | *.sqlite3)
|
|
mode="dump"
|
|
;;
|
|
*.dump | *.sql)
|
|
mode="restore"
|
|
;;
|
|
*)
|
|
if head -c 16 "$input" | grep -q "SQLite format 3"; then
|
|
mode="dump"
|
|
else
|
|
mode="restore"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
if [[ "$mode" == "dump" ]]; then
|
|
[[ -z "$output" ]] && output="${input%.*}.dump"
|
|
if [[ -f "$output" ]]; then
|
|
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
|
|
fi
|
|
LOGI "Dumping SQLite database to SQL text:"
|
|
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
|
|
if "$bin" migrate-db --src "$input" --dump "$output"; then
|
|
LOGI "Done. Wrote ${output}."
|
|
else
|
|
LOGE "Dump failed."
|
|
return 1
|
|
fi
|
|
else
|
|
[[ -z "$output" ]] && output="${input%.*}.db"
|
|
if [[ "$output" == "$default_db" ]] && check_status > /dev/null 2>&1; then
|
|
LOGE "Refusing to restore into the live database (${default_db}) while x-ui is running."
|
|
LOGE "Stop the panel first (x-ui stop) or choose a different output path."
|
|
return 1
|
|
fi
|
|
if [[ -f "$output" ]]; then
|
|
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
|
|
rm -f "$output"
|
|
fi
|
|
LOGI "Rebuilding SQLite database from SQL text:"
|
|
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
|
|
if "$bin" migrate-db --restore "$input" --out "$output"; then
|
|
LOGI "Done. Created ${output}."
|
|
else
|
|
LOGE "Restore failed."
|
|
rm -f "$output"
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Interactive wrapper around migrate_db for the menu: prompts for the paths and
|
|
# lets migrate_db auto-detect the direction.
|
|
migrate_db_prompt() {
|
|
local default_db="/etc/x-ui/x-ui.db"
|
|
local input output
|
|
echo -e "Convert between a SQLite ${green}.db${plain} and a portable ${green}.dump${plain} (direction auto-detected)."
|
|
read -rp "Input file [${default_db}]: " input
|
|
input="${input:-$default_db}"
|
|
read -rp "Output file (leave empty to auto-name next to input): " output
|
|
migrate_db "$input" "$output"
|
|
}
|
|
|
|
show_usage() {
|
|
echo -e "┌────────────────────────────────────────────────────────────────┐
|
|
│ ${blue}x-ui control menu usages (subcommands):${plain} │
|
|
│ │
|
|
│ ${blue}x-ui${plain} - Admin Management Script │
|
|
│ ${blue}x-ui start${plain} - Start │
|
|
│ ${blue}x-ui stop${plain} - Stop │
|
|
│ ${blue}x-ui restart${plain} - Restart │
|
|
| ${blue}x-ui restart-xray${plain} - Restart Xray │
|
|
│ ${blue}x-ui status${plain} - Current Status │
|
|
│ ${blue}x-ui settings${plain} - Current Settings │
|
|
│ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
|
|
│ ${blue}x-ui disable${plain} - Disable Autostart on OS Startup │
|
|
│ ${blue}x-ui log${plain} - Check logs │
|
|
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
|
│ ${blue}x-ui update${plain} - Update │
|
|
│ ${blue}x-ui update-dev${plain} - Update to Dev channel (latest) │
|
|
│ ${blue}x-ui update-all-geofiles${plain} - Update all geo files │
|
|
│ ${blue}x-ui migrateDB [file]${plain} - Convert .db <-> .dump (SQLite) │
|
|
│ ${blue}x-ui legacy${plain} - Legacy version │
|
|
│ ${blue}x-ui install${plain} - Install │
|
|
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
|
└────────────────────────────────────────────────────────────────┘"
|
|
}
|
|
|
|
show_menu() {
|
|
echo -e "
|
|
╔────────────────────────────────────────────────╗
|
|
│ ${green}3X-UI Panel Management Script${plain} │
|
|
│ ${green}0.${plain} Exit Script │
|
|
│────────────────────────────────────────────────│
|
|
│ ${green}1.${plain} Install │
|
|
│ ${green}2.${plain} Update │
|
|
│ ${green}3.${plain} Update to Dev Channel (latest commit) │
|
|
│ ${green}4.${plain} Update Menu │
|
|
│ ${green}5.${plain} Legacy Version │
|
|
│ ${green}6.${plain} Uninstall │
|
|
│────────────────────────────────────────────────│
|
|
│ ${green}7.${plain} Reset Username & Password │
|
|
│ ${green}8.${plain} Reset Web Base Path │
|
|
│ ${green}9.${plain} Reset Settings │
|
|
│ ${green}10.${plain} Change Port │
|
|
│ ${green}11.${plain} View Current Settings │
|
|
│────────────────────────────────────────────────│
|
|
│ ${green}12.${plain} Start │
|
|
│ ${green}13.${plain} Stop │
|
|
│ ${green}14.${plain} Restart │
|
|
| ${green}15.${plain} Restart Xray │
|
|
│ ${green}16.${plain} Check Status │
|
|
│ ${green}17.${plain} Logs Management │
|
|
│────────────────────────────────────────────────│
|
|
│ ${green}18.${plain} Enable Autostart │
|
|
│ ${green}19.${plain} Disable Autostart │
|
|
│────────────────────────────────────────────────│
|
|
│ ${green}20.${plain} SSL Certificate Management │
|
|
│ ${green}21.${plain} Cloudflare SSL Certificate │
|
|
│ ${green}22.${plain} IP Limit Management │
|
|
│ ${green}23.${plain} Firewall Management │
|
|
│ ${green}24.${plain} SSH Port Forwarding Management │
|
|
│ ${green}25.${plain} PostgreSQL Management │
|
|
│────────────────────────────────────────────────│
|
|
│ ${green}26.${plain} Enable BBR │
|
|
│ ${green}27.${plain} Update Geo Files │
|
|
│ ${green}28.${plain} Speedtest by Ookla │
|
|
╚────────────────────────────────────────────────╝
|
|
"
|
|
show_status
|
|
echo && read -rp "Please enter your selection [0-28]: " num
|
|
|
|
case "${num}" in
|
|
0)
|
|
exit 0
|
|
;;
|
|
1)
|
|
check_uninstall && install
|
|
;;
|
|
2)
|
|
check_install && update
|
|
;;
|
|
3)
|
|
check_install && update_dev
|
|
;;
|
|
4)
|
|
check_install && update_menu
|
|
;;
|
|
5)
|
|
check_install && legacy_version
|
|
;;
|
|
6)
|
|
check_install && uninstall
|
|
;;
|
|
7)
|
|
check_install && reset_user
|
|
;;
|
|
8)
|
|
check_install && reset_webbasepath
|
|
;;
|
|
9)
|
|
check_install && reset_config
|
|
;;
|
|
10)
|
|
check_install && set_port
|
|
;;
|
|
11)
|
|
check_install && check_config
|
|
;;
|
|
12)
|
|
check_install && start
|
|
;;
|
|
13)
|
|
check_install && stop
|
|
;;
|
|
14)
|
|
check_install && restart
|
|
;;
|
|
15)
|
|
check_install && restart_xray
|
|
;;
|
|
16)
|
|
check_install && status
|
|
;;
|
|
17)
|
|
check_install && show_log
|
|
;;
|
|
18)
|
|
check_install && enable
|
|
;;
|
|
19)
|
|
check_install && disable
|
|
;;
|
|
20)
|
|
ssl_cert_issue_main
|
|
;;
|
|
21)
|
|
ssl_cert_issue_CF
|
|
;;
|
|
22)
|
|
iplimit_main
|
|
;;
|
|
23)
|
|
firewall_menu
|
|
;;
|
|
24)
|
|
SSH_port_forwarding
|
|
;;
|
|
25)
|
|
postgresql_menu
|
|
;;
|
|
26)
|
|
bbr_menu
|
|
;;
|
|
27)
|
|
update_geo
|
|
;;
|
|
28)
|
|
run_speedtest
|
|
;;
|
|
*)
|
|
LOGE "Please enter the correct number [0-28]"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
if [[ $# > 0 ]]; then
|
|
case $1 in
|
|
"start")
|
|
check_install 0 && start 0
|
|
;;
|
|
"stop")
|
|
check_install 0 && stop 0
|
|
;;
|
|
"restart")
|
|
check_install 0 && restart 0
|
|
;;
|
|
"restart-xray")
|
|
check_install 0 && restart_xray 0
|
|
;;
|
|
"status")
|
|
check_install 0 && status 0
|
|
;;
|
|
"settings")
|
|
check_install 0 && check_config 0
|
|
;;
|
|
"enable")
|
|
check_install 0 && enable 0
|
|
;;
|
|
"disable")
|
|
check_install 0 && disable 0
|
|
;;
|
|
"log")
|
|
check_install 0 && show_log 0
|
|
;;
|
|
"banlog")
|
|
check_install 0 && show_banlog 0
|
|
;;
|
|
"setup-fail2ban")
|
|
setup_fail2ban_iplimit
|
|
;;
|
|
"update")
|
|
check_install 0 && update 0
|
|
;;
|
|
"update-dev")
|
|
check_install 0 && update_dev 0
|
|
;;
|
|
"legacy")
|
|
check_install 0 && legacy_version 0
|
|
;;
|
|
"install")
|
|
check_uninstall 0 && install 0
|
|
;;
|
|
"uninstall")
|
|
check_install 0 && uninstall 0
|
|
;;
|
|
"update-all-geofiles")
|
|
geo_updated=0
|
|
if check_install 0 && update_all_geofiles 0; then
|
|
[[ $geo_updated -eq 0 ]] || restart 0
|
|
fi
|
|
;;
|
|
"migrateDB")
|
|
migrate_db "$2" "$3"
|
|
;;
|
|
*) show_usage ;;
|
|
esac
|
|
else
|
|
show_menu
|
|
fi
|