3 Commits 66ef5bbc05 ... c9207b6f3e

Autore SHA1 Messaggio Data
  MHSanaei c9207b6f3e feat(install): install PostgreSQL 18 from PGDG for new local databases 20 ore fa
  MHSanaei 6be3c438e1 fix(node): keep client traffic history when a node inbound is replaced 22 ore fa
  MHSanaei d57dcf824b chore(deps): bump frontend and docs deps, clear npm audit findings 22 ore fa

+ 8 - 8
docs/package.json

@@ -18,12 +18,12 @@
     "test:watch": "vitest"
   },
   "dependencies": {
-    "fumadocs-core": "^16.15.18",
+    "fumadocs-core": "^16.16.2",
     "fumadocs-docgen": "^3.1.1",
     "fumadocs-mdx": "^15.4.6",
-    "fumadocs-openapi": "^12.1.1",
-    "fumadocs-ui": "^16.15.18",
-    "lucide-react": "^1.50.0",
+    "fumadocs-openapi": "^12.3.0",
+    "fumadocs-ui": "^16.16.2",
+    "lucide-react": "^1.52.0",
     "mermaid": "^12.1.0",
     "next": "16.3.8",
     "next-themes": "^0.4.6",
@@ -40,12 +40,12 @@
     "@types/node": "^26.6.4",
     "@types/react": "^19.3.0",
     "@types/react-dom": "^19.3.0",
-    "oxfmt": "0.71.0",
-    "oxlint": "1.86.0",
-    "postcss": "^8.5.28",
+    "oxfmt": "0.72.0",
+    "oxlint": "1.87.0",
+    "postcss": "^8.5.29",
     "tailwindcss": "^4.3.3",
     "typescript": "7.0.2",
     "vitest": "^5.0.3"
   },
-  "packageManager": "pnpm@12.8.1"
+  "packageManager": "pnpm@12.9.1"
 }

File diff suppressed because it is too large
+ 268 - 248
docs/pnpm-lock.yaml


+ 42 - 17
docs/pnpm-workspace.yaml

@@ -8,20 +8,45 @@ overrides:
   'postcss@<8.5.10': '^8.5.15'
   'sharp@<0.35.0': '^0.35.3'
 minimumReleaseAgeExclude:
-  - '@mermaid-js/[email protected] || 2.0.1'
-  - [email protected] || 12.1.0
-  - [email protected] || 1.50.0
-  - [email protected]
-  - [email protected] || 15.4.1 || 15.4.5 || 15.4.6
-  - '@fumadocs/[email protected] || 0.2.9'
-  - '@types/[email protected] || 26.6.4'
-  - [email protected] || 16.15.11 || 16.15.18
-  - [email protected] || 11.4.3 || 12.0.3 || 12.1.1
-  - [email protected] || 16.15.11 || 16.15.18
-  - '@fumadocs/[email protected]'
-  - '@fumari/[email protected]'
-  - '@fumari/[email protected]'
-  - '@vitest/[email protected] || 5.0.2'
-  - '@vitest/[email protected] || 5.0.2'
-  - [email protected]
-  - [email protected] || 5.0.2
+  - [email protected]
+  - [email protected]
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxfmt/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - '@oxlint/[email protected]'
+  - [email protected]
+  - [email protected]

File diff suppressed because it is too large
+ 247 - 262
frontend/package-lock.json


+ 11 - 6
frontend/package.json

@@ -66,26 +66,28 @@
     "@types/react": "^19.3.0",
     "@types/react-dom": "^19.3.0",
     "@types/swagger-ui-react": "^5.18.0",
-    "@vitejs/plugin-react": "^6.1.1",
+    "@vitejs/plugin-react": "^6.1.2",
     "@vitest/browser-playwright": "5.0.3",
     "@vitest/coverage-v8": "^5.0.3",
     "husky": "^9.1.7",
-    "jsdom": "^30.1.1",
+    "jsdom": "^30.1.2",
     "lint-staged": "^17.6.0",
