diff --git a/install.sh b/install.sh index bd8945f92..aadfa7a17 100644 --- a/install.sh +++ b/install.sh @@ -1384,6 +1384,169 @@ setup_fail2ban() { return 0 } +# Major version of the local systemd, 0 when it cannot be determined. The +# SystemCallFilter=@system-service group only exists from systemd 239 on (other +# @-named groups exist since 231); on older versions an unknown group is not +# ignored safely, the filter stays in force and leaves a whitelist the panel +# cannot run under. +_xui_systemd_major_version() { + local version="" + if command -v systemctl > /dev/null 2>&1; then + version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')" + fi + if [[ ! "$version" =~ ^[0-9]+$ ]]; then + echo 0 + return 0 + fi + echo "$version" +} + +# The shipped units list hardening that older systemd does not know: the +# directive is logged and ignored at load time rather than rejected, so the +# panel still starts, only without that protection. Each entry is the systemd +# release that introduced the directive (systemd.exec(5)); everything else in +# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219). +# SystemCallFilter= is listed because the drop-in only writes it from 239 on. +_xui_warn_unsupported_hardening() { + local version entry missing="" + version="$(_xui_systemd_major_version)" + [[ "$version" -gt 0 ]] || return 0 + for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \ + ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \ + SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \ + ProtectKernelLogs:244 ProtectClock:245; do + if [[ "$version" -lt "${entry##*:}" ]]; then + missing="${missing:+$missing, }${entry%%:*} (${entry##*:})" + fi + done + [[ -n "$missing" ]] || return 0 + echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}" + echo " Not applied, needs a newer systemd: ${missing}." + if [[ "$version" -lt 231 ]]; then + echo " The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs." + fi + echo " The rest of the hardening is in force. Upgrade systemd to apply the above." + return 0 +} + +# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem= +# strict would make the whole hierarchy read-only (only the kernel API +# filesystems stay as they are), and that would break the panel's own use of +# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER, +# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place +# the files in -- the unit's WorkingDirectory on a stock install, and what a +# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit +# either misses a relocated store -- the panel then cannot write its own SQLite +# database and sits in a Restart=on-failure loop -- or forces the operator to +# edit a file that every install/update overwrites from the release tarball. +# install.sh and update.sh therefore regenerate the drop-in from the folders +# actually in use, and the unit's own ReadWritePaths only carry the +# plain-install defaults. A relocated store means re-running install or update: +# the drop-in is only written here. +_xui_service_write_paths_dropin() { + # $1 is the env file to resolve the XUI_* folders from; callers pass nothing + # and get the OS-specific path the unit itself uses. + local env_file="${1:-}" + local dropin_dir dropin temp_file + local db_folder log_folder bin_folder main_folder + local path line="" whitespace_paths="" seen_paths="" escaped_path + + if [[ -z "$env_file" ]]; then + case "${release}" in + ubuntu | debian | armbian) + env_file="/etc/default/x-ui" + ;; + arch | manjaro | parch | alpine) + env_file="/etc/conf.d/x-ui" + ;; + *) + env_file="/etc/sysconfig/x-ui" + ;; + esac + fi + if [[ -r "$env_file" ]]; then + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + fi + + # XUI_* wins over the script's own default: the unit hands that same env + # file to the panel through EnvironmentFile=, so these are the folders it + # will actually use. + main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}" + db_folder="${XUI_DB_FOLDER:-/etc/x-ui}" + log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}" + # An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working + # directory, which the unit sets to the main folder. + bin_folder="${XUI_BIN_FOLDER:-bin}" + if [[ "$bin_folder" != /* ]]; then + bin_folder="${main_folder%/}/${bin_folder#./}" + fi + + for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do + [[ "$path" == /* ]] || continue + # ReadWritePaths= is a whitespace-separated list, and a folder whose + # name contains whitespace cannot be written into it without relying on + # quoting. A wrong entry makes systemd reject the whole drop-in and the + # panel would not start, so leave such a folder out and say so instead. + if [[ "$path" != "${path//[[:space:]]/}" ]]; then + whitespace_paths="${whitespace_paths:+$whitespace_paths }$path" + continue + fi + case " $seen_paths " in + *" $path "*) continue ;; + esac + seen_paths="${seen_paths}${seen_paths:+ }$path" + # systemd expands %-specifiers in unit files, so a folder name carrying + # a literal % has to be written as %%, or the entry stops naming the + # folder systemd is meant to keep writable. + escaped_path="${path//%/%%}" + line="${line} -${escaped_path}" + done + if [[ -n "$whitespace_paths" ]]; then + echo "Warning: these folders contain whitespace and were left out of" >&2 + echo " 10-xui-sandbox.conf: $whitespace_paths" >&2 + echo " The panel cannot write to them under the unit's sandbox." >&2 + fi + line="${line# }" + [[ -n "$line" ]] || return 1 + + dropin_dir="${xui_service}/x-ui.service.d" + dropin="${dropin_dir}/10-xui-sandbox.conf" + temp_file="${dropin}.tmp.$$" + + mkdir -p "$dropin_dir" || return 1 + cat > "$temp_file" << EOF +# Regenerated by install.sh/update.sh on every install and update: edits here +# are lost, and the list only reflects the XUI_* variables read from +# ${env_file} at that moment. Re-run install/update after moving a store. +# It lists the folders the panel writes to. Put local additions in their own +# drop-in, for example 20-x-ui-local.conf, which nothing here touches. +[Service] +ReadWritePaths=${line} +ReadWriteDirectories=${line} +EOF + if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then + cat >> "$temp_file" << 'EOF' +# @system-service needs systemd >= 239; on older versions the unknown group +# would leave the panel with a filter it cannot start under (x-ui.service.*). +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +EOF + fi + if [[ ! -s "$temp_file" ]]; then + rm -f "$temp_file" + return 1 + fi + chmod 644 "$temp_file" + mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; } + if command -v systemctl > /dev/null 2>&1; then + systemctl daemon-reload > /dev/null 2>&1 || true + fi + return 0 +} + # Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file + # atomic mv, so a failed cp/curl or an interrupted mv never leaves a # truncated unit file at the live path -- systemd would then fail to parse @@ -1415,6 +1578,11 @@ _install_xui_service_unit() { rm -f "$temp_file" return 1 fi + if ! _xui_service_write_paths_dropin; then + echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}" + echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}" + fi + _xui_warn_unsupported_hardening return 0 } diff --git a/update.sh b/update.sh index 894145bf7..77ae969cc 100755 --- a/update.sh +++ b/update.sh @@ -940,6 +940,180 @@ setup_fail2ban() { return 0 } +# The hardened unit makes /usr, /boot, /efi and /etc read-only. The panel's own +# updater is expected to escape that sandbox by running this script through a +# transient systemd-run unit; when systemd-run is unavailable it starts this +# script as a plain child instead, and that child inherits the sandbox and then +# cannot write anything this update needs. Say so once, up front, instead of +# dying partway through with "Failed to download x-ui". +require_writable_update_paths() { + local dir probe + for dir in "${xui_folder%/*}" "/usr/bin"; do + [[ -n "$dir" && -d "$dir" ]] || continue + probe="${dir}/.x-ui-write-test.$$" + # A real write test rather than [[ -w ]]: this runs as root, where a + # permission bit means little and the test only reflects the file mode + # and the mount flags, not an immutable attribute or a full filesystem. + if ! : > "$probe" 2> /dev/null; then + _fail "ERROR: ${dir} is not writable for this process (read-only mount, attribute or full filesystem). The panel's fallback updater cannot run inside the hardened systemd sandbox; update from the panel UI (which uses systemd-run) or run 'x-ui update' in a shell." + fi + rm -f "$probe" + done +} + +# Major version of the local systemd, 0 when it cannot be determined. The +# SystemCallFilter=@system-service group only exists from systemd 239 on (other +# @-named groups exist since 231); on older versions an unknown group is not +# ignored safely, the filter stays in force and leaves a whitelist the panel +# cannot run under. +_xui_systemd_major_version() { + local version="" + if command -v systemctl > /dev/null 2>&1; then + version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')" + fi + if [[ ! "$version" =~ ^[0-9]+$ ]]; then + echo 0 + return 0 + fi + echo "$version" +} + +# The shipped units list hardening that older systemd does not know: the +# directive is logged and ignored at load time rather than rejected, so the +# panel still starts, only without that protection. Each entry is the systemd +# release that introduced the directive (systemd.exec(5)); everything else in +# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219). +# SystemCallFilter= is listed because the drop-in only writes it from 239 on. +_xui_warn_unsupported_hardening() { + local version entry missing="" + version="$(_xui_systemd_major_version)" + [[ "$version" -gt 0 ]] || return 0 + for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \ + ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \ + SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \ + ProtectKernelLogs:244 ProtectClock:245; do + if [[ "$version" -lt "${entry##*:}" ]]; then + missing="${missing:+$missing, }${entry%%:*} (${entry##*:})" + fi + done + [[ -n "$missing" ]] || return 0 + echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}" + echo " Not applied, needs a newer systemd: ${missing}." + if [[ "$version" -lt 231 ]]; then + echo " The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs." + fi + echo " The rest of the hardening is in force. Upgrade systemd to apply the above." + return 0 +} + +# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem= +# strict would make the whole hierarchy read-only (only the kernel API +# filesystems stay as they are), and that would break the panel's own use of +# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER, +# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place +# the files in -- the unit's WorkingDirectory on a stock install, and what a +# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit +# either misses a relocated store -- the panel then cannot write its own SQLite +# database and sits in a Restart=on-failure loop -- or forces the operator to +# edit a file that every install/update overwrites from the release tarball. +# install.sh and update.sh therefore regenerate the drop-in from the folders +# actually in use, and the unit's own ReadWritePaths only carry the +# plain-install defaults. A relocated store means re-running install or update: +# the drop-in is only written here. +_xui_service_write_paths_dropin() { + # $1 is the env file to resolve the XUI_* folders from; callers pass nothing + # and get the OS-specific path the unit itself uses. + local env_file="${1:-}" + local dropin_dir dropin temp_file + local db_folder log_folder bin_folder main_folder + local path line="" whitespace_paths="" seen_paths="" escaped_path + + if [[ -z "$env_file" ]]; then + env_file="$(xui_env_file_path)" + fi + if [[ -r "$env_file" ]]; then + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + fi + + # XUI_* wins over the script's own default: the unit hands that same env + # file to the panel through EnvironmentFile=, so these are the folders it + # will actually use. + main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}" + db_folder="${XUI_DB_FOLDER:-/etc/x-ui}" + log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}" + # An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working + # directory, which the unit sets to the main folder. + bin_folder="${XUI_BIN_FOLDER:-bin}" + if [[ "$bin_folder" != /* ]]; then + bin_folder="${main_folder%/}/${bin_folder#./}" + fi + + for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do + [[ "$path" == /* ]] || continue + # ReadWritePaths= is a whitespace-separated list, and a folder whose + # name contains whitespace cannot be written into it without relying on + # quoting. A wrong entry makes systemd reject the whole drop-in and the + # panel would not start, so leave such a folder out and say so instead. + if [[ "$path" != "${path//[[:space:]]/}" ]]; then + whitespace_paths="${whitespace_paths:+$whitespace_paths }$path" + continue + fi + case " $seen_paths " in + *" $path "*) continue ;; + esac + seen_paths="${seen_paths}${seen_paths:+ }$path" + # systemd expands %-specifiers in unit files, so a folder name carrying + # a literal % has to be written as %%, or the entry stops naming the + # folder systemd is meant to keep writable. + escaped_path="${path//%/%%}" + line="${line} -${escaped_path}" + done + if [[ -n "$whitespace_paths" ]]; then + echo "Warning: these folders contain whitespace and were left out of" >&2 + echo " 10-xui-sandbox.conf: $whitespace_paths" >&2 + echo " The panel cannot write to them under the unit's sandbox." >&2 + fi + line="${line# }" + [[ -n "$line" ]] || return 1 + + dropin_dir="${xui_service}/x-ui.service.d" + dropin="${dropin_dir}/10-xui-sandbox.conf" + temp_file="${dropin}.tmp.$$" + + mkdir -p "$dropin_dir" || return 1 + cat > "$temp_file" << EOF +# Regenerated by install.sh/update.sh on every install and update: edits here +# are lost, and the list only reflects the XUI_* variables read from +# ${env_file} at that moment. Re-run install/update after moving a store. +# It lists the folders the panel writes to. Put local additions in their own +# drop-in, for example 20-x-ui-local.conf, which nothing here touches. +[Service] +ReadWritePaths=${line} +ReadWriteDirectories=${line} +EOF + if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then + cat >> "$temp_file" << 'EOF' +# @system-service needs systemd >= 239; on older versions the unknown group +# would leave the panel with a filter it cannot start under (x-ui.service.*). +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +EOF + fi + if [[ ! -s "$temp_file" ]]; then + rm -f "$temp_file" + return 1 + fi + chmod 644 "$temp_file" + mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; } + if command -v systemctl > /dev/null 2>&1; then + systemctl daemon-reload > /dev/null 2>&1 || true + fi + return 0 +} + # Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file + # atomic mv, so a failed cp/curl or an interrupted mv never leaves a # truncated unit file at the live path -- systemd would then fail to parse @@ -971,6 +1145,11 @@ _install_xui_service_unit() { rm -f "$temp_file" return 1 fi + if ! _xui_service_write_paths_dropin; then + echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}" + echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}" + fi + _xui_warn_unsupported_hardening return 0 } @@ -1292,5 +1471,6 @@ update_x-ui() { } echo -e "${green}Running...${plain}" +require_writable_update_paths install_base update_x-ui $1 diff --git a/x-ui.service.arch b/x-ui.service.arch index c8134b01a..59c1964c9 100644 --- a/x-ui.service.arch +++ b/x-ui.service.arch @@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID Restart=on-failure RestartSec=5s +# The panel intentionally stays root: it supervises the Xray child processes, +# edits netfilter state and reads TLS private keys. These settings only bound +# what a panel-level flaw can reach. +# +# PrivateTmp=yes is deliberately absent: the web updater writes its script into +# /tmp and hands the absolute path to a "systemd-run" transient unit, which +# does not share this service's private /tmp (the download would vanish). +NoNewPrivileges=yes +ProtectSystem=full +# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui), +# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in +# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the +# floor, not the whole list: install.sh and update.sh regenerate a drop-in +# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_* +# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and +# the list survives an update instead of being reset to these defaults. Changing +# one of those variables in the env file is not enough by itself: the drop-in has +# to be refreshed as well, i.e. install or update the panel again. +# Add local extras in your own drop-in (e.g. 20-local.conf). +# The leading '-' keeps the unit startable if a path does not exist yet. +# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become +# read-only, everything else stays writable. So this list matters for stores +# under those trees -- the default main folder under /usr/local is one. +# +# The in-panel updater is expected to leave this sandbox: it runs update.sh +# through a transient systemd-run unit, which does not inherit these settings. +# Its plain-child fallback (taken when systemd-run is unavailable) cannot work +# here -- update.sh stages the release archive beside the main folder, replaces +# /usr/bin/x-ui and calls the package manager -- so it stops with one clear +# message instead of failing halfway, and the sandbox deliberately does not +# grant /usr or /etc to accommodate it. +# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated +# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable. +ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui +ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectClock=yes +ProtectHostname=yes +# read-only rather than yes: installs keep TLS certs under /root/cert, and the +# panel must still be able to read them. +ProtectHome=read-only +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +UMask=0077 +# AF_NETLINK for interface/route lookups and the ip(8) child used by the +# AmneziaWG IPv6-alias feature. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW +# for raw sockets and SO_BINDTODEVICE. +# +# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is +# subtracted from root's own privileges too: without it root can only read a +# file when the owner/group/other bits let uid 0 through, and any TLS private +# key belonging to another account becomes unreadable -- a certificate issued to +# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails +# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and +# keeps serving plain HTTP, and every Xray inbound using that key stops. Those +# reads worked before the sandbox because the panel is root. +# DAC_READ_SEARCH is deliberately absent: directory search is already covered by +# DAC_OVERRIDE, so it would only widen the set without adding anything. +CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW +SystemCallArchitectures=native +# No seccomp whitelist here on purpose. @system-service needs systemd >= 239 +# (other @-named groups exist since 231), and older systemd does not ignore an +# unknown group name gracefully: on +# <231 the name fails to resolve and the filter stays the built-in whitelist of +# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to +# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and +# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and +# SystemCallErrorNumber=EPERM to the generated drop-in, but only when +# "systemctl --version" reports 239 or newer. + [Install] WantedBy=multi-user.target diff --git a/x-ui.service.debian b/x-ui.service.debian index dead90993..074bda292 100644 --- a/x-ui.service.debian +++ b/x-ui.service.debian @@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID Restart=on-failure RestartSec=5s +# The panel intentionally stays root: it supervises the Xray child processes, +# edits netfilter state and reads TLS private keys. These settings only bound +# what a panel-level flaw can reach. +# +# PrivateTmp=yes is deliberately absent: the web updater writes its script into +# /tmp and hands the absolute path to a "systemd-run" transient unit, which +# does not share this service's private /tmp (the download would vanish). +NoNewPrivileges=yes +ProtectSystem=full +# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui), +# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in +# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the +# floor, not the whole list: install.sh and update.sh regenerate a drop-in +# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_* +# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and +# the list survives an update instead of being reset to these defaults. Changing +# one of those variables in the env file is not enough by itself: the drop-in has +# to be refreshed as well, i.e. install or update the panel again. +# Add local extras in your own drop-in (e.g. 20-local.conf). +# The leading '-' keeps the unit startable if a path does not exist yet. +# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become +# read-only, everything else stays writable. So this list matters for stores +# under those trees -- the default main folder under /usr/local is one. +# +# The in-panel updater is expected to leave this sandbox: it runs update.sh +# through a transient systemd-run unit, which does not inherit these settings. +# Its plain-child fallback (taken when systemd-run is unavailable) cannot work +# here -- update.sh stages the release archive beside the main folder, replaces +# /usr/bin/x-ui and calls the package manager -- so it stops with one clear +# message instead of failing halfway, and the sandbox deliberately does not +# grant /usr or /etc to accommodate it. +# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated +# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable. +ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui +ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectClock=yes +ProtectHostname=yes +# read-only rather than yes: installs keep TLS certs under /root/cert, and the +# panel must still be able to read them. +ProtectHome=read-only +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +UMask=0077 +# AF_NETLINK for interface/route lookups and the ip(8) child used by the +# AmneziaWG IPv6-alias feature. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW +# for raw sockets and SO_BINDTODEVICE. +# +# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is +# subtracted from root's own privileges too: without it root can only read a +# file when the owner/group/other bits let uid 0 through, and any TLS private +# key belonging to another account becomes unreadable -- a certificate issued to +# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails +# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and +# keeps serving plain HTTP, and every Xray inbound using that key stops. Those +# reads worked before the sandbox because the panel is root. +# DAC_READ_SEARCH is deliberately absent: directory search is already covered by +# DAC_OVERRIDE, so it would only widen the set without adding anything. +CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW +SystemCallArchitectures=native +# No seccomp whitelist here on purpose. @system-service needs systemd >= 239 +# (other @-named groups exist since 231), and older systemd does not ignore an +# unknown group name gracefully: on +# <231 the name fails to resolve and the filter stays the built-in whitelist of +# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to +# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and +# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and +# SystemCallErrorNumber=EPERM to the generated drop-in, but only when +# "systemctl --version" reports 239 or newer. + [Install] WantedBy=multi-user.target diff --git a/x-ui.service.rhel b/x-ui.service.rhel index b7ae77764..ee2763331 100644 --- a/x-ui.service.rhel +++ b/x-ui.service.rhel @@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID Restart=on-failure RestartSec=5s +# The panel intentionally stays root: it supervises the Xray child processes, +# edits netfilter state and reads TLS private keys. These settings only bound +# what a panel-level flaw can reach. +# +# PrivateTmp=yes is deliberately absent: the web updater writes its script into +# /tmp and hands the absolute path to a "systemd-run" transient unit, which +# does not share this service's private /tmp (the download would vanish). +NoNewPrivileges=yes +ProtectSystem=full +# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui), +# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in +# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the +# floor, not the whole list: install.sh and update.sh regenerate a drop-in +# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_* +# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and +# the list survives an update instead of being reset to these defaults. Changing +# one of those variables in the env file is not enough by itself: the drop-in has +# to be refreshed as well, i.e. install or update the panel again. +# Add local extras in your own drop-in (e.g. 20-local.conf). +# The leading '-' keeps the unit startable if a path does not exist yet. +# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become +# read-only, everything else stays writable. So this list matters for stores +# under those trees -- the default main folder under /usr/local is one. +# +# The in-panel updater is expected to leave this sandbox: it runs update.sh +# through a transient systemd-run unit, which does not inherit these settings. +# Its plain-child fallback (taken when systemd-run is unavailable) cannot work +# here -- update.sh stages the release archive beside the main folder, replaces +# /usr/bin/x-ui and calls the package manager -- so it stops with one clear +# message instead of failing halfway, and the sandbox deliberately does not +# grant /usr or /etc to accommodate it. +# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated +# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable. +ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui +ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectClock=yes +ProtectHostname=yes +# read-only rather than yes: installs keep TLS certs under /root/cert, and the +# panel must still be able to read them. +ProtectHome=read-only +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +UMask=0077 +# AF_NETLINK for interface/route lookups and the ip(8) child used by the +# AmneziaWG IPv6-alias feature. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW +# for raw sockets and SO_BINDTODEVICE. +# +# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is +# subtracted from root's own privileges too: without it root can only read a +# file when the owner/group/other bits let uid 0 through, and any TLS private +# key belonging to another account becomes unreadable -- a certificate issued to +# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails +# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and +# keeps serving plain HTTP, and every Xray inbound using that key stops. Those +# reads worked before the sandbox because the panel is root. +# DAC_READ_SEARCH is deliberately absent: directory search is already covered by +# DAC_OVERRIDE, so it would only widen the set without adding anything. +CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW +SystemCallArchitectures=native +# No seccomp whitelist here on purpose. @system-service needs systemd >= 239 +# (other @-named groups exist since 231), and older systemd does not ignore an +# unknown group name gracefully: on +# <231 the name fails to resolve and the filter stays the built-in whitelist of +# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to +# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and +# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and +# SystemCallErrorNumber=EPERM to the generated drop-in, but only when +# "systemctl --version" reports 239 or newer. + [Install] WantedBy=multi-user.target diff --git a/x-ui.sh b/x-ui.sh index 33ab83f9e..b552bbfb6 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -294,6 +294,12 @@ uninstall() { systemctl stop x-ui systemctl disable x-ui rm ${xui_service}/x-ui.service -f + # The sandbox drop-in generated by install.sh/update.sh lives beside the + # unit; leaving it behind would keep an empty x-ui.service.d around and + # silently re-apply on a later install of another unit of the same name. + # Local drop-ins the operator added go with it, which is what an + # uninstall is expected to do. + rm -rf -- "${xui_service}/x-ui.service.d" systemctl daemon-reload systemctl reset-failed fi