-    "msw": "^3.0.1",
-    "oxfmt": "0.71.0",
-    "oxlint": "1.86.0",
+    "msw": "^3.0.2",
+    "oxfmt": "0.72.0",
+    "oxlint": "1.87.0",
     "oxlint-tsgolint": "^7.0.2003",
     "playwright": "^1.63.0",
     "storybook": "^10.6.1",
     "typescript": "7.0.2",
-    "vite": "8.3.2",
+    "vite": "8.3.3",
     "vitest": "^5.0.3"
   },
   "overrides": {
     "dompurify": "^3.4.15",
     "react-copy-to-clipboard": "^5.1.1",
     "react-inspector": "^9.0.0",
+    "seroval": "^1.6.8",
+    "seroval-plugins": "^1.6.8",
     "react-debounce-input": {
       "react": "^19.0.0"
     },
@@ -100,6 +102,9 @@
       "vitest": "^5.0.0",
       "@vitest/browser-playwright": "^5.0.0",
       "@vitest/browser": "^5.0.0"
+    },
+    "remarkable": {
+      "argparse": "^2.0.1"
     }
   },
   "allowScripts": {

+ 71 - 11
install.sh

@@ -208,6 +208,60 @@ pg_ensure_hba_password_auth() {
     sudo -u postgres psql -tAc 'SELECT pg_reload_conf()' > /dev/null 2>&1 || true
 }
 
+# New installs get this PostgreSQL major from the official PGDG repository; distros or
+# CPU architectures PGDG does not build for fall back to the distribution's own package.
+PG_TARGET_MAJOR=18
+
+pg_has_debian_cluster() {
+    command -v pg_lsclusters > /dev/null 2>&1 && [[ -n "$(pg_lsclusters -h 2> /dev/null)" ]]
+}
+
+# Installs postgresql-${PG_TARGET_MAJOR} from apt.postgresql.org. On failure it removes the
+# repository it added, so an unsupported release cannot break later `apt-get update` runs.
+pg_apt_install_pgdg_server() {
+    local list added_lists=()
+    for list in /etc/apt/sources.list.d/pgdg.list /etc/apt/sources.list.d/pgdg.sources; do
+        [[ -e "${list}" ]] || added_lists+=("${list}")
+    done
+    if apt-get install -y -q postgresql-common ca-certificates gnupg >&2 \
+        && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >&2 \
+        && apt-get install -y -q "postgresql-${PG_TARGET_MAJOR}" >&2; then
+        return 0
+    fi
+    for list in "${added_lists[@]}"; do
+        rm -f "${list}"
+    done
+    apt-get update >&2 || true
+    echo -e "${yellow}PostgreSQL ${PG_TARGET_MAJOR} is not available for this system; installing the distribution's PostgreSQL instead.${plain}" >&2
+    return 1
+}
+
+# Installs postgresql${PG_TARGET_MAJOR}-server from the PGDG yum repository (EL 8+ only); on
+# failure it undoes the repository and module changes it made. The caller runs initdb.
+pg_el_install_pgdg_server() {
+    local elver
+    elver=$(rpm -E %rhel 2> /dev/null)
+    [[ "${elver}" =~ ^[0-9]+$ && "${elver}" -ge 8 ]] && command -v dnf > /dev/null 2>&1 || return 1
+    [[ -f /var/lib/pgsql/data/PG_VERSION ]] && return 1
+    local added_repo=false
+    if ! rpm -q pgdg-redhat-repo > /dev/null 2>&1; then
+        if dnf install -y -q "https://download.postgresql.org/pub/repos/yum/reporpms/EL-${elver}-$(uname -m)/pgdg-redhat-repo-latest.noarch.rpm" >&2; then
+            added_repo=true
+            dnf -qy module disable postgresql > /dev/null 2>&1 || true
+        fi
+    fi
+    if rpm -q pgdg-redhat-repo > /dev/null 2>&1 \
+        && dnf install -y -q "postgresql${PG_TARGET_MAJOR}-server" "postgresql${PG_TARGET_MAJOR}-contrib" >&2; then
+        return 0
+    fi
+    if [[ "${added_repo}" == "true" ]]; then
+        dnf -qy module reset postgresql > /dev/null 2>&1 || true
+        dnf remove -y -q pgdg-redhat-repo >&2 || true
+    fi
+    echo -e "${yellow}PostgreSQL ${PG_TARGET_MAJOR} is not available for this system; installing the distribution's PostgreSQL instead.${plain}" >&2
+    return 1
+}
+
 install_postgres_local() {
     local pg_user pg_pass
     pg_pass=$(gen_random_string 24)
@@ -215,21 +269,27 @@ install_postgres_local() {
     local pg_host="127.0.0.1"
     local pg_port="5432"
 
+    local pg_service="postgresql"
     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
+            apt-get update >&2 || return 1
+            if pg_has_debian_cluster || ! pg_apt_install_pgdg_server; then
+                apt-get install -y -q postgresql >&2 || return 1
+            fi
             ;;
-        centos)
-            if [[ "${VERSION_ID}" =~ ^7 ]]; then
-                yum install -y postgresql-server postgresql-contrib >&2 || return 1
+        fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos)
+            if [[ "${release}" != "fedora" && "${release}" != "amzn" ]] && pg_el_install_pgdg_server; then
+                pg_service="postgresql-${PG_TARGET_MAJOR}"
+                [[ -f "/var/lib/pgsql/${PG_TARGET_MAJOR}/data/PG_VERSION" ]] \
+                    || "/usr/pgsql-${PG_TARGET_MAJOR}/bin/postgresql-${PG_TARGET_MAJOR}-setup" initdb >&2 || return 1
             else
-                dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
+                if [[ "${release}" == "centos" && "${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
             fi
-            [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
             ;;
         arch | manjaro | parch)
             pacman -Sy --noconfirm postgresql >&2 || return 1
@@ -259,7 +319,7 @@ install_postgres_local() {
     esac
 
     if [[ "${release}" != "alpine" ]]; then
-        systemctl enable --now postgresql >&2 || return 1
+        systemctl enable --now "${pg_service}" >&2 || return 1
     fi
 
     # Wait briefly for the server to accept connections.

+ 44 - 2
internal/web/service/inbound_node.go

@@ -698,7 +698,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			var compatible []*model.Inbound
 			for i := range central {
 				candidate := &central[i]
-				if candidate.OriginNodeGuid == origin &&
+				// An empty origin is a master-created inbound not yet tag-matched: hosted here.
+				candidateOrigin := candidate.OriginNodeGuid
+				if candidateOrigin == "" {
+					candidateOrigin = selfKey
+				}
+				if candidateOrigin == origin &&
 					candidate.Port == snapIb.Port &&
 					candidate.Protocol == snapIb.Protocol &&
 					strings.TrimSpace(candidate.Listen) == strings.TrimSpace(snapIb.Listen) {
@@ -862,6 +867,24 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 	}
 
+	// The inbound each reported email now lives under, so a swept inbound's
+	// accumulator rows follow the email instead of being dropped and re-seeded at 0.
+	snapEmailHome := make(map[string]int, len(snapEmailsAll))
+	for _, snapIb := range snap.Inbounds {
+		if snapIb == nil {
+			continue
+		}
+		home, ok := tagToCentral[snapIb.Tag]
+		if !ok {
+			continue
+		}
+		for i := range snapIb.ClientStats {
+			if _, taken := snapEmailHome[snapIb.ClientStats[i].Email]; !taken {
+				snapEmailHome[snapIb.ClientStats[i].Email] = home.Id
+			}
+		}
+	}
+
 	for _, c := range central {
 		if dirty {
 			continue
@@ -886,7 +909,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if unmanagedTag(c.Tag) {
 			continue
 		}
-		// This drops the central inbound and its clients' traffic history, so say
+		// This drops the central inbound and its unreported clients' history, so say
 		// so: silent removal is indistinguishable from an inbound never arriving.
 		logger.Warningf("setRemoteTraffic: node %d no longer reports inbound %q (id %d, port %d) — removing it centrally", nodeID, c.Tag, c.Id, c.Port)
 		var goneEmails []string
@@ -922,11 +945,30 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				return false, sErr
 			}
 			delEmails := make([]string, 0, len(goneEmails))
+			rehome := make(map[int][]string)
 			for _, e := range goneEmails {
+				if home, still := snapEmailHome[e]; still {
+					rehome[home] = append(rehome[home], e)
+					if row, ok := centralCS[csKey{c.Id, e}]; ok {
+						delete(centralCS, csKey{c.Id, e})
+						row.InboundId = home
+						centralCS[csKey{home, e}] = row
+					}
+					continue
+				}
 				if !sharedEmails[strings.ToLower(strings.TrimSpace(e))] {
 					delEmails = append(delEmails, e)
 				}
 			}
+			for home, emails := range rehome {
+				for _, batch := range chunkStrings(emails, sqliteMaxVars) {
+					if err := tx.Model(xray.ClientTraffic{}).
+						Where("inbound_id = ? AND email IN ?", c.Id, batch).
+						Update("inbound_id", home).Error; err != nil {
+						return false, err
+					}
+				}
+			}
 			for _, batch := range chunkStrings(delEmails, sqliteMaxVars) {
 				if err := tx.Where("inbound_id = ? AND email IN ?", c.Id, batch).
 					Delete(&xray.ClientTraffic{}).Error; err != nil {

+ 90 - 0
internal/web/service/node_inbound_replace_test.go

@@ -0,0 +1,90 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A snapshot tag that matches neither the central tag nor an alias replaces the
+// inbound in one tick; the client's accumulated usage must survive the swap.
+func TestSetRemoteTraffic_InboundReplacedKeepsClientHistory(t *testing.T) {
+	db := initTrafficTestDB(t)
+	const nodeID = 2
+	if err := db.Create(&model.Node{Id: nodeID, Name: "node", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-guid"}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	const email = "baba"
+	settings := `{"clients":[{"email":"baba","enable":true}]}`
+	createNodeInboundWithClient(t, db, nodeID, "n2-in-2053-tcp", 2053, email)
+	svc := &InboundService{}
+	sync := func(up, down int64) {
+		t.Helper()
+		snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
+			Tag: "in-2053-tcp", OriginNodeGuid: "node-guid", Enable: true, Port: 2053, Protocol: model.VLESS,
+			Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: up, Down: down, Enable: true}},
+		}}}
+		if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
+			t.Fatalf("setRemoteTrafficLocked: %v", err)
+		}
+	}
+
+	sync(100, 200)
+	sync(600, 1200)
+	assertUpDown(t, readTraffic(t, db, email), 500, 1000, "before the replace")
+
+	// Desync the central row so the next snapshot neither tag-matches nor aliases it.
+	if err := db.Model(&model.Inbound{}).Where("node_id = ?", nodeID).
+		Updates(map[string]any{"tag": "n2-legacy", "origin_node_guid": "stale-guid"}).Error; err != nil {
+		t.Fatalf("desync central inbound: %v", err)
+	}
+
+	sync(650, 1300)
+	assertUpDown(t, readTraffic(t, db, email), 550, 1100, "replace tick")
+	sync(700, 1400)
+	assertUpDown(t, readTraffic(t, db, email), 600, 1200, "tick after the replace")
+
+	var ib model.Inbound
+	if err := db.Where("node_id = ?", nodeID).First(&ib).Error; err != nil {
+		t.Fatalf("read node inbound: %v", err)
+	}
+	if ib.Tag != "in-2053-tcp" {
+		t.Fatalf("fixture did not replace the inbound: surviving tag %q", ib.Tag)
+	}
+	if ct := readTraffic(t, db, email); ct.InboundId != ib.Id {
+		t.Errorf("client_traffics.inbound_id = %d, want the surviving inbound %d", ct.InboundId, ib.Id)
+	}
+}
+
+// A node inbound created on the master has no origin until its first tag match;
+// that empty origin is still this node, so a renamed tag aliases instead of replacing.
+func TestSetRemoteTraffic_AliasesInboundWithEmptyOrigin(t *testing.T) {
+	db := initTrafficTestDB(t)
+	const nodeID = 2
+	if err := db.Create(&model.Node{Id: nodeID, Name: "node", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-guid"}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	createNodeInbound(t, db, nodeID, "n2-in-2053-tcp", 2053)
+	var before model.Inbound
+	if err := db.Where("node_id = ?", nodeID).First(&before).Error; err != nil {
+		t.Fatalf("read node inbound: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
+		Tag: "in-2053-tcp-2", OriginNodeGuid: "node-guid", Enable: true, Port: 2053, Protocol: model.VLESS,
+		Settings: `{"clients":[]}`,
+	}}}
+	if _, err := (&InboundService{}).setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var rows []model.Inbound
+	if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil {
+		t.Fatalf("list node inbounds: %v", err)
+	}
+	if len(rows) != 1 || rows[0].Id != before.Id {
+		t.Fatalf("node inbounds = %#v, want only the original id %d", rows, before.Id)
+	}
+}

+ 78 - 14
x-ui.sh

@@ -2738,6 +2738,8 @@ pg_systemd_unit() {
         ver="${info%% *}"
         cluster="${info##* }"
         echo "postgresql@${ver}-${cluster}"
+    elif [[ -f "/usr/lib/systemd/system/postgresql-${PG_TARGET_MAJOR}.service" ]]; then
+        echo "postgresql-${PG_TARGET_MAJOR}"
     else
         echo "postgresql"
     fi
@@ -2860,19 +2862,21 @@ purge_postgresql() {
         systemctl disable "$(pg_systemd_unit)" 2> /dev/null
     fi
 
+    local pgdg_pkgs=()
+    command -v rpm > /dev/null 2>&1 && mapfile -t pgdg_pkgs < <(rpm -qa --qf '%{NAME}\n' "postgresql${PG_TARGET_MAJOR}*")
     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
+            dnf remove -y postgresql postgresql-server postgresql-contrib "${pgdg_pkgs[@]}"
             ;;
         centos)
             if [[ "${VERSION_ID}" =~ ^7 ]]; then
                 yum remove -y postgresql postgresql-server postgresql-contrib
             else
-                dnf remove -y postgresql postgresql-server postgresql-contrib
+                dnf remove -y postgresql postgresql-server postgresql-contrib "${pgdg_pkgs[@]}"
             fi
             ;;
         arch | manjaro | parch)
@@ -2925,6 +2929,60 @@ pg_ensure_hba_password_auth() {
     sudo -u postgres psql -tAc 'SELECT pg_reload_conf()' > /dev/null 2>&1 || true
 }
 
+# New installs get this PostgreSQL major from the official PGDG repository; distros or
+# CPU architectures PGDG does not build for fall back to the distribution's own package.
+PG_TARGET_MAJOR=18
+
+pg_has_debian_cluster() {
+    command -v pg_lsclusters > /dev/null 2>&1 && [[ -n "$(pg_lsclusters -h 2> /dev/null)" ]]
+}
+
+# Installs postgresql-${PG_TARGET_MAJOR} from apt.postgresql.org. On failure it removes the
+# repository it added, so an unsupported release cannot break later `apt-get update` runs.
+pg_apt_install_pgdg_server() {
+    local list added_lists=()
+    for list in /etc/apt/sources.list.d/pgdg.list /etc/apt/sources.list.d/pgdg.sources; do
+        [[ -e "${list}" ]] || added_lists+=("${list}")
+    done
+    if apt-get install -y -q postgresql-common ca-certificates gnupg >&2 \
+        && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >&2 \
+        && apt-get install -y -q "postgresql-${PG_TARGET_MAJOR}" >&2; then
+        return 0
+    fi
+    for list in "${added_lists[@]}"; do
+        rm -f "${list}"
+    done
+    apt-get update >&2 || true
+    echo -e "${yellow}PostgreSQL ${PG_TARGET_MAJOR} is not available for this system; installing the distribution's PostgreSQL instead.${plain}" >&2
+    return 1
+}
+
+# Installs postgresql${PG_TARGET_MAJOR}-server from the PGDG yum repository (EL 8+ only); on
+# failure it undoes the repository and module changes it made. The caller runs initdb.
+pg_el_install_pgdg_server() {
+    local elver
+    elver=$(rpm -E %rhel 2> /dev/null)
+    [[ "${elver}" =~ ^[0-9]+$ && "${elver}" -ge 8 ]] && command -v dnf > /dev/null 2>&1 || return 1
+    [[ -f /var/lib/pgsql/data/PG_VERSION ]] && return 1
+    local added_repo=false
+    if ! rpm -q pgdg-redhat-repo > /dev/null 2>&1; then
+        if dnf install -y -q "https://download.postgresql.org/pub/repos/yum/reporpms/EL-${elver}-$(uname -m)/pgdg-redhat-repo-latest.noarch.rpm" >&2; then
+            added_repo=true
+            dnf -qy module disable postgresql > /dev/null 2>&1 || true
+        fi
+    fi
+    if rpm -q pgdg-redhat-repo > /dev/null 2>&1 \
+        && dnf install -y -q "postgresql${PG_TARGET_MAJOR}-server" "postgresql${PG_TARGET_MAJOR}-contrib" >&2; then
+        return 0
+    fi
+    if [[ "${added_repo}" == "true" ]]; then
+        dnf -qy module reset postgresql > /dev/null 2>&1 || true
+        dnf remove -y -q pgdg-redhat-repo >&2 || true
+    fi
+    echo -e "${yellow}PostgreSQL ${PG_TARGET_MAJOR} is not available for this system; installing the distribution's PostgreSQL instead.${plain}" >&2
+    return 1
+}
+
 # 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
@@ -2936,21 +2994,27 @@ pg_install_local() {
     pg_host="127.0.0.1"
     pg_port="5432"
 
+    local pg_service="postgresql"
     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
+            apt-get update >&2 || return 1
+            if pg_has_debian_cluster || ! pg_apt_install_pgdg_server; then
+                apt-get install -y -q postgresql >&2 || return 1
+            fi
             ;;
-        centos)
-            if [[ "${VERSION_ID}" =~ ^7 ]]; then
-                yum install -y postgresql-server postgresql-contrib >&2 || return 1
+        fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos)
+            if [[ "${release}" != "fedora" && "${release}" != "amzn" ]] && pg_el_install_pgdg_server; then
+                pg_service="postgresql-${PG_TARGET_MAJOR}"
+                [[ -f "/var/lib/pgsql/${PG_TARGET_MAJOR}/data/PG_VERSION" ]] \
+                    || "/usr/pgsql-${PG_TARGET_MAJOR}/bin/postgresql-${PG_TARGET_MAJOR}-setup" initdb >&2 || return 1
             else
-                dnf install -y -q postgresql-server postgresql-contrib >&2 || return 1
+                if [[ "${release}" == "centos" && "${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
             fi
-            [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
             ;;
         arch | manjaro | parch)
             pacman -Sy --noconfirm postgresql >&2 || return 1
@@ -2980,7 +3044,7 @@ pg_install_local() {
     esac
 
     if [[ "${release}" != "alpine" ]]; then
-        systemctl enable --now postgresql >&2 || return 1
+        systemctl enable --now "${pg_service}" >&2 || return 1
     fi
 
     local i
@@ -3090,7 +3154,7 @@ pg_upgrade_client() {
                 apt-get install -y -q postgresql-client >&2 || return 1
             elif ! apt-get install -y -q "postgresql-client-${want}" >&2; then
                 LOGI "postgresql-client-${want} is not in the distribution repositories; adding the official PostgreSQL apt repository..."
-                apt-get install -y -q postgresql-common ca-certificates >&2 || return 1
+                apt-get install -y -q postgresql-common ca-certificates gnupg >&2 || return 1
                 /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >&2 || return 1
                 apt-get install -y -q "postgresql-client-${want}" >&2 || return 1
             fi

Some files were not shown because too many files changed in this diff