14 Incheckningar e4b881e58a ... b1fb39c486

Upphovsman SHA1 Meddelande Datum
  MHSanaei b1fb39c486 v3.4.1 14 timmar sedan
  MHSanaei 9381fa284b feat(logs): add auto-update toggle to Access Logs and Logs viewers 14 timmar sedan
  MHSanaei 30796dc2ce chore(deploy): drop the AWS golden-image build stack 14 timmar sedan
  MHSanaei dc6d13b58f chore: bump deps and modernize test loops 14 timmar sedan
  MHSanaei e27f2490b2 feat(logs): label the Xray access-log viewer 'Access Logs' across all languages 15 timmar sedan
  MHSanaei df0e52cda8 fix(logs): render plain log notices verbatim instead of mangling them as timestamps 15 timmar sedan
  MHSanaei 1d69508263 feat(logs): add 1000 rows option and drop 10 from log row count selectors 15 timmar sedan
  MHSanaei 8f65aa7e4b fix(hosts): show proper page title instead of falling back to 3X-UI 15 timmar sedan
  MHSanaei 293c1e44dc perf(metrics): tiered rollup history (7d at ~1.5MB) and cleaner ranges 15 timmar sedan
  MHSanaei 69ad8b76e1 perf(memory): report real RSS and cut footprint via GOGC + periodic release 16 timmar sedan
  MHSanaei b32837e523 fix(node): import per-client traffic history on first sync of a node-hosted inbound 17 timmar sedan
  MHSanaei 9dec15bd4b feat(uninstall): offer to purge PostgreSQL when removing the panel 19 timmar sedan
  MHSanaei e64e998194 feat(clients): add bulk enable/disable and move selection actions into More menu 19 timmar sedan
  MHSanaei a4be5a0deb fix(sub): recover {{TRAFFIC_USED}} for clients with orphaned traffic rows 20 timmar sedan
77 ändrade filer med 1880 tillägg och 2199 borttagningar
  1. 1 4
      .gitattributes
  2. 0 260
      .github/workflows/image.yml
  3. 1 1
      .github/workflows/release.yml
  4. 2 14
      .github/workflows/smoke.yml
  5. 3 5
      README.ar_EG.md
  6. 3 5
      README.es_ES.md
  7. 3 5
      README.fa_IR.md
  8. 3 5
      README.md
  9. 3 5
      README.ru_RU.md
  10. 3 5
      README.tr_TR.md
  11. 3 5
      README.zh_CN.md
  12. 9 16
      deploy/README.md
  13. 4 9
      deploy/cloud-init/README.md
  14. 0 22
      deploy/firstboot/x-ui-firstboot.service
  15. 0 166
      deploy/firstboot/x-ui-firstboot.sh
  16. 0 94
      deploy/lightsail/README.md
  17. 0 192
      deploy/lightsail/build-snapshot.sh
  18. 0 51
      deploy/lightsail/launch-script.sh
  19. 0 59
      deploy/lightsail/snapshot-userdata.sh
  20. 0 92
      deploy/marketplace/aws/README.md
  21. 3 24
      deploy/marketplace/hetzner/README.md
  22. 0 7
      deploy/packer/.gitignore
  23. 0 116
      deploy/packer/README.md
  24. 0 59
      deploy/packer/scripts/cleanup.sh
  25. 0 39
      deploy/packer/scripts/harden.sh
  26. 0 76
      deploy/packer/scripts/provision.sh
  27. 0 109
      deploy/packer/variables.pkr.hcl
  28. 0 160
      deploy/packer/x-ui.pkr.hcl
  29. 0 86
      deploy/test/smoke-firstboot.sh
  30. 8 4
      docker-compose.yml
  31. 282 282
      frontend/package-lock.json
  32. 3 3
      frontend/package.json
  33. 116 0
      frontend/public/openapi.json
  34. 21 0
      frontend/src/hooks/useClients.ts
  35. 1 0
      frontend/src/hooks/usePageTitle.ts
  36. 14 0
      frontend/src/pages/api-docs/endpoints.ts
  37. 80 23
      frontend/src/pages/clients/ClientsPage.tsx
  38. 19 2
      frontend/src/pages/index/LogModal.tsx
  39. 7 5
      frontend/src/pages/index/SystemHistoryModal.tsx
  40. 19 2
      frontend/src/pages/index/XrayLogModal.tsx
  41. 2 3
      frontend/src/pages/index/XrayMetricsModal.tsx
  42. 1 1
      frontend/src/pages/index/XrayStatusCard.tsx
  43. 14 8
      frontend/src/pages/index/logParse.ts
  44. 8 0
      frontend/src/schemas/client.ts
  45. 13 16
      go.mod
  46. 14 14
      go.sum
  47. 1 1
      internal/config/version
  48. 7 0
      internal/sub/remark_vars.go
  49. 26 0
      internal/sub/service.go
  50. 54 0
      internal/sub/service_orphaned_stats_test.go
  51. 2 2
      internal/tunnelmonitor/monitor_test.go
  52. 62 26
      internal/util/sys/memlimit.go
  53. 34 0
      internal/util/sys/procmem.go
  54. 32 0
      internal/web/controller/client.go
  55. 19 0
      internal/web/job/free_os_memory.go
  56. 297 0
      internal/web/service/client_bulk.go
  57. 9 2
      internal/web/service/inbound_node.go
  58. 229 97
      internal/web/service/metric_history.go
  59. 167 0
      internal/web/service/metric_history_test.go
  60. 26 0
      internal/web/service/node_client_traffic_sum_test.go
  61. 16 12
      internal/web/service/server.go
  62. 12 0
      internal/web/translation/ar-EG.json
  63. 12 0
      internal/web/translation/en-US.json
  64. 12 0
      internal/web/translation/es-ES.json
  65. 12 0
      internal/web/translation/fa-IR.json
  66. 12 0
      internal/web/translation/id-ID.json
  67. 12 0
      internal/web/translation/ja-JP.json
  68. 12 0
      internal/web/translation/pt-BR.json
  69. 12 0
      internal/web/translation/ru-RU.json
  70. 12 0
      internal/web/translation/tr-TR.json
  71. 12 0
      internal/web/translation/uk-UA.json
  72. 12 0
      internal/web/translation/vi-VN.json
  73. 12 0
      internal/web/translation/zh-CN.json
  74. 12 0
      internal/web/translation/zh-TW.json
  75. 9 0
      internal/web/web.go
  76. 2 4
      main.go
  77. 69 1
      x-ui.sh

+ 1 - 4
.gitattributes

@@ -5,8 +5,5 @@ frontend/src/generated/** text eol=lf
 frontend/public/openapi.json text eol=lf
 frontend/src/test/__snapshots__/** text eol=lf
 
-# Cloud-image deploy assets are consumed on Linux — force LF regardless of host.
-*.service text eol=lf
-deploy/**/*.service text eol=lf
-deploy/**/*.hcl text eol=lf
+# Cloud-init deploy assets are consumed on Linux — force LF regardless of host.
 deploy/**/*.yaml text eol=lf

+ 0 - 260
.github/workflows/image.yml

@@ -1,260 +0,0 @@
-name: Build Cloud Images
-
-# Build golden cloud images from a published release, for amd64 and arm64:
-#   * qemu       -> qcow2 attached to the GitHub release (always)
-#   * amazon-ebs -> AWS AMI (only when AWS credentials are configured)
-#
-# Images contain NO database and NO baked credentials; first boot generates
-# unique per-instance credentials (see deploy/firstboot + deploy/packer).
-
-on:
-  release:
-    types: [published]
-  workflow_dispatch:
-    inputs:
-      tag:
-        description: "Release tag to build images for (e.g. v3.3.1)"
-        required: true
-        type: string
-
-permissions:
-  contents: write
-
-concurrency:
-  group: image-${{ github.event.release.tag_name || inputs.tag }}
-  cancel-in-progress: false
-
-jobs:
-  # Resolve the tag and wait until BOTH arch tarballs are actually published
-  # (the release matrix uploads assets one by one, so 'published' can fire
-  # before the tarballs exist).
-  setup:
-    runs-on: ubuntu-latest
-    outputs:
-      tag: ${{ steps.resolve.outputs.tag }}
-    steps:
-      - name: Resolve tag
-        id: resolve
-        run: |
-          if [ "${{ github.event_name }}" = "release" ]; then
-            TAG="${{ github.event.release.tag_name }}"
-          else
-            TAG="${{ inputs.tag }}"
-          fi
-          [ -n "$TAG" ] || { echo "::error::no tag resolved"; exit 1; }
-          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
-
-      - name: Wait for released binary assets (amd64 + arm64)
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ steps.resolve.outputs.tag }}
-        run: |
-          want="x-ui-linux-amd64.tar.gz x-ui-linux-arm64.tar.gz"
-          for i in $(seq 1 30); do
-            names=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')
-            missing=""
-            for w in $want; do
-              echo "$names" | grep -qx "$w" || missing="$missing $w"
-            done
-            if [ -z "$missing" ]; then
-              echo "All assets present on $TAG"
-              exit 0
-            fi
-            echo "Waiting for$missing on $TAG ($i/30)..."
-            sleep 20
-          done
-          echo "::error::missing release assets on $TAG after 10 minutes:$missing"
-          exit 1
-
-  # Gate the AWS AMI build so forks without secrets skip it cleanly
-  # (secrets cannot be referenced directly in job-level `if`).
-  check-aws:
-    runs-on: ubuntu-latest
-    outputs:
-      enabled: ${{ steps.c.outputs.enabled }}
-      use_oidc: ${{ steps.c.outputs.use_oidc }}
-    steps:
-      - id: c
-        env:
-          ROLE: ${{ secrets.AWS_ROLE_ARN }}
-          KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
-        run: |
-          if [ -n "$ROLE" ]; then
-            echo "enabled=true" >> "$GITHUB_OUTPUT"
-            echo "use_oidc=true" >> "$GITHUB_OUTPUT"
-          elif [ -n "$KEY" ]; then
-            echo "enabled=true" >> "$GITHUB_OUTPUT"
-            echo "use_oidc=false" >> "$GITHUB_OUTPUT"
-          else
-            echo "enabled=false" >> "$GITHUB_OUTPUT"
-            echo "use_oidc=false" >> "$GITHUB_OUTPUT"
-            echo "::notice::No AWS credentials configured; skipping the AMI build."
-          fi
-
-  qemu-image:
-    needs: setup
-    timeout-minutes: 90
-    strategy:
-      fail-fast: false
-      matrix:
-        include:
-          - arch: amd64
-            runner: ubuntu-latest
-            qemu_pkgs: qemu-system-x86 qemu-utils
-          - arch: arm64
-            runner: ubuntu-24.04-arm
-            qemu_pkgs: qemu-system-arm qemu-efi-aarch64 qemu-utils
-    runs-on: ${{ matrix.runner }}
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v7
-
-      - name: Install QEMU
-        run: |
-          sudo apt-get update
-          sudo apt-get install -y --no-install-recommends ${{ matrix.qemu_pkgs }}
-
-      - name: Setup Packer
-        uses: hashicorp/setup-packer@v3
-        with:
-          version: latest
-
-      - name: Verify released binary asset
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ needs.setup.outputs.tag }}
-        run: |
-          mkdir -p _asset
-          gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
-            --pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
-          ls -la _asset
-
-      - name: Select accelerator
-        id: accel
-        run: |
-          if [ -e /dev/kvm ]; then echo "value=kvm" >> "$GITHUB_OUTPUT"; else echo "value=tcg" >> "$GITHUB_OUTPUT"; fi
-
-      - name: Packer init
-        run: packer init deploy/packer/
-
-      - name: Build qcow2 image
-        env:
-          TAG: ${{ needs.setup.outputs.tag }}
-          ACCEL: ${{ steps.accel.outputs.value }}
-        run: |
-          packer build -only='qemu.x-ui' \
-            -var "xui_version=${TAG}" \
-            -var "xui_arch=${{ matrix.arch }}" \
-            -var "qemu_accelerator=${ACCEL}" \
-            deploy/packer/
-
-      - name: Compress qcow2
-        id: pack
-        env:
-          TAG: ${{ needs.setup.outputs.tag }}
-        run: |
-          cd deploy/packer/output-qemu
-          src="3x-ui-ubuntu-24.04-${{ matrix.arch }}.qcow2"
-          out="3x-ui-ubuntu-24.04-${TAG}-${{ matrix.arch }}.qcow2.xz"
-          xz -T0 -6 -c "$src" > "$out"
-          sha256sum "$out" > "${out}.sha256"
-          echo "file=deploy/packer/output-qemu/${out}" >> "$GITHUB_OUTPUT"
-          echo "sha=deploy/packer/output-qemu/${out}.sha256" >> "$GITHUB_OUTPUT"
-          ls -la
-
-      - name: Attach qcow2 to release
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ needs.setup.outputs.tag }}
-        run: |
-          gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \
-            "${{ steps.pack.outputs.file }}" "${{ steps.pack.outputs.sha }}"
-
-      - name: Summary
-        env:
-          TAG: ${{ needs.setup.outputs.tag }}
-          ACCEL: ${{ steps.accel.outputs.value }}
-        run: |
-          {
-            echo "## QEMU image (${{ matrix.arch }})"
-            echo "- Tag: \`${TAG}\`"
-            echo "- Accelerator: \`${ACCEL}\`"
-            echo "- Attached: \`$(basename "${{ steps.pack.outputs.file }}")\`"
-          } >> "$GITHUB_STEP_SUMMARY"
-
-  ami-image:
-    needs: [setup, check-aws]
-    if: needs.check-aws.outputs.enabled == 'true'
-    runs-on: ubuntu-latest
-    timeout-minutes: 60
-    permissions:
-      contents: read
-      id-token: write
-    strategy:
-      fail-fast: false
-      matrix:
-        include:
-          - arch: amd64
-            instance_type: t3.small
-          - arch: arm64
-            instance_type: t4g.small
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v7
-
-      - name: Setup Packer
-        uses: hashicorp/setup-packer@v3
-        with:
-          version: latest
-
-      - name: Configure AWS credentials (OIDC)
-        if: needs.check-aws.outputs.use_oidc == 'true'
-        uses: aws-actions/configure-aws-credentials@v6
-        with:
-          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
-          aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
-
-      - name: Configure AWS credentials (access keys)
-        if: needs.check-aws.outputs.use_oidc != 'true'
-        uses: aws-actions/configure-aws-credentials@v6
-        with:
-          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
-          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-          aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
-
-      - name: Verify released binary asset
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ needs.setup.outputs.tag }}
-        run: |
-          mkdir -p _asset
-          gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
-            --pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
-          ls -la _asset
-
-      - name: Packer init
-        run: packer init deploy/packer/
-
-      - name: Build AMI
-        env:
-          TAG: ${{ needs.setup.outputs.tag }}
-          REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
-        run: |
-          packer build -only='amazon-ebs.x-ui' \
-            -var "xui_version=${TAG}" \
-            -var "xui_arch=${{ matrix.arch }}" \
-            -var "instance_type=${{ matrix.instance_type }}" \
-            -var "region=${REGION}" \
-            deploy/packer/
-
-      - name: Publish AMI id to summary
-        env:
-          REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
-        run: |
-          AMI_ID=$(jq -r '.builds[] | select(.builder_type=="amazon-ebs") | .artifact_id' packer-manifest.json | tail -1 | cut -d: -f2)
-          {
-            echo "## AWS AMI (${{ matrix.arch }})"
-            echo "- Region: \`${REGION}\`"
-            echo "- Instance type: \`${{ matrix.instance_type }}\`"
-            echo "- AMI ID: \`${AMI_ID}\`"
-          } >> "$GITHUB_STEP_SUMMARY"

+ 1 - 1
.github/workflows/release.yml

@@ -339,7 +339,7 @@ jobs:
         uses: actions/checkout@v7
 
       - name: Download all build artifacts
-        uses: actions/download-artifact@v7
+        uses: actions/download-artifact@v8
         with:
           path: dev-artifacts
           merge-multiple: true

+ 2 - 14
.github/workflows/smoke.yml

@@ -1,7 +1,7 @@
 name: Deploy Smoke Tests
 
-# Container smoke tests for the unattended install path and first-boot
-# credential generation. Runs only when the install/deploy assets change.
+# Container smoke test for the unattended (cloud-init) install path.
+# Runs only when the install/deploy assets change.
 
 on:
   push:
@@ -30,15 +30,3 @@ jobs:
       - uses: actions/checkout@v7
       - name: Non-interactive install smoke test
         run: bash deploy/test/smoke-noninteractive.sh
-
-  first-boot:
-    strategy:
-      fail-fast: false
-      matrix:
-        runner: [ubuntu-latest, ubuntu-24.04-arm]
-    runs-on: ${{ matrix.runner }}
-    timeout-minutes: 15
-    steps:
-      - uses: actions/checkout@v7
-      - name: First-boot credential smoke test
-        run: bash deploy/test/smoke-firstboot.sh

+ 3 - 5
README.ar_EG.md

@@ -89,17 +89,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 
 للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
 
-### التثبيت غير التفاعلي وصور السحابة
+### التثبيت غير التفاعلي
 
-يعمل المثبِّت أيضًا **بشكل غير تفاعلي** لـ cloud-init والصور الجاهزة (golden images).
+يعمل المثبِّت أيضًا **بشكل غير تفاعلي** لـ cloud-init.
 عيّن `XUI_NONINTERACTIVE=1` (أو مرّره عبر أنبوب دون TTY) وسيتولى التثبيت من البداية إلى النهاية
 دون أي مطالبات، مُنشئًا بيانات اعتماد عشوائية وكاتبًا إياها في
 `/etc/x-ui/install-result.env`. راجع [`deploy/`](deploy/) لـ:
 
 - [بيانات مستخدم cloud-init](deploy/cloud-init/) — تثبيت غير تفاعلي على أي سحابة (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [صورة Packer الجاهزة](deploy/packer/) — بناء صورة AWS EC2 AMI و qcow2 (amd64/arm64) مع بيانات اعتماد لكل نسخة يتم إنشاؤها عند الإقلاع الأول
-- [Amazon Lightsail](deploy/lightsail/) — سكربت إطلاق وأداة بناء لقطات قابلة لإعادة الاستخدام
-- [قائمة تحقق AWS Marketplace](deploy/marketplace/aws/)
+- [ملاحظات Hetzner Cloud](deploy/marketplace/hetzner/) — نشر يعتمد على cloud-init على Hetzner
 
 ## المنصات المدعومة
 

+ 3 - 5
README.es_ES.md

@@ -89,17 +89,15 @@ Durante la instalación se generan un nombre de usuario, una contraseña y una r
 
 Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
 
-### Instalación desatendida e imágenes de nube
+### Instalación desatendida
 
-El instalador también se ejecuta de forma **no interactiva** para cloud-init e imágenes doradas (golden images).
+El instalador también se ejecuta de forma **no interactiva** para cloud-init.
 Define `XUI_NONINTERACTIVE=1` (o canalízalo sin TTY) y realizará la instalación de principio a fin sin
 ninguna pregunta, generando credenciales aleatorias y escribiéndolas en
 `/etc/x-ui/install-result.env`. Consulta [`deploy/`](deploy/) para:
 
 - [User-data de cloud-init](deploy/cloud-init/) — instalación desatendida en cualquier nube (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [Imagen dorada de Packer](deploy/packer/) — crea una AMI de AWS EC2 + qcow2 (amd64/arm64) con credenciales por instancia generadas en el primer arranque
-- [Amazon Lightsail](deploy/lightsail/) — script de lanzamiento + constructor de snapshots reutilizable
-- [Lista de verificación de AWS Marketplace](deploy/marketplace/aws/)
+- [Notas de Hetzner Cloud](deploy/marketplace/hetzner/) — despliegue basado en cloud-init en Hetzner
 
 ## Plataformas Compatibles
 

+ 3 - 5
README.fa_IR.md

@@ -89,17 +89,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 
 برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
 
-### نصب بدون نظارت و ایمیج‌های ابری
+### نصب بدون نظارت
 
-نصب‌کننده به‌صورت **غیرتعاملی** نیز برای cloud-init و ایمیج‌های آماده (golden images) اجرا می‌شود.
+نصب‌کننده به‌صورت **غیرتعاملی** نیز برای cloud-init اجرا می‌شود.
 ‏`XUI_NONINTERACTIVE=1` را تنظیم کنید (یا بدون TTY از طریق pipe اجرا کنید) تا نصب به‌صورت سرتاسری و بدون
 هیچ پرسشی انجام شود، اطلاعات ورود تصادفی تولید کرده و آن‌ها را در
 `/etc/x-ui/install-result.env` می‌نویسد. برای موارد زیر به [`deploy/`](deploy/) مراجعه کنید:
 
 - [user-data مربوط به Cloud-init](deploy/cloud-init/) — نصب بدون نظارت روی هر ابری (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [ایمیج آماده‌ی Packer](deploy/packer/) — ساخت یک AMI برای AWS EC2 به‌همراه qcow2 (amd64/arm64) با اطلاعات ورودِ مخصوص هر اینستنس که در نخستین بوت تولید می‌شود
-- [Amazon Lightsail](deploy/lightsail/) — اسکریپت راه‌اندازی به‌همراه سازنده‌ی اسنپ‌شات قابل‌استفاده‌ی مجدد
-- [چک‌لیست AWS Marketplace](deploy/marketplace/aws/)
+- [یادداشت‌های Hetzner Cloud](deploy/marketplace/hetzner/) — استقرار مبتنی بر cloud-init روی Hetzner
 
 ## پلتفرم‌های پشتیبانی‌شده
 

+ 3 - 5
README.md

@@ -89,17 +89,15 @@ During installation a random username, password, and access path are generated.
 
 For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
 
-### Unattended install & cloud images
+### Unattended install
 
-The installer also runs **non-interactively** for cloud-init and golden images.
+The installer also runs **non-interactively** for cloud-init.
 Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with
 zero prompts, generating random credentials and writing them to
 `/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for:
 
 - [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [Packer golden image](deploy/packer/) — build an AWS EC2 AMI + qcow2 (amd64/arm64) with per-instance credentials generated on first boot
-- [Amazon Lightsail](deploy/lightsail/) — launch script + reusable snapshot builder
-- [AWS Marketplace checklist](deploy/marketplace/aws/)
+- [Hetzner Cloud notes](deploy/marketplace/hetzner/) — cloud-init deployment on Hetzner
 
 ## Supported Platforms
 

+ 3 - 5
README.ru_RU.md

@@ -89,17 +89,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 
 Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
 
-### Автоматическая установка и облачные образы
+### Автоматическая установка
 
-Установщик также работает в **неинтерактивном** режиме для cloud-init и готовых образов.
+Установщик также работает в **неинтерактивном** режиме для cloud-init.
 Задайте `XUI_NONINTERACTIVE=1` (или передайте по конвейеру без TTY), и установка пройдёт от начала до конца
 без единого запроса: будут сгенерированы случайные учётные данные и записаны в
 `/etc/x-ui/install-result.env`. Смотрите [`deploy/`](deploy/) для:
 
 - [Cloud-init user-data](deploy/cloud-init/) — автоматическая установка в любом облаке (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [Готовый образ Packer](deploy/packer/) — сборка AWS EC2 AMI + qcow2 (amd64/arm64) с учётными данными для каждого экземпляра, генерируемыми при первой загрузке
-- [Amazon Lightsail](deploy/lightsail/) — скрипт запуска + переиспользуемый сборщик снимков
-- [Чек-лист для AWS Marketplace](deploy/marketplace/aws/)
+- [Заметки по Hetzner Cloud](deploy/marketplace/hetzner/) — развёртывание на Hetzner на базе cloud-init
 
 ## Поддерживаемые платформы
 

+ 3 - 5
README.tr_TR.md

@@ -89,17 +89,15 @@ Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşt
 
 Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
 
-### Etkileşimsiz kurulum ve hazır bulut imajları
+### Etkileşimsiz kurulum
 
-Yükleyici, cloud-init ve hazır (golden) imajlar için **etkileşimsiz** olarak da çalışır.
+Yükleyici, cloud-init için **etkileşimsiz** olarak da çalışır.
 `XUI_NONINTERACTIVE=1` ayarlayın (veya TTY olmadan boru hattına aktarın); kurulum baştan
 sona hiçbir soru sormadan tamamlanır, rastgele kimlik bilgileri oluşturup bunları
 `/etc/x-ui/install-result.env` dosyasına yazar. Şunlar için [`deploy/`](deploy/) klasörüne bakın:
 
 - [Cloud-init user-data](deploy/cloud-init/) — herhangi bir bulutta etkileşimsiz kurulum (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [Packer hazır imajı](deploy/packer/) — ilk açılışta her örnek (instance) için kimlik bilgileri oluşturan bir AWS EC2 AMI + qcow2 (amd64/arm64) imajı oluşturun
-- [Amazon Lightsail](deploy/lightsail/) — başlatma betiği + yeniden kullanılabilir anlık görüntü (snapshot) oluşturucu
-- [AWS Marketplace kontrol listesi](deploy/marketplace/aws/)
+- [Hetzner Cloud notları](deploy/marketplace/hetzner/) — Hetzner üzerinde cloud-init tabanlı dağıtım
 
 ## Desteklenen Platformlar
 

+ 3 - 5
README.zh_CN.md

@@ -89,17 +89,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 
 完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
 
-### 无人值守安装与云镜像
+### 无人值守安装
 
-安装程序也可以**非交互式**运行,适用于 cloud-init 和黄金镜像(golden image)
+安装程序也可以**非交互式**运行,适用于 cloud-init。
 设置 `XUI_NONINTERACTIVE=1`(或在无 TTY 的情况下通过管道传入),它就会全程
 零提示地完成端到端安装,生成随机凭据并写入
 `/etc/x-ui/install-result.env`。请参阅 [`deploy/`](deploy/):
 
 - [Cloud-init user-data](deploy/cloud-init/) — 在任意云平台上无人值守安装(Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
-- [Packer golden image](deploy/packer/) — 构建 AWS EC2 AMI + qcow2(amd64/arm64),首次启动时生成每个实例独有的凭据
-- [Amazon Lightsail](deploy/lightsail/) — 启动脚本 + 可复用的快照构建器
-- [AWS Marketplace 清单](deploy/marketplace/aws/)
+- [Hetzner Cloud 说明](deploy/marketplace/hetzner/) — 在 Hetzner 上基于 cloud-init 的部署
 
 ## 支持的平台
 

+ 9 - 16
deploy/README.md

@@ -1,27 +1,20 @@
-# Cloud deployment & golden images
+# Cloud deployment (unattended install)
 
-Tooling to ship the 3x-ui panel as a cloud image or via unattended install,
-with **per-instance credentials generated on first boot** (never `admin/admin`,
-never a shared session secret). Everything here supports **amd64 and arm64**.
+Tooling to ship the 3x-ui panel via unattended install, with **per-instance
+credentials generated on first boot** (never `admin/admin`, never a shared
+session secret). Works on amd64 and arm64.
 
 | Path | What it is | Use when |
 | --- | --- | --- |
 | [`cloud-init/`](cloud-init/) | Generic cloud-init user-data (unattended `install.sh`) | Any cloud, no image build |
-| [`packer/`](packer/) | Packer build → AWS AMI + qcow2/raw | Reusable / Marketplace images |
-| [`lightsail/`](lightsail/) | Launch script + snapshot builder | Amazon Lightsail |
-| [`firstboot/`](firstboot/) | First-boot unit + script that mints per-instance creds | Used by the Packer/Lightsail images |
-| [`marketplace/aws/`](marketplace/aws/) | AWS Marketplace submission checklist | Publishing an EC2 AMI |
 | [`marketplace/hetzner/`](marketplace/hetzner/) | Hetzner Cloud notes | Hetzner deployments |
-| [`test/`](test/) | Container smoke tests | Verifying the install/firstboot paths |
+| [`test/`](test/) | Container smoke test | Verifying the install path |
 
-## Two models
+## How it works
 
-- **Non-interactive install (cloud-init):** `install.sh` runs unattended when
-  `XUI_NONINTERACTIVE=1` or stdin is not a TTY. Each instance installs and
-  configures itself with random credentials. See [`cloud-init/README.md`](cloud-init/README.md).
-- **Golden image (Packer):** the image contains the panel but **no DB and no
-  secrets**; `firstboot` generates unique credentials on first boot. See
-  [`packer/README.md`](packer/README.md).
+`install.sh` runs unattended when `XUI_NONINTERACTIVE=1` or stdin is not a TTY.
+Each instance installs and configures itself with random credentials. See
+[`cloud-init/README.md`](cloud-init/README.md).
 
 ## Unattended install knobs
 

+ 4 - 9
deploy/cloud-init/README.md

@@ -1,12 +1,8 @@
-# 3x-ui via cloud-init (generic, no golden image)
+# 3x-ui via cloud-init
 
-This is the **secondary** deployment path: a single [`cloud-init.yaml`](cloud-init.yaml)
-user-data file that installs 3x-ui non-interactively on a fresh Ubuntu/Debian
-VM and generates **unique random credentials per instance**. Use it when you do
-not want to build a golden image — it works on any cloud-init platform.
-
-> For AWS Marketplace / reusable images, use the Packer build in
-> [`../packer/`](../packer/) instead.
+A single [`cloud-init.yaml`](cloud-init.yaml) user-data file that installs 3x-ui
+non-interactively on a fresh Ubuntu/Debian VM and generates **unique random
+credentials per instance**. It works on any cloud-init platform.
 
 ## How it works
 
@@ -53,7 +49,6 @@ Edit the `export XUI_*` lines inside the `write_files` block of
   `hcloud server create --image ubuntu-24.04 --user-data-from-file cloud-init.yaml ...`
 - **AWS EC2** — *Advanced details → User data*: paste the file. Or
   `aws ec2 run-instances --user-data file://cloud-init.yaml ...`
-  (For a reusable Marketplace image use the Packer AMI build instead.)
 - **DigitalOcean** — *Create Droplet → Advanced options → Add Initialization
   scripts (user data)*: paste the file. Or `doctl compute droplet create --user-data-file cloud-init.yaml ...`
 - **Vultr** — *Deploy → Additional Features → Cloud-Init User-Data*: paste the file.

+ 0 - 22
deploy/firstboot/x-ui-firstboot.service

@@ -1,22 +0,0 @@
-[Unit]
-Description=3x-ui first-boot per-instance credential generation
-Documentation=https://github.com/MHSanaei/3x-ui
-# Run after the network and cloud-init are up, but BEFORE the panel starts, so
-# the panel never serves the default admin/admin account.
-After=network-online.target cloud-init.service
-Wants=network-online.target
-Before=x-ui.service
-# Skip entirely once the sentinel exists (cheap guard; the script re-checks too).
-ConditionPathExists=!/etc/x-ui/.firstboot-done
-
-[Service]
-Type=oneshot
-RemainAfterExit=yes
-# Inherit the same DB configuration the panel uses (sqlite default / postgres).
-EnvironmentFile=-/etc/default/x-ui
-EnvironmentFile=-/etc/conf.d/x-ui
-EnvironmentFile=-/etc/sysconfig/x-ui
-ExecStart=/usr/local/x-ui/x-ui-firstboot.sh
-
-[Install]
-WantedBy=multi-user.target

+ 0 - 166
deploy/firstboot/x-ui-firstboot.sh

@@ -1,166 +0,0 @@
-#!/usr/bin/env bash
-#
-# x-ui-firstboot.sh — generate per-instance 3x-ui panel credentials on first boot.
-#
-# A golden image (AMI / qcow2) MUST ship without an initialized x-ui.db: the
-# panel seeds a hardcoded admin/admin user and generates its session secret +
-# panel GUID on first start, so a baked DB would make every clone share the same
-# credentials and secret. This script runs ONCE, before x-ui.service starts, and
-# replaces the default admin with fresh random credentials on a random high port.
-#
-# Idempotent: a sentinel file guards against re-running. If a non-default admin
-# already exists (operator pre-configured the box), regeneration is skipped.
-#
-# Wired up by deploy/packer/scripts/provision.sh; ordered Before=x-ui.service.
-
-set -u
-
-SENTINEL="/etc/x-ui/.firstboot-done"
-CRED_FILE="/etc/x-ui/credentials.txt"
-MOTD_FILE="/etc/motd"
-XUI_DIR="${XUI_MAIN_FOLDER:-/usr/local/x-ui}"
-XUI_BIN="${XUI_DIR}/x-ui"
-
-log() { echo "[x-ui-firstboot] $*"; }
-
-# Already provisioned — nothing to do (idempotent on re-run / re-image).
-if [ -f "$SENTINEL" ]; then
-    log "sentinel $SENTINEL present; skipping."
-    exit 0
-fi
-
-if [ ! -x "$XUI_BIN" ]; then
-    log "ERROR: x-ui binary not found at $XUI_BIN"
-    exit 1
-fi
-
-# Inherit DB configuration (sqlite default; postgres via XUI_DB_TYPE/XUI_DB_DSN)
-# from the same env files the systemd unit loads, so the binary talks to the
-# same database the panel will use.
-for ef in /etc/default/x-ui /etc/conf.d/x-ui /etc/sysconfig/x-ui; do
-    if [ -r "$ef" ]; then
-        set -a
-        # shellcheck disable=SC1090
-        . "$ef"
-        set +a
-    fi
-done
-
-install -d -m 755 /etc/x-ui 2> /dev/null || true
-
-# Defense-in-depth: make sure the panel is not running while we mutate the DB.
-if command -v systemctl > /dev/null 2>&1; then
-    systemctl stop x-ui > /dev/null 2>&1 || true
-fi
-
-gen_random_string() {
-    local length="$1"
-    openssl rand -base64 $((length * 2)) | tr -dc 'a-zA-Z0-9' | head -c "$length"
-}
-
-# Best-effort public IPv4 for the displayed access URL (cosmetic only — the
-# panel binds 0.0.0.0). Falls back to the primary local IP, then a placeholder.
-detect_ip() {
-    local ip=""
-    local url
-    for url in https://api4.ipify.org https://ipv4.icanhazip.com https://4.ident.me; do
-        ip=$(curl -fsS4 --max-time 3 "$url" 2> /dev/null | tr -d '[:space:]')
-        if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
-            echo "$ip"
-            return 0
-        fi
-    done
-    ip=$(hostname -I 2> /dev/null | awk '{print $1}')
-    if [ -n "$ip" ]; then
-        echo "$ip"
-        return 0
-    fi
-    echo "<server-ip>"
-}
-
-# Detect whether the seeded admin/admin default is still in place.
-default_creds=$("$XUI_BIN" setting -show true 2> /dev/null | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
-
-# The parse MUST yield exactly "true" or "false". If the command failed or its
-# output format changed, refuse to proceed: do NOT write the sentinel, so the
-# next boot retries instead of silently leaving admin/admin in place.
-if [ "$default_creds" != "true" ] && [ "$default_creds" != "false" ]; then
-    log "ERROR: could not determine credential state (hasDefaultCredential='${default_creds}'); not writing sentinel, will retry next boot."
-    exit 1
-fi
-
-if [ "$default_creds" = "false" ]; then
-    log "non-default admin already configured; skipping credential regeneration."
-    {
-        echo "3x-ui first-boot: a non-default admin account already exists on this"
-        echo "instance, so credentials were left unchanged."
-    } > "$MOTD_FILE" 2> /dev/null || true
-    : > "$SENTINEL" 2> /dev/null || true
-    chmod 600 "$SENTINEL" 2> /dev/null || true
-    exit 0
-fi
-
-log "generating per-instance credentials..."
-
-NEW_USER="${XUI_USERNAME:-$(gen_random_string 10)}"
-NEW_PASS="${XUI_PASSWORD:-$(gen_random_string 16)}"
-NEW_PATH="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}"
-NEW_PORT="${XUI_PANEL_PORT:-$(shuf -i 1024-62000 -n 1)}"
-
-# Clean settings slate: drops any baked port/webBasePath and forces the panel
-# to regenerate its session secret + panel GUID on next start (per-instance).
-"$XUI_BIN" setting -reset > /dev/null 2>&1 || true
-
-# Apply fresh random identity. UpdateFirstUser renames the seeded admin row and
-# rehashes the password, so admin/admin no longer exists after this call.
-if ! "$XUI_BIN" setting -username "$NEW_USER" -password "$NEW_PASS" -port "$NEW_PORT" -webBasePath "$NEW_PATH" > /dev/null 2>&1; then
-    log "ERROR: failed to apply new panel settings."
-    exit 1
-fi
-
-API_TOKEN=$("$XUI_BIN" setting -getApiToken true 2> /dev/null | grep -Eo 'apiToken: .+' | awk '{print $2}')
-SERVER_IP=$(detect_ip)
-ACCESS_URL="http://${SERVER_IP}:${NEW_PORT}/${NEW_PATH}"
-
-# Persist credentials for the operator (root-only). Values are shell-escaped
-# with %q so the file stays safe to `source` even if a value contains shell
-# metacharacters (the smoke test and operators source this file).
-umask 077
-{
-    echo "# 3x-ui per-instance credentials (generated on first boot)"
-    printf 'XUI_USERNAME=%q\n' "$NEW_USER"
-    printf 'XUI_PASSWORD=%q\n' "$NEW_PASS"
-    printf 'XUI_PANEL_PORT=%q\n' "$NEW_PORT"
-    printf 'XUI_WEB_BASE_PATH=%q\n' "$NEW_PATH"
-    printf 'XUI_ACCESS_URL=%q\n' "$ACCESS_URL"
-    printf 'XUI_API_TOKEN=%q\n' "$API_TOKEN"
-} > "$CRED_FILE"
-chmod 600 "$CRED_FILE" 2> /dev/null || true
-
-# Friendly login banner shown on SSH / console before the panel is reachable.
-# /etc/motd is world-readable, so it MUST NOT contain the password or API token;
-# those secrets live only in ${CRED_FILE} (mode 600). Show non-secret info only.
-cat > "$MOTD_FILE" 2> /dev/null << EOF
-
-========================================================================
-  3x-ui panel — per-instance credentials (generated on first boot)
-========================================================================
-  Access URL : ${ACCESS_URL}
-  Username   : ${NEW_USER}
-
-  The password and API token are NOT shown here (this banner is
-  world-readable). Read them as root with:
-      sudo cat ${CRED_FILE}
-
-  Change the password after login. If no public IP is shown above,
-  replace <server-ip> with the address you reach this server on.
-========================================================================
-
-EOF
-
-# Mark complete so we never regenerate on subsequent boots.
-: > "$SENTINEL" 2> /dev/null || true
-chmod 600 "$SENTINEL" 2> /dev/null || true
-
-log "done. Panel will start on port ${NEW_PORT} with a unique admin account."
-exit 0

+ 0 - 94
deploy/lightsail/README.md

@@ -1,94 +0,0 @@
-# 3x-ui on Amazon Lightsail
-
-Two self-service ways to run 3x-ui on Lightsail, both producing **unique
-per-instance credentials** (never `admin/admin`, never a shared secret).
-
-> **Reality check.** The Lightsail *blueprint* list (WordPress, LAMP, GitLab…)
-> is curated by AWS — you **cannot** self-publish your panel there, and Lightsail
-> **cannot** launch from an arbitrary EC2 AMI. What you *can* do yourself is the
-> two paths below. (For a public AWS listing you'd use the EC2 **AMI** +
-> Marketplace path in [`../marketplace/aws/`](../marketplace/aws/), which is a
-> different product from Lightsail.)
-
----
-
-## Path A — launch script (simplest, self-service)
-
-Install on a fresh instance at creation time. No image to build.
-
-1. **Create instance** → platform **Linux/Unix** → blueprint **OS Only → Ubuntu 24.04**.
-2. **Add launch script** → paste [`launch-script.sh`](launch-script.sh).
-3. Create the instance.
-4. After it boots, read the credentials:
-   ```bash
-   ssh ubuntu@<public-ip> 'sudo cat /etc/x-ui/install-result.env'
-   ```
-5. **Open the panel port** (see the firewall note below) and log in.
-
-CLI equivalent:
-
-```bash
-aws lightsail create-instances \
-  --instance-names my-3xui \
-  --availability-zone eu-central-1a \
-  --blueprint-id ubuntu_24_04 \
-  --bundle-id small_3_0 \
-  --user-data file://deploy/lightsail/launch-script.sh \
-  --region eu-central-1
-```
-
-By default the panel uses a **random** high port (in `install-result.env`). To
-pin a known port so you can pre-open it, set `export XUI_PANEL_PORT=54321` inside
-`launch-script.sh`.
-
----
-
-## Path B — reusable snapshot (your own "ready image")
-
-Build a Lightsail **snapshot** once; launch as many instances from it as you
-like, each generating its own credentials on first boot (the golden-image model).
-
-```bash
-deploy/lightsail/build-snapshot.sh --region eu-central-1 --panel-port 54321
-```
-
-What it does: launches a temporary Ubuntu instance with
-[`snapshot-userdata.sh`](snapshot-userdata.sh) (installs the panel, **no DB**,
-enables the first-boot unit), strips all state via the shared
-[`cleanup.sh`](../packer/scripts/cleanup.sh), then snapshots and deletes the
-build instance. Requires `awscli`, `jq`, `ssh` and Lightsail permissions.
-
-Launch instances from the snapshot:
-
-```bash
-aws lightsail create-instances-from-snapshot \
-  --instance-snapshot-name 3x-ui-ubuntu-24.04-<stamp> \
-  --instance-names my-3xui-1 --bundle-id small_3_0 \
-  --availability-zone eu-central-1a --region eu-central-1
-```
-
-Each launched instance runs `x-ui-firstboot` and writes its unique credentials to
-`/etc/x-ui/credentials.txt` + `/etc/motd`. With `--panel-port` the port is the
-same across instances (only the credentials differ), so you can pre-open it.
-
-> Lightsail snapshots are **private to your AWS account** (and region). To use one
-> elsewhere you can export it to EC2 (`aws lightsail export-snapshot`) and share
-> the resulting AMI.
-
----
-
-## Lightsail firewall note (important)
-
-Lightsail's per-instance firewall only opens **22 / 80 / 443** by default. The
-panel runs on a different port, so you must open it:
-
-- Console: instance → **Networking → IPv4 Firewall → Add rule** (TCP, the panel port).
-- CLI:
-  ```bash
-  aws lightsail open-instance-public-ports --region eu-central-1 \
-    --instance-name my-3xui \
-    --port-info fromPort=54321,toPort=54321,protocol=TCP
-  ```
-
-The panel port is in `/etc/x-ui/install-result.env` (Path A) or
-`/etc/x-ui/credentials.txt` (Path B), or fixed via `--panel-port` / `XUI_PANEL_PORT`.

+ 0 - 192
deploy/lightsail/build-snapshot.sh

@@ -1,192 +0,0 @@
-#!/usr/bin/env bash
-#
-# build-snapshot.sh — build a reusable Amazon Lightsail snapshot of 3x-ui.
-#
-# Flow (mirrors the Packer golden-image model, via the Lightsail API):
-#   1. create an Ubuntu Lightsail instance with snapshot-userdata.sh
-#      (installs the panel, NO database, enables the first-boot unit)
-#   2. wait for provisioning, then (optionally) pin a known panel port and run
-#      the shared cleanup.sh (wipes any DB/creds/keys/host-keys/cloud-init state)
-#   3. stop the instance and create an instance snapshot
-#   4. delete the build instance (unless --keep-instance)
-#
-# Every instance you later launch from the snapshot generates its OWN unique
-# credentials on first boot (see deploy/firstboot/). The snapshot is private to
-# your AWS account.
-#
-# Requirements: awscli v2, jq, ssh. AWS credentials with Lightsail permissions.
-# Usage:
-#   deploy/lightsail/build-snapshot.sh --region eu-central-1 [options]
-# Options:
-#   --region <r>            AWS region (default: $AWS_REGION or eu-central-1)
-#   --blueprint-id <id>     Lightsail blueprint (default: ubuntu_24_04)
-#   --bundle-id <id>        Lightsail bundle/size (default: small_3_0)
-#   --availability-zone <z> AZ (default: <region>a)
-#   --panel-port <p>        Pin the panel port in the snapshot so you can pre-open
-#                           it in the Lightsail firewall (default: random per instance)
-#   --snapshot-name <n>     Snapshot name (default: 3x-ui-ubuntu-24.04-<timestamp>)
-#   --keep-instance         Do not delete the build instance afterwards
-set -euo pipefail
-
-REGION="${AWS_REGION:-eu-central-1}"
-BLUEPRINT="ubuntu_24_04"
-BUNDLE="small_3_0"
-AZ=""
-PANEL_PORT=""
-SNAPSHOT_NAME=""
-KEEP_INSTANCE=0
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-STAMP="$(date +%Y%m%d-%H%M%S)"
-INSTANCE_NAME="3xui-build-${STAMP}"
-KEY_FILE=""
-
-log() { echo "[build-snapshot] $*"; }
-die() {
-    echo "[build-snapshot] ERROR: $*" >&2
-    exit 1
-}
-
-while [ $# -gt 0 ]; do
-    case "$1" in
-        --region) REGION="$2"; shift 2 ;;
-        --blueprint-id) BLUEPRINT="$2"; shift 2 ;;
-        --bundle-id) BUNDLE="$2"; shift 2 ;;
-        --availability-zone) AZ="$2"; shift 2 ;;
-        --panel-port) PANEL_PORT="$2"; shift 2 ;;
-        --snapshot-name) SNAPSHOT_NAME="$2"; shift 2 ;;
-        --keep-instance) KEEP_INSTANCE=1; shift ;;
-        -h | --help) sed -n '2,40p' "$0"; exit 0 ;;
-        *) die "unknown option: $1" ;;
-    esac
-done
-
-[ -n "$AZ" ] || AZ="${REGION}a"
-[ -n "$SNAPSHOT_NAME" ] || SNAPSHOT_NAME="3x-ui-ubuntu-24.04-${STAMP}"
-
-for cmd in aws jq ssh; do
-    command -v "$cmd" > /dev/null 2>&1 || die "'$cmd' is required"
-done
-
-SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR)
-
-cleanup() {
-    [ -n "$KEY_FILE" ] && rm -f "$KEY_FILE"
-    if [ "$KEEP_INSTANCE" -eq 0 ]; then
-        aws lightsail delete-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null 2>&1 || true
-    fi
-}
-trap cleanup EXIT
-
-wait_state() {
-    local want="$1" tries="${2:-60}" st
-    for _ in $(seq 1 "$tries"); do
-        st=$(aws lightsail get-instance-state --instance-name "$INSTANCE_NAME" --region "$REGION" \
-            --query 'state.name' --output text 2> /dev/null || echo "")
-        [ "$st" = "$want" ] && return 0
-        sleep 5
-    done
-    return 1
-}
-
-log "creating build instance ${INSTANCE_NAME} (${BLUEPRINT}/${BUNDLE}) in ${REGION}..."
-aws lightsail create-instances \
-    --instance-names "$INSTANCE_NAME" \
-    --availability-zone "$AZ" \
-    --blueprint-id "$BLUEPRINT" \
-    --bundle-id "$BUNDLE" \
-    --user-data "file://${SCRIPT_DIR}/snapshot-userdata.sh" \
-    --region "$REGION" > /dev/null
-
-log "waiting for instance to run..."
-wait_state running 60 || die "instance did not reach 'running'"
-
-IP=$(aws lightsail get-instance --instance-name "$INSTANCE_NAME" --region "$REGION" \
-    --query 'instance.publicIpAddress' --output text)
-if [ -z "$IP" ] || [ "$IP" = "None" ]; then die "no public IP"; fi
-log "instance IP: ${IP}"
-
-KEY_FILE="$(mktemp)"
-# download-default-key-pair returns the key in 'privateKeyBase64'. Despite the
-# name, the CLI historically emits the plaintext PEM (-----BEGIN...); the API
-# docs describe it as base64. Handle both: write PEM as-is, else base64-decode.
-KEY_RAW="$(aws lightsail download-default-key-pair --region "$REGION" \
-    --query 'privateKeyBase64' --output text)"
-[ -n "$KEY_RAW" ] && [ "$KEY_RAW" != "None" ] || die "failed to download default key pair"
-case "$KEY_RAW" in
-    *-----BEGIN*) printf '%s\n' "$KEY_RAW" > "$KEY_FILE" ;;
-    *) printf '%s' "$KEY_RAW" | base64 -d > "$KEY_FILE" 2> /dev/null \
-        || die "private key is neither PEM nor valid base64" ;;
-esac
-grep -q -- "-----BEGIN" "$KEY_FILE" || die "downloaded key is not a valid PEM private key"
-chmod 600 "$KEY_FILE"
-
-log "waiting for provisioning to finish (this installs the panel)..."
-ok=0
-for _ in $(seq 1 72); do # ~12 min
-    if ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
-        'test -f /var/lib/3xui-provision-done' 2> /dev/null; then
-        ok=1
-        break
-    fi
-    sleep 10
-done
-[ "$ok" -eq 1 ] || die "provisioning did not complete in time"
-log "provisioning complete."
-
-if [ -n "$PANEL_PORT" ]; then
-    log "pinning panel port ${PANEL_PORT} (username/password stay random)..."
-    ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
-        "echo 'XUI_PANEL_PORT=${PANEL_PORT}' | sudo tee -a /etc/default/x-ui >/dev/null"
-fi
-
-log "stripping instance state (shared cleanup.sh)..."
-ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
-    'curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/deploy/packer/scripts/cleanup.sh | sudo bash'
-
-log "stopping instance..."
-aws lightsail stop-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null
-wait_state stopped 60 || die "instance did not stop"
-
-log "creating snapshot ${SNAPSHOT_NAME}..."
-aws lightsail create-instance-snapshot \
-    --instance-name "$INSTANCE_NAME" \
-    --instance-snapshot-name "$SNAPSHOT_NAME" \
-    --region "$REGION" > /dev/null
-
-log "waiting for snapshot to become available..."
-snap_ok=0
-for _ in $(seq 1 120); do # ~20 min
-    state=$(aws lightsail get-instance-snapshot --instance-snapshot-name "$SNAPSHOT_NAME" \
-        --region "$REGION" --query 'instanceSnapshot.state' --output text 2> /dev/null || echo "")
-    [ "$state" = "available" ] && {
-        snap_ok=1
-        break
-    }
-    sleep 10
-done
-[ "$snap_ok" -eq 1 ] || die "snapshot did not become available"
-
-log "DONE."
-echo
-echo "================================================================"
-echo " Lightsail snapshot ready: ${SNAPSHOT_NAME}  (region ${REGION})"
-echo "================================================================"
-echo " Launch an instance from it:"
-echo "   aws lightsail create-instances-from-snapshot \\"
-echo "     --instance-snapshot-name ${SNAPSHOT_NAME} \\"
-echo "     --instance-names my-3xui-1 --bundle-id ${BUNDLE} \\"
-echo "     --availability-zone ${AZ} --region ${REGION}"
-if [ -n "$PANEL_PORT" ]; then
-    echo
-    echo " Then open the panel port (pinned to ${PANEL_PORT}):"
-    echo "   aws lightsail open-instance-public-ports --region ${REGION} \\"
-    echo "     --instance-name my-3xui-1 \\"
-    echo "     --port-info fromPort=${PANEL_PORT},toPort=${PANEL_PORT},protocol=TCP"
-else
-    echo
-    echo " Each instance picks a RANDOM panel port. After it boots, read it from"
-    echo "   sudo cat /etc/x-ui/credentials.txt"
-    echo " and open that TCP port in the instance's Lightsail IPv4 firewall."
-fi
-echo "================================================================"

+ 0 - 51
deploy/lightsail/launch-script.sh

@@ -1,51 +0,0 @@
-#!/bin/bash
-#
-# Amazon Lightsail launch script for 3x-ui (self-service, per-instance creds).
-#
-# Use it one of two ways when creating an Ubuntu 24.04 Lightsail instance:
-#   * Console: "Add launch script" -> paste this file.
-#   * CLI:     aws lightsail create-instances --user-data file://launch-script.sh ...
-#
-# It installs the latest 3x-ui release non-interactively and generates unique
-# random credentials for THIS instance. The full credentials land in
-# /etc/x-ui/install-result.env (mode 600); /etc/motd shows only the URL + username.
-#
-# IMPORTANT (Lightsail firewall): Lightsail only opens 22/80/443 by default. The
-# panel listens on a random high port, so after boot read the port from
-# /etc/x-ui/install-result.env and open it under the instance's Networking tab
-# (IPv4 Firewall), or pin a known port below and pre-open it.
-set -e
-export DEBIAN_FRONTEND=noninteractive
-
-# --- Non-interactive install knobs ------------------------------------------
-export XUI_NONINTERACTIVE=1
-export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
-# Pin a known panel port so you can pre-open it in the Lightsail firewall
-# (otherwise a random high port is chosen). Username/password stay random:
-#   export XUI_PANEL_PORT="54321"
-# Other optional pins (unset => secure random):
-#   export XUI_USERNAME="admin2"
-#   export XUI_PASSWORD="change-me"
-#   export XUI_WEB_BASE_PATH="panel"
-# Domain TLS instead of plain HTTP:
-#   export XUI_SSL_MODE="domain" XUI_DOMAIN="panel.example.com" XUI_ACME_EMAIL="[email protected]"
-# ----------------------------------------------------------------------------
-
-curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
-
-# /etc/motd is world-readable, so it gets ONLY non-secret info (URL + username);
-# the full credentials stay in the root-only /etc/x-ui/install-result.env
-# (mode 600) — read them with `sudo cat` over SSH.
-if [ -r /etc/x-ui/install-result.env ]; then
-    # shellcheck disable=SC1091
-    . /etc/x-ui/install-result.env
-    {
-        echo
-        echo "=== 3x-ui panel (generated on first boot) ==="
-        echo "URL:      ${XUI_ACCESS_URL:-unknown}"
-        echo "Username: ${XUI_USERNAME:-unknown}"
-        echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
-        echo "Open the panel port in the Lightsail IPv4 firewall, then log in."
-        echo "============================================="
-    } >> /etc/motd 2>/dev/null || true
-fi

+ 0 - 59
deploy/lightsail/snapshot-userdata.sh

@@ -1,59 +0,0 @@
-#!/bin/bash
-#
-# Lightsail snapshot provisioning user-data (used by build-snapshot.sh).
-#
-# Installs the 3x-ui panel into a build instance but creates NO database and
-# NO credentials, and enables the first-boot unit. The instance is then snapshot
-# so that every instance launched from the snapshot generates its own unique
-# credentials on first boot (see deploy/firstboot/).
-#
-# This is the Lightsail equivalent of deploy/packer/scripts/provision.sh. It is
-# NOT for end users — use deploy/lightsail/launch-script.sh for a direct install.
-set -e
-export DEBIAN_FRONTEND=noninteractive
-
-REPO=MHSanaei/3x-ui
-XUI_DIR=/usr/local/x-ui
-RAW="https://raw.githubusercontent.com/${REPO}/main"
-
-apt-get update
-apt-get install -y --no-install-recommends \
-    ca-certificates curl tar tzdata socat openssl cron jq
-
-ARCH=$(dpkg --print-architecture) # amd64 | arm64
-VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
-if [ -z "$VER" ] || [ "$VER" = "null" ]; then
-    echo "failed to resolve 3x-ui version" >&2
-    exit 1
-fi
-
-tmp=$(mktemp -d)
-curl -fL4 --retry 3 -o "${tmp}/x.tar.gz" \
-    "https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"
-
-systemctl stop x-ui > /dev/null 2>&1 || true
-rm -rf "$XUI_DIR"
-tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
-chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
-chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
-cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
-chmod +x /usr/bin/x-ui
-mkdir -p /var/log/x-ui
-
-# Panel + first-boot systemd units.
-install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
-curl -fL4 -o "${XUI_DIR}/x-ui-firstboot.sh" "${RAW}/deploy/firstboot/x-ui-firstboot.sh"
-curl -fL4 -o /etc/systemd/system/x-ui-firstboot.service "${RAW}/deploy/firstboot/x-ui-firstboot.service"
-chmod 755 "${XUI_DIR}/x-ui-firstboot.sh"
-chmod 644 /etc/systemd/system/x-ui-firstboot.service
-
-systemctl daemon-reload
-systemctl enable x-ui-firstboot.service
-systemctl enable x-ui.service
-
-# No DB, no creds in the image — first boot generates them per-instance.
-rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
-
-# Marker that build-snapshot.sh polls for over SSH.
-touch /var/lib/3xui-provision-done
-echo "[snapshot-userdata] provisioned 3x-ui ${VER} (${ARCH}); no DB created."

+ 0 - 92
deploy/marketplace/aws/README.md

@@ -1,92 +0,0 @@
-# Publishing 3x-ui to the AWS Marketplace (AMI)
-
-This is the checklist for turning the Packer-built AMI into an AWS Marketplace
-listing. It assumes you have already built an AMI with
-[`../../packer/`](../../packer/) (locally or via `.github/workflows/image.yml`).
-
-> Do **not** commit AMI IDs, AWS account numbers, or credentials. The AMI ID is
-> printed to the workflow job summary at build time.
-
-## 1. Seller registration (one-time)
-
-1. Sign in to the [AWS Marketplace Management Portal](https://aws.amazon.com/marketplace/management/)
-   with the AWS account that will own the listing.
-2. Complete **seller registration** (legal entity, bank, tax interview). Required
-   before any product can be submitted.
-
-## 2. Build a compliant AMI
-
-Build in the seller account (or share the AMI into it):
-
-```bash
-cd deploy/packer
-packer init .
-# amd64
-packer build -only='amazon-ebs.x-ui' \
-  -var 'xui_version=vX.Y.Z' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
-# arm64 (Graviton)
-packer build -only='amazon-ebs.x-ui' \
-  -var 'xui_version=vX.Y.Z' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
-```
-
-You can list both AMIs (amd64 + arm64) as architectures of a single Marketplace
-product, or as separate products.
-
-The image already satisfies the Marketplace AMI policies enforced by `harden.sh`
-+ `cleanup.sh`:
-
-- ✅ `PasswordAuthentication no`, `PermitRootLogin prohibit-password`
-- ✅ no default OS account passwords (all locked)
-- ✅ no baked `authorized_keys`, no SSH host keys (regenerated on boot)
-- ✅ base OS = current Ubuntu 24.04 LTS, patched at build time
-- ✅ no application default credentials — the panel admin is generated on first
-  boot on a random high port (no `admin/admin`, no shipped `x-ui.db`)
-
-## 3. Run the self-service AMI scan
-
-1. In the Management Portal: **Server products → AMIs → Upload/scan an AMI**.
-2. Share the AMI with the AWS Marketplace scanning account when prompted
-   (the portal gives you the exact account id and the `modify-image-attribute`
-   command, or share it from the EC2 console).
-3. Start the scan. It checks SSH config, default credentials, open ports, and
-   for malware. Fix any finding and re-scan.
-
-Common scan findings and where they're handled:
-
-| Finding | Fix (already in the build) |
-| --- | --- |
-| Password authentication enabled | `harden.sh` sshd drop-in |
-| Root login with password | `harden.sh` `PermitRootLogin prohibit-password` |
-| Default user password set | `harden.sh` `passwd -l` on all accounts |
-| Authorized keys present | `cleanup.sh` removes them |
-| Out-of-date packages | base image is the latest LTS; `provision.sh` runs `apt-get update` |
-
-## 4. Create the product (limited / private first)
-
-1. **Server products → Create new product → AMI** (or AMI + CloudFormation).
-2. Add title, description, categories, pricing (free or paid), regions, the AMI
-   id, recommended instance types, and the **usage instructions** (tell buyers
-   to read `/etc/x-ui/credentials.txt` / MOTD after first boot for the generated
-   admin login, then change the password).
-3. Submit as a **Limited** (private) listing first. AWS publishes it with
-   restricted visibility so only your account / allow-listed accounts see it.
-
-## 5. Preview & launch test
-
-1. From the limited listing, **subscribe and launch** a test instance.
-2. SSH in, `sudo cat /etc/x-ui/credentials.txt`, open the panel URL, log in,
-   confirm the panel works and the credentials are unique to that instance.
-3. Launch a second instance and confirm its credentials differ (no shared
-   secrets).
-
-## 6. Go public
-
-1. Once the scan passes and the preview looks correct, request **public
-   visibility** (move from Limited to Public) in the listing.
-2. AWS does a final review before the listing goes live.
-
-## References
-
-- AWS Marketplace seller guide: <https://docs.aws.amazon.com/marketplace/latest/userguide/>
-- AMI-based product requirements: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html>
-- Self-service AMI scanning: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-submission.html>

+ 3 - 24
deploy/marketplace/hetzner/README.md

@@ -1,9 +1,10 @@
 # 3x-ui on Hetzner Cloud
 
 Hetzner Cloud does **not** have a third-party image marketplace the way AWS does.
-There are two practical ways to ship 3x-ui on Hetzner.
+Ship 3x-ui via **cloud-init**: each instance installs non-interactively and
+generates unique per-instance credentials (no `admin/admin`, no shared secret).
 
-## Option A — cloud-init (recommended, no image build)
+## cloud-init (no image build)
 
 Use the generic user-data from [`../../cloud-init/`](../../cloud-init/). It installs
 3x-ui non-interactively and generates unique per-instance credentials.
@@ -27,28 +28,6 @@ After boot, fetch the generated credentials:
 ssh root@<server-ip> 'cat /etc/x-ui/install-result.env'
 ```
 
-## Option B — snapshot from the qcow2 / a configured server
-
-Hetzner lets you create a **snapshot** of a running server and launch new
-servers from it. Two ways to get there:
-
-1. **From the Packer qcow2:** Hetzner does not allow direct qcow2 upload via the
-   normal API, but you can boot a server, write the image to its disk in rescue
-   mode, then take a snapshot — or simply use Option A, which needs no image.
-2. **From a configured server:** spin up a server, install via cloud-init
-   (Option A), verify, then **delete `/etc/x-ui/x-ui.db` and the first-boot
-   sentinel** before snapshotting so clones regenerate their own credentials:
-
-   ```bash
-   systemctl stop x-ui
-   rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done /etc/x-ui/credentials.txt
-   # re-enable first-boot regeneration if you installed via Packer:
-   systemctl enable x-ui-firstboot 2>/dev/null || true
-   ```
-
-   > ⚠️ If you snapshot a server **with** its `x-ui.db`, every clone shares the
-   > same admin credentials and session secret. Always remove the DB first.
-
 ## "App"-style listing
 
 Hetzner's curated apps live in the community repo

+ 0 - 7
deploy/packer/.gitignore

@@ -1,7 +0,0 @@
-# Packer build artifacts (never commit images or manifests)
-output-qemu/
-*.qcow2
-*.raw
-packer-manifest.json
-packer_cache/
-crash.log

+ 0 - 116
deploy/packer/README.md

@@ -1,116 +0,0 @@
-# 3x-ui golden image (Packer)
-
-Builds a cloud image with the 3x-ui panel pre-installed but **not configured**:
-the image ships with **no database and no credentials**, and generates a unique
-admin account on first boot. This is the **primary** path for AWS Marketplace
-and any reusable image.
-
-Two sources, one build:
-
-| Source | Output | For |
-| --- | --- | --- |
-| `amazon-ebs` | AWS AMI | AWS / Marketplace |
-| `qemu` | `qcow2` (+ `raw`) | Hetzner, DigitalOcean, Vultr, GCP, Azure, Oracle, bare metal |
-
-Both sources build for **`amd64` and `arm64`** (select with `-var xui_arch=...`).
-
-## Why no baked DB
-
-3x-ui seeds a hardcoded `admin/admin` user and generates its session secret +
-panel GUID the first time it starts. If an image shipped an initialized
-`x-ui.db`, **every clone would share the same credentials and secret**. So the
-build deliberately:
-
-- installs the panel binary + systemd unit but **never starts it** and **never
-  creates a DB** (`scripts/provision.sh`);
-- wipes any stray DB/credentials/host-keys at the end (`scripts/cleanup.sh`);
-- enables `x-ui-firstboot.service`, which on first boot resets settings, sets a
-  random username/password on a random high port, regenerates the secret/GUID,
-  and writes the credentials to `/etc/x-ui/credentials.txt` + `/etc/motd`
-  (`deploy/firstboot/`).
-
-## Prerequisites
-
-- [Packer](https://developer.hashicorp.com/packer) ≥ 1.9
-- For `qemu` amd64: `qemu-system-x86`, `qemu-utils` (and `/dev/kvm` for acceptable speed)
-- For `qemu` arm64: `qemu-system-arm`, `qemu-efi-aarch64`, `qemu-utils` — best built on an
-  arm64 host (native KVM); cross-building from x86 works but uses slow TCG emulation
-- For `amazon-ebs`: AWS credentials with EC2 build permissions (arm64 builds on a Graviton
-  instance such as `t4g.small`)
-
-```bash
-cd deploy/packer
-packer init .
-packer fmt -check .      # formatting
-packer validate .        # both sources
-```
-
-## Build
-
-Build a specific release (recommended) or `latest`:
-
-```bash
-# amd64 qcow2 (no cloud account needed)
-packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' .
-
-# arm64 qcow2 (run on an arm64 host for native KVM)
-packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' .
-
-# amd64 AWS AMI
-packer build -only='amazon-ebs.x-ui' \
-  -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
-
-# arm64 AWS AMI (Graviton)
-packer build -only='amazon-ebs.x-ui' \
-  -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
-```
-
-Outputs (per arch):
-- `output-qemu/3x-ui-ubuntu-24.04-<arch>.qcow2` and `.raw`
-- the AMI id (also recorded in `packer-manifest.json`)
-
-If `/dev/kvm` is unavailable, add `-var 'qemu_accelerator=tcg'` (much slower).
-
-## Key variables
-
-See [`variables.pkr.hcl`](variables.pkr.hcl) for the full list.
-
-| Variable | Default | Notes |
-| --- | --- | --- |
-| `xui_version` | `latest` | Release tag to install, e.g. `v3.3.1` |
-| `xui_arch` | `amd64` | `amd64` or `arm64` (derives the base AMI / cloud image) |
-| `region` | `eu-central-1` | AWS region (amazon-ebs) |
-| `instance_type` | `t3.small` | EC2 build instance — must match the arch (`t4g.small` for arm64) |
-| `qemu_accelerator` | `kvm` | `kvm` or `tcg` |
-| `qemu_cpu` | `host` | arm64 `-cpu` model (`host` with KVM, `max` for TCG) |
-| `ubuntu_version` | `24.04` | Base Ubuntu LTS (naming/tags) |
-
-The CI workflow builds both arches automatically: amd64 qcow2 on a standard runner,
-arm64 qcow2 on a native `ubuntu-24.04-arm` runner, and both AMIs from a single runner
-(the build instance runs in AWS).
-
-## First boot
-
-On the first boot of any instance launched from the image:
-
-1. `x-ui-firstboot.service` runs **before** `x-ui.service`.
-2. It generates a unique admin username/password, a random panel port, a random
-   base path, and an API token.
-3. Credentials are written to `/etc/x-ui/credentials.txt` (root-only) and shown
-   in `/etc/motd`. Retrieve them with `sudo cat /etc/x-ui/credentials.txt`.
-4. The panel then starts on the random port. `admin/admin` never exists.
-
-## CI
-
-`.github/workflows/image.yml` runs this build on `release: published` (and via
-`workflow_dispatch`), attaching the compressed `qcow2` to the release and
-building the AMI when AWS credentials are configured.
-
-## A note on host firewalls
-
-`scripts/harden.sh` intentionally does **not** enable a restrictive host
-firewall. 3x-ui opens Xray inbound ports on admin-chosen ports at runtime, which
-a host firewall would block. Use your cloud provider's security groups/firewall
-instead, and open the panel port + your inbound ports there. If you still want a
-host firewall, add `ufw` rules in `harden.sh` allowing SSH, the panel port and
-your inbound ports.

+ 0 - 59
deploy/packer/scripts/cleanup.sh

@@ -1,59 +0,0 @@
-#!/usr/bin/env bash
-#
-# cleanup.sh — strip all instance-specific state and secrets from the image.
-#
-# Runs LAST. The output image must contain no panel database, no credentials,
-# no SSH host keys, and no baked authorized_keys. Fails the build if any of
-# those survive.
-set -euo pipefail
-
-echo "[cleanup] removing panel database, credentials and first-boot sentinel..."
-rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* 2> /dev/null || true
-rm -f /etc/x-ui/install-result.env /etc/x-ui/credentials.txt 2> /dev/null || true
-rm -f /etc/x-ui/.firstboot-done 2> /dev/null || true
-
-echo "[cleanup] removing SSH host keys (regenerated on first boot)..."
-rm -f /etc/ssh/ssh_host_* 2> /dev/null || true
-
-echo "[cleanup] removing any baked authorized_keys..."
-rm -f /root/.ssh/authorized_keys 2> /dev/null || true
-find /home -maxdepth 3 -name authorized_keys -type f -delete 2> /dev/null || true
-
-echo "[cleanup] resetting machine-id..."
-truncate -s 0 /etc/machine-id 2> /dev/null || true
-rm -f /var/lib/dbus/machine-id 2> /dev/null || true
-ln -sf /etc/machine-id /var/lib/dbus/machine-id 2> /dev/null || true
-
-echo "[cleanup] resetting cloud-init so it re-runs on the real first boot..."
-cloud-init clean --logs --seed > /dev/null 2>&1 || rm -rf /var/lib/cloud/* 2> /dev/null || true
-
-echo "[cleanup] truncating logs, history and package caches..."
-find /var/log -type f -exec truncate -s 0 {} + 2> /dev/null || true
-rm -rf /var/lib/x-ui /var/log/x-ui/* 2> /dev/null || true
-apt-get clean || true
-rm -rf /var/lib/apt/lists/* 2> /dev/null || true
-rm -f /root/.bash_history 2> /dev/null || true
-find /home -maxdepth 3 -name .bash_history -type f -delete 2> /dev/null || true
-rm -rf /tmp/firstboot 2> /dev/null || true
-
-echo "[cleanup] verifying the image is clean..."
-fail=0
-for f in /etc/x-ui/x-ui.db /etc/x-ui/credentials.txt /etc/x-ui/install-result.env /etc/x-ui/.firstboot-done; do
-    if [ -e "$f" ]; then
-        echo "[cleanup] FATAL: $f is present in the image" >&2
-        fail=1
-    fi
-done
-if ls /etc/ssh/ssh_host_* > /dev/null 2>&1; then
-    echo "[cleanup] FATAL: SSH host keys present in the image" >&2
-    fail=1
-fi
-if [ -e /root/.ssh/authorized_keys ]; then
-    echo "[cleanup] FATAL: /root/.ssh/authorized_keys present in the image" >&2
-    fail=1
-fi
-if [ "$fail" -ne 0 ]; then
-    exit 1
-fi
-
-echo "[cleanup] OK — no DB, no credentials, no host keys, no authorized_keys."

+ 0 - 39
deploy/packer/scripts/harden.sh

@@ -1,39 +0,0 @@
-#!/usr/bin/env bash
-#
-# harden.sh — baseline OS hardening for AWS Marketplace AMI scanner compliance.
-#
-# Focus: the controls the scanner actually checks — key-only SSH, no root
-# password login, and no default OS account passwords. A restrictive host
-# firewall is intentionally NOT enforced by default because 3x-ui opens Xray
-# inbound ports on admin-chosen ports at runtime (see README for the rationale
-# and how to add ufw rules if you want them).
-set -euo pipefail
-export DEBIAN_FRONTEND=noninteractive
-
-echo "[harden] applying SSH hardening..."
-install -d -m 755 /etc/ssh/sshd_config.d
-cat > /etc/ssh/sshd_config.d/99-3xui-hardening.conf << 'EOF'
-# 3x-ui golden image hardening (AWS Marketplace scanner compliance)
-PasswordAuthentication no
-PermitRootLogin prohibit-password
-KbdInteractiveAuthentication no
-ChallengeResponseAuthentication no
-EOF
-chmod 644 /etc/ssh/sshd_config.d/99-3xui-hardening.conf
-
-echo "[harden] locking passwords on default OS accounts..."
-# No account may ship with a usable password. Keys are provisioned per-instance
-# by the cloud platform (EC2 metadata / cloud-init) on first boot.
-# passwd -l locks the PASSWORD only; key-based login keeps working.
-for u in root ubuntu admin; do
-    if id "$u" > /dev/null 2>&1; then
-        passwd -l "$u" > /dev/null 2>&1 || true
-    fi
-done
-
-echo "[harden] enabling automatic security updates..."
-apt-get update
-apt-get install -y --no-install-recommends unattended-upgrades
-systemctl enable unattended-upgrades > /dev/null 2>&1 || true
-
-echo "[harden] done."

+ 0 - 76
deploy/packer/scripts/provision.sh

@@ -1,76 +0,0 @@
-#!/usr/bin/env bash
-#
-# provision.sh — install the 3x-ui panel into a golden image (Packer).
-#
-# Self-contained: mirrors install.sh's download/extract logic but DELIBERATELY
-# does NOT run config_after_install and does NOT create a database. The image
-# must ship without /etc/x-ui/x-ui.db so that deploy/firstboot generates unique
-# per-instance credentials on first boot. Both x-ui.service and
-# x-ui-firstboot.service are enabled but NOT started here.
-#
-# Inputs (from Packer environment_vars):
-#   XUI_VERSION  release tag (e.g. v3.3.1) or 'latest'
-#   XUI_ARCH     amd64 (default) or arm64
-set -euo pipefail
-
-XUI_VERSION="${XUI_VERSION:-latest}"
-XUI_ARCH="${XUI_ARCH:-amd64}"
-XUI_DIR="/usr/local/x-ui"
-REPO="MHSanaei/3x-ui"
-export DEBIAN_FRONTEND=noninteractive
-
-echo "[provision] installing base packages..."
-apt-get update
-apt-get install -y --no-install-recommends \
-    ca-certificates curl tar tzdata socat openssl cron jq
-
-echo "[provision] resolving 3x-ui version..."
-if [ "$XUI_VERSION" = "latest" ]; then
-    XUI_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name')
-fi
-if [ -z "$XUI_VERSION" ] || [ "$XUI_VERSION" = "null" ]; then
-    echo "[provision] ERROR: could not resolve 3x-ui release tag" >&2
-    exit 1
-fi
-echo "[provision] installing 3x-ui ${XUI_VERSION} (${XUI_ARCH})"
-
-tarball="x-ui-linux-${XUI_ARCH}.tar.gz"
-url="https://github.com/${REPO}/releases/download/${XUI_VERSION}/${tarball}"
-tmp="$(mktemp -d)"
-trap 'rm -rf "$tmp"' EXIT
-
-# Download the RELEASED binary tarball (no Go build inside the image).
-curl -fL4 --retry 3 -o "${tmp}/${tarball}" "$url"
-
-# Extract into /usr/local/ (the tarball contains an x-ui/ directory).
-systemctl stop x-ui > /dev/null 2>&1 || true
-rm -rf "$XUI_DIR"
-tar -xzf "${tmp}/${tarball}" -C /usr/local/
-chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
-chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
-
-# Install the x-ui management CLI.
-if [ -f "${XUI_DIR}/x-ui.sh" ]; then
-    cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
-else
-    curl -fL4 -o /usr/bin/x-ui "https://raw.githubusercontent.com/${REPO}/main/x-ui.sh"
-fi
-chmod +x /usr/bin/x-ui
-mkdir -p /var/log/x-ui
-
-# Panel systemd unit (Ubuntu base => debian variant).
-install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
-
-# First-boot per-instance credential unit + script (uploaded to /tmp/firstboot).
-install -m 755 /tmp/firstboot/x-ui-firstboot.sh "${XUI_DIR}/x-ui-firstboot.sh"
-install -m 644 /tmp/firstboot/x-ui-firstboot.service /etc/systemd/system/x-ui-firstboot.service
-
-systemctl daemon-reload
-# Enable (start on next boot) but do NOT start now — there is no DB yet.
-systemctl enable x-ui-firstboot.service
-systemctl enable x-ui.service
-
-# Belt-and-braces: ensure no DB / sentinel was created during provisioning.
-rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
-
-echo "[provision] done — panel installed, services enabled, NO database initialized."

+ 0 - 109
deploy/packer/variables.pkr.hcl

@@ -1,109 +0,0 @@
-// Input variables for the 3x-ui golden-image build.
-// See README.md for usage. Override with -var / -var-file or env (PKR_VAR_*).
-
-variable "xui_version" {
-  type        = string
-  description = "3x-ui release tag to install, e.g. v3.3.1. 'latest' resolves the newest GitHub release at build time."
-  default     = "latest"
-}
-
-variable "xui_arch" {
-  type        = string
-  description = "CPU architecture to build for: amd64 or arm64."
-  default     = "amd64"
-  validation {
-    condition     = contains(["amd64", "arm64"], var.xui_arch)
-    error_message = "The xui_arch value must be 'amd64' or 'arm64'."
-  }
-}
-
-variable "ubuntu_version" {
-  type        = string
-  description = "Ubuntu LTS version label, used only for image naming/tags."
-  default     = "24.04"
-}
-
-// --- amazon-ebs (AMI) ---------------------------------------------------------
-
-variable "region" {
-  type        = string
-  description = "AWS region the AMI is built in."
-  default     = "eu-central-1"
-}
-
-variable "instance_type" {
-  type        = string
-  description = "EC2 instance type used to build the AMI. Must match xui_arch (e.g. t3.small for amd64, t4g.small for arm64/Graviton)."
-  default     = "t3.small"
-}
-
-variable "ami_name_prefix" {
-  type        = string
-  description = "Prefix for the produced AMI name."
-  default     = "3x-ui"
-}
-
-variable "source_ami_filter_name" {
-  type        = string
-  description = "Override for the Canonical Ubuntu base AMI name filter. Empty ⇒ derived from xui_arch (latest patched 24.04 LTS for that arch)."
-  default     = ""
-}
-
-variable "ssh_username" {
-  type        = string
-  description = "Default SSH user on the base Ubuntu cloud image."
-  default     = "ubuntu"
-}
-
-// --- qemu (qcow2 / raw) -------------------------------------------------------
-
-variable "qemu_iso_url" {
-  type        = string
-  description = "Override for the Ubuntu cloud image used as the qemu base disk. Empty ⇒ derived from xui_arch (amd64/arm64 cloud image)."
-  default     = ""
-}
-
-variable "qemu_iso_checksum" {
-  type        = string
-  description = "Checksum for the qemu base disk. 'file:<SHA256SUMS url>' auto-fetches; 'none' skips verification."
-  default     = "file:https://cloud-images.ubuntu.com/releases/24.04/release/SHA256SUMS"
-}
-
-variable "qemu_accelerator" {
-  type        = string
-  description = "QEMU accelerator: 'kvm' when /dev/kvm is available, else 'tcg' (slow software emulation)."
-  default     = "kvm"
-}
-
-variable "qemu_headless" {
-  type        = bool
-  description = "Run QEMU without a display (required on CI runners)."
-  default     = true
-}
-
-variable "qemu_build_password" {
-  type        = string
-  description = "Temporary password injected via cloud-init for Packer's build-time SSH. Locked/removed before the image is finalized."
-  default     = "packer-build-temp-pw"
-  sensitive   = true
-}
-
-# --- qemu arm64-only knobs (ignored for amd64) -------------------------------
-
-variable "qemu_cpu" {
-  type        = string
-  description = "QEMU -cpu model for arm64 builds: 'host' with KVM on an arm64 host, 'max' for TCG emulation."
-  default     = "host"
-}
-
-variable "qemu_efi_code" {
-  type        = string
-  description = "Path to the arm64 UEFI code firmware (AAVMF). Only used when xui_arch=arm64."
-  default     = "/usr/share/AAVMF/AAVMF_CODE.fd"
-}
-
-variable "qemu_efi_vars" {
-  type        = string
-  description = "Path to the arm64 UEFI vars firmware template (AAVMF). Only used when xui_arch=arm64."
-  default     = "/usr/share/AAVMF/AAVMF_VARS.fd"
-}

+ 0 - 160
deploy/packer/x-ui.pkr.hcl

@@ -1,160 +0,0 @@
-// 3x-ui golden image — one build, two sources:
-//   * amazon-ebs : produces an AWS AMI (Marketplace-scannable)
-//   * qemu       : produces a qcow2 (+ raw) for Hetzner/DO/Vultr/GCP/Azure/Oracle
-//
-// The image ships WITHOUT an initialized x-ui.db and WITHOUT any baked
-// credentials. deploy/firstboot/x-ui-firstboot.{sh,service} generates unique
-// per-instance credentials on first boot, before x-ui.service starts.
-//
-// Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.
-
-packer {
-  required_plugins {
-    amazon = {
-      version = ">= 1.3.0"
-      source  = "github.com/hashicorp/amazon"
-    }
-    qemu = {
-      version = ">= 1.1.0"
-      source  = "github.com/hashicorp/qemu"
-    }
-  }
-}
-
-locals {
-  build_stamp = formatdate("YYYYMMDD-hhmmss", timestamp())
-  image_name  = "${var.ami_name_prefix}-ubuntu-${var.ubuntu_version}-${var.xui_arch}"
-  is_arm      = var.xui_arch == "arm64"
-
-  # Base images are derived from xui_arch unless explicitly overridden.
-  source_ami_name = var.source_ami_filter_name != "" ? var.source_ami_filter_name : "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-${var.xui_arch}-server-*"
-  qemu_iso_url    = var.qemu_iso_url != "" ? var.qemu_iso_url : "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-${var.xui_arch}.img"
-}
-
-source "amazon-ebs" "x-ui" {
-  region        = var.region
-  instance_type = var.instance_type
-  ssh_username  = var.ssh_username
-
-  ami_name        = "${local.image_name}-${var.xui_version}-${local.build_stamp}"
-  ami_description = "3x-ui panel on Ubuntu ${var.ubuntu_version}. Per-instance credentials are generated on first boot."
-
-  source_ami_filter {
-    filters = {
-      name                = local.source_ami_name
-      root-device-type    = "ebs"
-      virtualization-type = "hvm"
-    }
-    owners      = ["099720109477"] // Canonical
-    most_recent = true
-  }
-
-  launch_block_device_mappings {
-    device_name           = "/dev/sda1"
-    volume_size           = 8
-    volume_type           = "gp3"
-    delete_on_termination = true
-  }
-
-  tags = {
-    Name       = local.image_name
-    Project    = "3x-ui"
-    XuiVersion = var.xui_version
-    BuildTool  = "packer"
-    BaseOS     = "ubuntu-${var.ubuntu_version}"
-  }
-}
-
-source "qemu" "x-ui" {
-  iso_url      = local.qemu_iso_url
-  iso_checksum = var.qemu_iso_checksum
-  disk_image   = true
-  disk_size    = "10G"
-  format       = "qcow2"
-
-  accelerator    = var.qemu_accelerator
-  headless       = var.qemu_headless
-  cpus           = 2
-  memory         = 2048
-  net_device     = "virtio-net"
-  disk_interface = "virtio"
-
-  // Arch-specific QEMU machine. amd64 uses Packer defaults (BIOS boot, x86_64);
-  // arm64 needs the aarch64 binary, the 'virt' machine and UEFI (AAVMF) firmware.
-  qemu_binary       = local.is_arm ? "qemu-system-aarch64" : null
-  machine_type      = local.is_arm ? "virt" : null
-  efi_boot          = local.is_arm
-  efi_firmware_code = local.is_arm ? var.qemu_efi_code : null
-  efi_firmware_vars = local.is_arm ? var.qemu_efi_vars : null
-  qemuargs          = local.is_arm ? [["-cpu", var.qemu_cpu]] : []
-
-  output_directory = "output-qemu"
-  vm_name          = "${local.image_name}.qcow2"
-
-  // Build-time access: a NoCloud seed sets a temporary password for the default
-  // user so Packer can SSH in. The seed is a separate CD-ROM (not part of the
-  // output disk); the password is locked by harden.sh and state wiped by cleanup.sh.
-  cd_label = "cidata"
-  cd_content = {
-    "meta-data" = ""
-    "user-data" = <<-EOT
-      #cloud-config
-      password: ${var.qemu_build_password}
-      chpasswd: { expire: false }
-      ssh_pwauth: true
-    EOT
-  }
-
-  ssh_username = var.ssh_username
-  ssh_password = var.qemu_build_password
-  ssh_timeout  = "20m"
-  boot_wait    = "45s"
-
-  shutdown_command = "sudo shutdown -P now"
-}
-
-build {
-  name    = "3x-ui"
-  sources = ["source.amazon-ebs.x-ui", "source.qemu.x-ui"]
-
-  // Upload the first-boot unit + script so provision.sh can install them.
-  provisioner "shell" {
-    inline = ["mkdir -p /tmp/firstboot"]
-  }
-  provisioner "file" {
-    source      = "${path.root}/../firstboot/x-ui-firstboot.sh"
-    destination = "/tmp/firstboot/x-ui-firstboot.sh"
-  }
-  provisioner "file" {
-    source      = "${path.root}/../firstboot/x-ui-firstboot.service"
-    destination = "/tmp/firstboot/x-ui-firstboot.service"
-  }
-
-  provisioner "shell" {
-    environment_vars = [
-      "XUI_VERSION=${var.xui_version}",
-      "XUI_ARCH=${var.xui_arch}",
-      "DEBIAN_FRONTEND=noninteractive",
-    ]
-    execute_command = "chmod +x {{ .Path }}; sudo -E bash {{ .Path }}"
-    scripts = [
-      "${path.root}/scripts/provision.sh",
-      "${path.root}/scripts/harden.sh",
-      "${path.root}/scripts/cleanup.sh",
-    ]
-    // give cloud-init time to release apt locks on the very first boot
-    pause_before = "10s"
-  }
-
-  // Convert the qcow2 to raw for clouds that need it (qemu source only).
-  post-processor "shell-local" {
-    only   = ["qemu.x-ui"]
-    inline = ["qemu-img convert -p -O raw output-qemu/${local.image_name}.qcow2 output-qemu/${local.image_name}.raw"]
-  }
-
-  // Record the AMI id / artifacts for CI to surface.
-  post-processor "manifest" {
-    output     = "packer-manifest.json"
-    strip_path = true
-  }
-}

+ 0 - 86
deploy/test/smoke-firstboot.sh

@@ -1,86 +0,0 @@
-#!/usr/bin/env bash
-#
-# smoke-firstboot.sh — verify the first-boot per-instance credential script.
-#
-# Installs the released x-ui binary into a container WITHOUT a database, runs
-# x-ui-firstboot.sh, and asserts:
-#   * fresh random credentials are generated (no admin/admin)
-#   * /etc/x-ui/credentials.txt (600) and /etc/motd are written
-#   * the sentinel is created and a second run is a no-op (creds unchanged)
-#
-# Requires Docker and network access. Usage: bash deploy/test/smoke-firstboot.sh
-set -euo pipefail
-
-REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
-IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
-
-if ! command -v docker > /dev/null 2>&1; then
-    echo "ERROR: docker is required for this smoke test." >&2
-    exit 1
-fi
-
-echo "== first-boot credential smoke test (image: $IMAGE) =="
-
-docker run --rm \
-    -v "${REPO_ROOT}/deploy/firstboot/x-ui-firstboot.sh:/root/x-ui-firstboot.sh:ro" \
-    -e DEBIAN_FRONTEND=noninteractive \
-    "$IMAGE" bash -euo pipefail -c '
-        apt-get update -qq
-        apt-get install -y -qq curl tar openssl ca-certificates jq > /dev/null
-
-        echo "--- installing released x-ui binary (no DB, no systemd) ---"
-        REPO=MHSanaei/3x-ui
-        ARCH=$(dpkg --print-architecture)   # amd64 | arm64
-        echo "container arch: $ARCH"
-        VER=$(curl --fail --location --silent --show-error \
-            --retry 5 --retry-all-errors --retry-delay 3 \
-            --connect-timeout 15 --max-time 60 \
-            "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
-        [ -n "$VER" ] && [ "$VER" != "null" ] || { echo "FAIL: cannot resolve version"; exit 1; }
-        tmp=$(mktemp -d)
-        # 504s and other transient GitHub/CDN hiccups are retried; a real HTTP
-        # failure (e.g. missing arch asset) still aborts after the retries.
-        if ! curl -4 --fail --location --silent --show-error \
-            --retry 5 --retry-all-errors --retry-delay 3 \
-            --connect-timeout 15 --max-time 300 \
-            -o "${tmp}/x.tar.gz" \
-            "https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"; then
-            echo "FAIL: cannot download x-ui-linux-${ARCH}.tar.gz (${VER})" >&2; exit 1
-        fi
-        test -s "${tmp}/x.tar.gz" || { echo "FAIL: downloaded tarball is empty"; exit 1; }
-        tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
-        chmod +x /usr/local/x-ui/x-ui
-        install -m 755 /root/x-ui-firstboot.sh /usr/local/x-ui/x-ui-firstboot.sh
-
-        # Guarantee a clean slate (the image must never ship a DB).
-        rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done
-
-        echo "--- run 1: generate per-instance credentials ---"
-        /usr/local/x-ui/x-ui-firstboot.sh
-
-        test -f /etc/x-ui/.firstboot-done || { echo "FAIL: sentinel not created"; exit 1; }
-        test -f /etc/x-ui/credentials.txt || { echo "FAIL: credentials.txt missing"; exit 1; }
-        perms=$(stat -c %a /etc/x-ui/credentials.txt)
-        [ "$perms" = "600" ] || { echo "FAIL: credentials.txt perms=$perms (want 600)"; exit 1; }
-        grep -q "3x-ui" /etc/motd || { echo "FAIL: motd not written"; exit 1; }
-
-        # shellcheck disable=SC1090
-        . /etc/x-ui/credentials.txt
-        [ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
-            || { echo "FAIL: username missing or still admin"; exit 1; }
-        first_user="$XUI_USERNAME"
-
-        /usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
-            || { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
-
-        echo "--- run 2: must be a no-op (sentinel honored) ---"
-        /usr/local/x-ui/x-ui-firstboot.sh
-        # shellcheck disable=SC1090
-        . /etc/x-ui/credentials.txt
-        [ "$XUI_USERNAME" = "$first_user" ] \
-            || { echo "FAIL: credentials changed on re-run"; exit 1; }
-
-        echo "SMOKE_PASS: firstboot user=$first_user (stable across re-run)"
-    '
-
-echo "== first-boot smoke test PASSED =="

+ 8 - 4
docker-compose.yml

@@ -5,8 +5,8 @@ services:
       dockerfile: ./Dockerfile
     container_name: 3xui_app
     # hostname: yourhostname <- optional
-    # Optional hard memory cap. When set, the panel auto-derives its Go soft
-    # limit (GOMEMLIMIT, ~90%) from this so it GCs before the OOM killer fires.
+    # Optional hard memory cap. When set, the panel derives its Go soft limit
+    # (GOMEMLIMIT, ~90% of this cap) so it GCs before the OOM killer fires.
     # mem_limit: 512m
     # The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
     # with iptables, which needs NET_ADMIN. Without these caps a ban is logged
@@ -21,8 +21,12 @@ services:
     environment:
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
-      # Go memory soft limit. If neither is set, the panel auto-detects the
-      # cgroup/host limit and targets ~90%. Pin it explicitly with one of:
+      # Memory tuning. The panel keeps RAM low via GOGC + periodic release; it no
+      # longer sets a soft limit from total host RAM (no benefit, risks GC thrash).
+      # XUI_GOGC: "75"              # lower = less RAM, slightly more CPU; GOGC env overrides
+      # XUI_MEMORY_RELEASE_INTERVAL: "10"  # minutes between FreeOSMemory; 0 disables
+      # Go memory soft limit, only applied from an explicit budget below (or a
+      # real cgroup/mem_limit cap). Pin it with one of:
       # XUI_MEMORY_LIMIT: "400"      # in MiB
       # GOMEMLIMIT: "400MiB"         # Go syntax, takes precedence
       # XUI_PPROF: "true"           # expose pprof on 127.0.0.1:6060 for profiling

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 282 - 282
frontend/package-lock.json


+ 3 - 3
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.4.0",
+  "version": "0.4.1",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
@@ -30,10 +30,10 @@
     "axios": "^1.18.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
-    "i18next": "^26.3.1",
+    "i18next": "^26.3.2",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
-    "qs": "^6.15.2",
+    "qs": "^6.15.3",
     "react": "^19.2.7",
     "react-dom": "^19.2.7",
     "react-i18next": "^17.0.8",

+ 116 - 0
frontend/public/openapi.json

@@ -5572,6 +5572,122 @@
         }
       }
     },
+    "/panel/api/clients/bulkEnable": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Enable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to add each user. Note that enabling a client whose quota is exhausted or whose expiry has passed only flips the flag — the traffic loop will disable it again on the next tick. Returns the changed count and per-email skip reasons.",
+        "operationId": "post_panel_api_clients_bulkEnable",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "changed": 2,
+                    "skipped": [
+                      {
+                        "email": "carol",
+                        "reason": "client not found"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/bulkDisable": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Disable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to remove each user. Returns the changed count and per-email skip reasons.",
+        "operationId": "post_panel_api_clients_bulkDisable",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "changed": 2,
+                    "skipped": [
+                      {
+                        "email": "carol",
+                        "reason": "client not found"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/bulkDel": {
       "post": {
         "tags": [

+ 21 - 0
frontend/src/hooks/useClients.ts

@@ -14,6 +14,7 @@ import {
   BulkAttachResultSchema,
   BulkCreateResultSchema,
   BulkDeleteResultSchema,
+  BulkSetEnableResultSchema,
   BulkDetachResultSchema,
   DelDepletedResultSchema,
   type ClientHydrate,
@@ -27,6 +28,7 @@ import {
   type BulkAttachResult,
   type BulkCreateResult,
   type BulkDeleteResult,
+  type BulkSetEnableResult,
   type BulkDetachResult,
 } from '@/schemas/client';
 import { DefaultsPayloadSchema } from '@/schemas/defaults';
@@ -348,6 +350,15 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+  const bulkSetEnableMut = useMutation({
+    mutationFn: async (payload: { emails: string[]; enable: boolean }): Promise<Msg<BulkSetEnableResult>> => {
+      const path = payload.enable ? '/panel/api/clients/bulkEnable' : '/panel/api/clients/bulkDisable';
+      const raw = await HttpUtil.post(path, { emails: payload.emails }, JSON_HEADERS);
+      return parseMsg(raw, BulkSetEnableResultSchema, payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
   const attachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
       HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
@@ -439,6 +450,14 @@ export function useClients() {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
   }, [bulkAdjustMut]);
+  const bulkEnable = useCallback((emails: string[]) => {
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
+    return bulkSetEnableMut.mutateAsync({ emails, enable: true });
+  }, [bulkSetEnableMut]);
+  const bulkDisable = useCallback((emails: string[]) => {
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
+    return bulkSetEnableMut.mutateAsync({ emails, enable: false });
+  }, [bulkSetEnableMut]);
   const bulkAddToGroup = useCallback((emails: string[], group: string) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     return bulkAddToGroupMut.mutateAsync({ emails, group });
@@ -590,6 +609,8 @@ export function useClients() {
     remove,
     bulkDelete,
     bulkAdjust,
+    bulkEnable,
+    bulkDisable,
     bulkAddToGroup,
     bulkRemoveFromGroup,
     attach,

+ 1 - 0
frontend/src/hooks/usePageTitle.ts

@@ -8,6 +8,7 @@ const TITLE_KEYS: Record<string, string> = {
   '/clients': 'menu.clients',
   '/groups': 'menu.groups',
   '/nodes': 'menu.nodes',
+  '/hosts': 'menu.hosts',
   '/settings': 'menu.settings',
   '/xray': 'menu.xray',
   '/outbound': 'menu.outbounds',

+ 14 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -648,6 +648,20 @@ export const sections: readonly Section[] = [
         body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkEnable',
+        summary: 'Enable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to add each user. Note that enabling a client whose quota is exhausted or whose expiry has passed only flips the flag — the traffic loop will disable it again on the next tick. Returns the changed count and per-email skip reasons.',
+        body: '{\n  "emails": ["alice", "bob"]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "changed": 2,\n    "skipped": [\n      { "email": "carol", "reason": "client not found" }\n    ]\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkDisable',
+        summary: 'Disable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to remove each user. Returns the changed count and per-email skip reasons.',
+        body: '{\n  "emails": ["alice", "bob"]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "changed": 2,\n    "skipped": [\n      { "email": "carol", "reason": "client not found" }\n    ]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/bulkDel',

+ 80 - 23
frontend/src/pages/clients/ClientsPage.tsx

@@ -27,6 +27,7 @@ import {
 } from 'antd';
 import type { ColumnsType, TableProps } from 'antd/es/table';
 import {
+  CheckCircleOutlined,
   ClockCircleOutlined,
   DeleteOutlined,
   DisconnectOutlined,
@@ -42,6 +43,7 @@ import {
   RetweetOutlined,
   SearchOutlined,
   SortAscendingOutlined,
+  StopOutlined,
   TagsOutlined,
   TeamOutlined,
   UploadOutlined,
@@ -204,7 +206,7 @@ export default function ClientsPage() {
     setQuery,
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
+    create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     refresh,
@@ -641,6 +643,35 @@ export default function ClientsPage() {
     });
   }
 
+  function onBulkSetEnable(enable: boolean) {
+    const emails = [...selectedRowKeys];
+    if (emails.length === 0) return;
+    modal.confirm({
+      title: t(enable ? 'pages.clients.bulkEnableConfirmTitle' : 'pages.clients.bulkDisableConfirmTitle', { count: emails.length }),
+      content: t(enable ? 'pages.clients.bulkEnableConfirmContent' : 'pages.clients.bulkDisableConfirmContent'),
+      okText: t('confirm'),
+      okType: enable ? 'primary' : 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = enable ? await bulkEnable(emails) : await bulkDisable(emails);
+        setSelectedRowKeys([]);
+        const changed = msg?.obj?.changed ?? 0;
+        const skipped = msg?.obj?.skipped ?? [];
+        const failed = skipped.length;
+        const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
+        const okKey = enable ? 'pages.clients.toasts.bulkEnabled' : 'pages.clients.toasts.bulkDisabled';
+        const mixedKey = enable ? 'pages.clients.toasts.bulkEnabledMixed' : 'pages.clients.toasts.bulkDisabledMixed';
+        if (failed === 0 && msg?.success) {
+          messageApi.success(t(okKey, { count: changed }));
+        } else {
+          messageApi.warning(firstError
+            ? `${t(mixedKey, { ok: changed, failed })} — ${firstError}`
+            : t(mixedKey, { ok: changed, failed }));
+        }
+      },
+    });
+  }
+
   function onBulkDelete() {
     const emails = [...selectedRowKeys];
     if (emails.length === 0) return;
@@ -1012,28 +1043,14 @@ export default function ClientsPage() {
                               {!isMobile && t('pages.clients.addClients')}
                             </Button>
                           ) : (
-                            <>
-                              <Tag
-                                color="blue"
-                                closable
-                                onClose={() => setSelectedRowKeys([])}
-                                style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
-                              >
-                                {t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
-                              </Tag>
-                              <Button icon={<UsergroupAddOutlined />} onClick={() => setBulkAttachOpen(true)}>
-                                {!isMobile && t('pages.clients.attach')}
-                              </Button>
-                              <Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
-                                {!isMobile && t('pages.clients.detach')}
-                              </Button>
-                              <Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
-                                {!isMobile && t('pages.clients.addToGroup')}
-                              </Button>
-                              <Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
-                                {!isMobile && t('pages.clients.ungroup')}
-                              </Button>
-                            </>
+                            <Tag
+                              color="blue"
+                              closable
+                              onClose={() => setSelectedRowKeys([])}
+                              style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
+                            >
+                              {t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
+                            </Tag>
                           )}
                           <Dropdown
                             trigger={['click']}
@@ -1041,6 +1058,46 @@ export default function ClientsPage() {
                             menu={{
                               items: selectedRowKeys.length > 0
                                 ? [
+                                  {
+                                    key: 'attach',
+                                    icon: <UsergroupAddOutlined />,
+                                    label: t('pages.clients.attach'),
+                                    onClick: () => setBulkAttachOpen(true),
+                                  },
+                                  {
+                                    key: 'detach',
+                                    icon: <UsergroupDeleteOutlined />,
+                                    label: t('pages.clients.detach'),
+                                    danger: true,
+                                    onClick: () => setBulkDetachOpen(true),
+                                  },
+                                  {
+                                    key: 'addToGroup',
+                                    icon: <TagsOutlined />,
+                                    label: t('pages.clients.addToGroup'),
+                                    onClick: () => setBulkGroupOpen(true),
+                                  },
+                                  {
+                                    key: 'ungroup',
+                                    icon: <UngroupIcon />,
+                                    label: t('pages.clients.ungroup'),
+                                    danger: true,
+                                    onClick: onBulkUngroup,
+                                  },
+                                  { type: 'divider' as const },
+                                  {
+                                    key: 'enable',
+                                    icon: <CheckCircleOutlined />,
+                                    label: t('pages.clients.enable'),
+                                    onClick: () => onBulkSetEnable(true),
+                                  },
+                                  {
+                                    key: 'disable',
+                                    icon: <StopOutlined />,
+                                    label: t('pages.clients.disable'),
+                                    danger: true,
+                                    onClick: () => onBulkSetEnable(false),
+                                  },
                                   {
                                     key: 'adjust',
                                     icon: <ClockCircleOutlined />,

+ 19 - 2
frontend/src/pages/index/LogModal.tsx

@@ -13,12 +13,15 @@ interface LogModalProps {
   onClose: () => void;
 }
 
+const AUTO_UPDATE_INTERVAL = 5000;
+
 export default function LogModal({ open, onClose }: LogModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
   const [rows, setRows] = useState('20');
   const [level, setLevel] = useState('info');
   const [syslog, setSyslog] = useState(false);
+  const [autoUpdate, setAutoUpdate] = useState(false);
   const [loading, setLoading] = useState(false);
   const [logs, setLogs] = useState<string[]>([]);
   const openRef = useRef(open);
@@ -39,6 +42,11 @@ export default function LogModal({ open, onClose }: LogModalProps) {
     }
   }, [rows, level, syslog]);
 
+  const refreshRef = useRef(refresh);
+  useEffect(() => {
+    refreshRef.current = refresh;
+  }, [refresh]);
+
   useEffect(() => {
     openRef.current = open;
     if (open) refresh();
@@ -48,6 +56,12 @@ export default function LogModal({ open, onClose }: LogModalProps) {
     if (openRef.current) refresh();
   }, [rows, level, syslog, refresh]);
 
+  useEffect(() => {
+    if (!open || !autoUpdate) return;
+    const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
+    return () => clearInterval(id);
+  }, [open, autoUpdate]);
+
   const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]);
 
   function download() {
@@ -80,11 +94,11 @@ export default function LogModal({ open, onClose }: LogModalProps) {
               style={{ width: 70 }}
               onChange={setRows}
               options={[
-                { value: '10', label: '10' },
                 { value: '20', label: '20' },
                 { value: '50', label: '50' },
                 { value: '100', label: '100' },
                 { value: '500', label: '500' },
+                { value: '1000', label: '1000' },
               ]}
             />
             <Select
@@ -106,6 +120,9 @@ export default function LogModal({ open, onClose }: LogModalProps) {
           <Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
             SysLog
           </Checkbox>
+          <Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
+            {t('pages.index.autoUpdate')}
+          </Checkbox>
         </Form.Item>
         <Form.Item className="download-item">
           <Button type="primary" onClick={download} icon={<DownloadOutlined />} />
@@ -147,7 +164,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
               {log.levelText && <span className={`log-level ${log.levelClass}`}>{log.levelText}</span>}
               {(log.body || log.service) && (
                 <>
-                  <span> - </span>
+                  {(log.stamp || log.levelText) && <span> - </span>}
                   {log.service && <b>{log.service}</b>}
                   {log.service && log.body ? ' ' : ''}
                   <span>{log.body}</span>

+ 7 - 5
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -137,10 +137,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
         const tss: number[] = [];
         for (const p of msg.obj) {
           const d = new Date(p.t * 1000);
+          const MM = String(d.getMonth() + 1).padStart(2, '0');
+          const DD = String(d.getDate()).padStart(2, '0');
           const hh = String(d.getHours()).padStart(2, '0');
           const mm = String(d.getMinutes()).padStart(2, '0');
           const ss = String(d.getSeconds()).padStart(2, '0');
-          labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
+          const lab = bucket >= 2880 ? `${MM}-${DD} ${hh}:${mm}` : bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`;
+          labs.push(lab);
           vals.push(Number(p.v) || 0);
           tss.push(Number(p.t) || 0);
         }
@@ -208,14 +211,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
             onChange={setBucket}
             options={[
               { value: 2, label: '2m' },
-              { value: 30, label: '30m' },
               { value: 60, label: '1h' },
-              { value: 120, label: '2h' },
               { value: 180, label: '3h' },
-              { value: 300, label: '5h' },
+              { value: 360, label: '6h' },
               { value: 720, label: '12h' },
               { value: 1440, label: '24h' },
-              { value: 2880, label: '48h' },
+              { value: 2880, label: '2d' },
+              { value: 10080, label: '7d' },
             ]}
           />
         </div>

+ 19 - 2
frontend/src/pages/index/XrayLogModal.tsx

@@ -44,6 +44,8 @@ function shortTime(value?: string | number): string {
   return `${hh}:${mm}:${ss}`;
 }
 
+const AUTO_UPDATE_INTERVAL = 5000;
+
 export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
   const { t } = useTranslation();
   const { datepicker } = useDatepicker();
@@ -53,6 +55,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
   const [showDirect, setShowDirect] = useState(true);
   const [showBlocked, setShowBlocked] = useState(true);
   const [showProxy, setShowProxy] = useState(true);
+  const [autoUpdate, setAutoUpdate] = useState(false);
   const [loading, setLoading] = useState(false);
   const [logs, setLogs] = useState<XrayLogEntry[]>([]);
   const openRef = useRef(open);
@@ -75,6 +78,11 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
     }
   }, [rows, filter, showDirect, showBlocked, showProxy]);
 
+  const refreshRef = useRef(refresh);
+  useEffect(() => {
+    refreshRef.current = refresh;
+  }, [refresh]);
+
   useEffect(() => {
     openRef.current = open;
     if (open) refresh();
@@ -84,6 +92,12 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
     if (openRef.current) refresh();
   }, [rows, showDirect, showBlocked, showProxy, refresh]);
 
+  useEffect(() => {
+    if (!open || !autoUpdate) return;
+    const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
+    return () => clearInterval(id);
+  }, [open, autoUpdate]);
+
   function fullDate(value?: string | number): string {
     return IntlUtil.formatDate(value, datepicker);
   }
@@ -117,7 +131,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
       onCancel={onClose}
       title={
         <>
-          {t('pages.index.logs')}
+          {t('pages.index.accessLogs')}
           <SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
         </>
       }
@@ -130,11 +144,11 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
             style={{ width: 70 }}
             onChange={setRows}
             options={[
-              { value: '10', label: '10' },
               { value: '20', label: '20' },
               { value: '50', label: '50' },
               { value: '100', label: '100' },
               { value: '500', label: '500' },
+              { value: '1000', label: '1000' },
             ]}
           />
         </Form.Item>
@@ -158,6 +172,9 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
           <Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
             Proxy
           </Checkbox>
+          <Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
+            {t('pages.index.autoUpdate')}
+          </Checkbox>
         </Form.Item>
         <Form.Item className="download-item">
           <Button type="primary" onClick={download} icon={<DownloadOutlined />} />

+ 2 - 3
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -286,11 +286,10 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
             onChange={setBucket}
             options={[
               { value: 2, label: '2m' },
-              { value: 30, label: '30m' },
               { value: 60, label: '1h' },
-              { value: 120, label: '2h' },
               { value: 180, label: '3h' },
-              { value: 300, label: '5h' },
+              { value: 360, label: '6h' },
+              { value: 720, label: '12h' },
             ]}
           />
         </div>

+ 1 - 1
frontend/src/pages/index/XrayStatusCard.tsx

@@ -92,7 +92,7 @@ export default function XrayStatusCard({
       ? [
           <Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
             <BarsOutlined />
-            {!isMobile && <span>{t('pages.index.logs')}</span>}
+            {!isMobile && <span>{t('pages.index.accessLogs')}</span>}
           </Space>,
         ]
       : []),

+ 14 - 8
frontend/src/pages/index/logParse.ts

@@ -41,6 +41,11 @@ const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S
 const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
 // telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
 const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
+// App-log format emitted by the in-memory buffer:
+// "2006/01/02 15:04:05 LEVEL - message". Only a line matching this exact shape
+// carries a structured timestamp/level; anything else (e.g. a plain notice such
+// as the Windows "Syslog is not supported" message) is kept whole as the body.
+const APP_LOG = /^(\d{4}\/\d{2}\/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\S+)\s+-\s+([\s\S]*)$/;
 
 // splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
 // and the remainder. Returns null when the message does not start with a level.
@@ -84,16 +89,17 @@ export function parseLogLine(line: string): ParsedLog {
       }
     }
   } else {
-    // App-log format: "2006/01/02 15:04:05 LEVEL - body"
-    const [head, ...rest] = raw.split(' - ');
-    const message = rest.join(' - ');
-    const parts = head.split(' ');
-    if (parts.length >= 3) {
-      [date, time, levelText] = parts;
+    const app = raw.match(APP_LOG);
+    if (app) {
+      // App-log format: "2006/01/02 15:04:05 LEVEL - body"
+      date = app[1];
+      time = app[2];
+      levelText = app[3];
+      body = app[4];
     } else {
-      levelText = head;
+      // Plain message with no timestamp/level — show it verbatim.
+      body = raw;
     }
-    body = message || '';
   }
 
   const li = LEVELS.indexOf(levelText);

+ 8 - 0
frontend/src/schemas/client.ts

@@ -101,6 +101,13 @@ export const BulkDeleteResultSchema = z.object({
     .optional(),
 });
 
+export const BulkSetEnableResultSchema = z.object({
+  changed: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
 export const BulkCreateResultSchema = z.object({
   created: z.number(),
   skipped: z
@@ -221,6 +228,7 @@ export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
 export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
 export type BulkDeleteResult = z.infer<typeof BulkDeleteResultSchema>;
+export type BulkSetEnableResult = z.infer<typeof BulkSetEnableResultSchema>;
 export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
 export type BulkAttachResult = z.infer<typeof BulkAttachResultSchema>;
 export type BulkDetachResult = z.infer<typeof BulkDetachResultSchema>;

+ 13 - 16
go.mod

@@ -30,19 +30,10 @@ require (
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlite v1.6.0
-	gorm.io/gorm v1.31.1
+	gorm.io/gorm v1.31.2
 	pgregory.net/rapid v1.3.0
 )
 
-require (
-	github.com/pion/dtls/v3 v3.1.4 // indirect
-	github.com/pion/logging v0.2.4 // indirect
-	github.com/pion/stun/v3 v3.1.6 // indirect
-	github.com/pion/transport/v4 v4.0.2 // indirect
-	github.com/wlynxg/anet v0.0.5 // indirect
-	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
-)
-
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
 	github.com/andybalholm/brotli v1.2.1 // indirect
@@ -50,7 +41,7 @@ require (
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/sonic v1.15.2 // indirect
 	github.com/bytedance/sonic/loader v0.5.1 // indirect
-	github.com/cloudflare/circl v1.6.3 // indirect
+	github.com/cloudflare/circl v1.6.4 // indirect
 	github.com/cloudwego/base64x v0.1.7 // indirect
 	github.com/ebitengine/purego v0.10.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -77,11 +68,15 @@ require (
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
 	github.com/mattn/go-isatty v0.0.22 // indirect
-	github.com/mattn/go-sqlite3 v1.14.45 // indirect
+	github.com/mattn/go-sqlite3 v1.14.47 // indirect
 	github.com/miekg/dns v1.1.72 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	github.com/pelletier/go-toml/v2 v2.3.1 // indirect
+	github.com/pelletier/go-toml/v2 v2.4.2 // indirect
+	github.com/pion/dtls/v3 v3.1.4 // indirect
+	github.com/pion/logging v0.2.4 // indirect
+	github.com/pion/stun/v3 v3.1.6 // indirect
+	github.com/pion/transport/v4 v4.0.2 // indirect
 	github.com/pires/go-proxyproto v0.12.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
@@ -98,9 +93,10 @@ require (
 	github.com/valyala/fastjson v1.6.10 // indirect
 	github.com/vishvananda/netlink v1.3.1 // indirect
 	github.com/vishvananda/netns v0.0.5 // indirect
+	github.com/wlynxg/anet v0.0.5 // indirect
 	github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
-	go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
+	go.mongodb.org/mongo-driver/v2 v2.7.0 // indirect
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
 	golang.org/x/arch v0.28.0 // indirect
 	golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
@@ -108,10 +104,11 @@ require (
 	golang.org/x/net v0.56.0
 	golang.org/x/sync v0.21.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
-	golang.org/x/tools v0.46.0 // indirect
+	golang.org/x/tools v0.47.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
+	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect

+ 14 - 14
go.sum

@@ -16,8 +16,8 @@ github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NI
 github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
-github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
+github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
 github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
 github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
 github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
 github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
-github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
-github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
+github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
+github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
 github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
 github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -146,8 +146,8 @@ github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0C
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
 github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
-github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
 github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
 github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@@ -224,8 +224,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
-go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
+go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
@@ -269,8 +269,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
 golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
 golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
-golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
 google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -299,8 +299,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
 gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
-gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=

+ 1 - 1
internal/config/version

@@ -1 +1 @@
-3.4.0
+3.4.1

+ 7 - 0
internal/sub/remark_vars.go

@@ -441,6 +441,13 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
 	if stats, ok := s.statsByEmail[client.Email]; ok {
 		return stats
 	}
+	// Both in-memory paths key off client_traffics.inbound_id, which goes stale
+	// when an inbound is deleted and recreated, orphaning the row from every
+	// loaded inbound. Fall back to a direct lookup by the globally-unique email
+	// so usage still resolves for clients predating that recreation (#5567).
+	if stats, ok := s.statsByEmailFromDB(client.Email); ok {
+		return stats
+	}
 	return xray.ClientTraffic{
 		Enable:     client.Enable,
 		ExpiryTime: client.ExpiryTime,

+ 26 - 0
internal/sub/service.go

@@ -1665,6 +1665,32 @@ func (s *SubService) findClientStats(inbound *model.Inbound, email string) (xray
 	return xray.ClientTraffic{}, false
 }
 
+// statsByEmailFromDB resolves a client's traffic row straight from the DB by its
+// globally-unique email, caching the hit into statsByEmail for the rest of the
+// request. It's the last-resort lookup behind statsForClient: the preloaded
+// ClientStats and the statsByEmail index are both keyed by
+// client_traffics.inbound_id, which is written once by AddClientStat and never
+// updated. When an inbound is deleted and recreated it gets a new id, so the old
+// row is orphaned from every loaded inbound and both in-memory paths miss —
+// leaving {{TRAFFIC_USED}} stuck at 0 for pre-existing clients even though their
+// usage is intact (#5567). Matching by email recovers it, the same way the
+// sub-info header's AggregateTrafficByEmails already does.
+func (s *SubService) statsByEmailFromDB(email string) (xray.ClientTraffic, bool) {
+	db := database.GetDB()
+	if db == nil {
+		return xray.ClientTraffic{}, false
+	}
+	var row xray.ClientTraffic
+	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
+		return xray.ClientTraffic{}, false
+	}
+	if s.statsByEmail == nil {
+		s.statsByEmail = map[string]xray.ClientTraffic{}
+	}
+	s.statsByEmail[email] = row
+	return row, true
+}
+
 func searchKey(data any, key string) (any, bool) {
 	switch val := data.(type) {
 	case map[string]any:

+ 54 - 0
internal/sub/service_orphaned_stats_test.go

@@ -0,0 +1,54 @@
+package sub
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// statsForClient recovers a client's usage by email when the client_traffics row
+// is orphaned — its inbound_id points at an inbound that was deleted and
+// recreated, so the preloaded ClientStats and the statsByEmail index both miss.
+// Before the email fallback, {{TRAFFIC_USED}} stayed at 0 for such pre-existing
+// clients while the sub-info header was correct (#5567).
+func TestStatsForClient_OrphanedInboundIdFallback(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const email = "[email protected]"
+	const total = int64(100) * gb
+
+	db := database.GetDB()
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 999,
+		Email:     email,
+		Up:        15 * gb,
+		Down:      5 * gb,
+		Total:     total,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatalf("seed orphaned traffic: %v", err)
+	}
+
+	s := &SubService{statsByEmail: map[string]xray.ClientTraffic{}}
+	inbound := &model.Inbound{Id: 1, Remark: "DE"}
+	client := model.Client{Email: email, TotalGB: total, Enable: true}
+
+	st := s.statsForClient(inbound, client)
+	if used := st.Up + st.Down; used != 20*gb {
+		t.Fatalf("statsForClient used = %d, want %d (email fallback)", used, 20*gb)
+	}
+	if _, ok := s.statsByEmail[email]; !ok {
+		t.Fatalf("email fallback must cache the row into statsByEmail")
+	}
+	if got := remarkVarValue("TRAFFIC_USED", remarkContext{stats: st}); got != "20.00GB" {
+		t.Fatalf("TRAFFIC_USED = %q, want 20.00GB", got)
+	}
+}

+ 2 - 2
internal/tunnelmonitor/monitor_test.go

@@ -367,7 +367,7 @@ func TestMonitorNilRecoverStaysBounded(t *testing.T) {
 
 	monitor := newWithClient(cfg, failingClient(), nil)
 
-	for i := 0; i < 5; i++ {
+	for range 5 {
 		recovered, _ := monitor.Step(context.Background())
 		if recovered {
 			t.Fatal("a nil recovery func must never report recovery")
@@ -402,7 +402,7 @@ func TestMonitorFailuresCappedDuringCooldown(t *testing.T) {
 		t.Fatal("expected recovery once the threshold is reached")
 	}
 
-	for i := 0; i < 6; i++ {
+	for range 6 {
 		monitor.Step(context.Background())
 		if monitor.failures > cfg.FailureThreshold {
 			t.Fatalf("failures must never exceed threshold %d during cooldown, got %d", cfg.FailureThreshold, monitor.failures)

+ 62 - 26
internal/util/sys/memlimit.go

@@ -1,27 +1,59 @@
 package sys
 
 import (
+	"fmt"
 	"os"
 	"runtime/debug"
 	"strconv"
 	"strings"
+)
 
-	"github.com/shirou/gopsutil/v4/mem"
+const (
+	memLimitHeadroomPercent = 90
+	defaultGCPercent        = 75
+	defaultReleaseMinutes   = 10
 )
 
-// memLimitHeadroomPercent is the share of detected memory used for the soft
-// limit, leaving room for non-heap (stacks, mmap, the xray child) before the OS
-// OOM-kills the process.
-const memLimitHeadroomPercent = 90
+// ApplyMemoryTuning configures the Go runtime for a lower, steadier footprint and
+// returns one log line per decision. It does NOT derive a soft limit from total
+// system RAM: on a shared or uncontrolled host that gives no benefit (GOGC, not
+// the limit, paces GC while the heap is far below it) and risks GC thrashing, so
+// memory is kept low via GOGC plus the periodic release job instead.
+func ApplyMemoryTuning() []string {
+	lines := []string{applyGCPercent()}
+	if limit, source := applyMemoryLimit(); limit > 0 {
+		lines = append(lines, fmt.Sprintf("Go memory soft limit set to %d MiB (%s)", limit>>20, source))
+	} else {
+		lines = append(lines, "Go memory soft limit not enforced: "+source)
+	}
+	return lines
+}
+
+// applyGCPercent lowers GOGC so the heap high-water mark, and thus RSS, stays
+// smaller. An explicit GOGC env (including GOGC=off) is left to the runtime.
+func applyGCPercent() string {
+	if _, ok := os.LookupEnv("GOGC"); ok {
+		return "GC percent: GOGC env (handled by the Go runtime)"
+	}
+
+	pct := defaultGCPercent
+	if v := strings.TrimSpace(os.Getenv("XUI_GOGC")); v != "" {
+		if n, err := strconv.Atoi(v); err == nil {
+			pct = n
+		}
+	}
+
+	if pct <= 0 {
+		return "GC percent left at Go default"
+	}
+	debug.SetGCPercent(pct)
+	return fmt.Sprintf("GC percent set to %d", pct)
+}
 
-// ApplyMemoryLimit sets a Go soft memory limit (the runtime's GOMEMLIMIT) when
-// one is not already configured, so a long-running panel in a memory-capped
-// container or VPS triggers GC as it approaches the cap instead of growing RSS
-// until the OS OOM-kills it. Precedence: an explicit GOMEMLIMIT env is left to
-// the runtime; otherwise XUI_MEMORY_LIMIT (in MiB) wins; otherwise the limit is
-// derived from the cgroup memory limit, falling back to total system RAM.
-// Returns the limit applied in bytes (0 when none) and a short source label.
-func ApplyMemoryLimit() (int64, string) {
+// applyMemoryLimit sets the soft limit only from an explicit budget: GOMEMLIMIT
+// env (left to the runtime), XUI_MEMORY_LIMIT in MiB, or a real cgroup limit at
+// 90% to leave headroom for non-heap and the xray child. No budget -> Go default.
+func applyMemoryLimit() (int64, string) {
 	if strings.TrimSpace(os.Getenv("GOMEMLIMIT")) != "" {
 		return 0, "GOMEMLIMIT env (handled by the Go runtime)"
 	}
@@ -34,28 +66,32 @@ func ApplyMemoryLimit() (int64, string) {
 		}
 	}
 
-	total, source := detectAvailableMemory()
-	if total <= 0 {
-		return 0, "undetectable; left at Go default"
+	if v, ok := cgroupMemoryLimit(); ok {
+		limit := v / 100 * memLimitHeadroomPercent
+		debug.SetMemoryLimit(limit)
+		return limit, "cgroup limit"
 	}
-	limit := total / 100 * memLimitHeadroomPercent
-	debug.SetMemoryLimit(limit)
-	return limit, source
+
+	return 0, "no explicit budget; soft limit left at Go default"
 }
 
-func detectAvailableMemory() (int64, string) {
-	if v, ok := cgroupMemoryLimit(); ok {
-		return v, "cgroup limit"
+// MemoryReleaseIntervalMinutes reports how often freed heap memory is returned to
+// the OS via debug.FreeOSMemory. XUI_MEMORY_RELEASE_INTERVAL overrides the
+// default; an explicit 0 disables the periodic release.
+func MemoryReleaseIntervalMinutes() int {
+	v := strings.TrimSpace(os.Getenv("XUI_MEMORY_RELEASE_INTERVAL"))
+	if v == "" {
+		return defaultReleaseMinutes
 	}
-	if vm, err := mem.VirtualMemory(); err == nil && vm.Total > 0 {
-		return int64(vm.Total), "system RAM"
+	if n, err := strconv.Atoi(v); err == nil && n >= 0 {
+		return n
 	}
-	return 0, ""
+	return defaultReleaseMinutes
 }
 
 // cgroupMemoryLimit reads the container memory limit from cgroup v2 then v1.
 // A "max" value or the v1 unlimited sentinel (~8 EiB) means no limit at this
-// level, so it reports not-found and the caller falls back to system RAM. The
+// level, so it reports not-found and the caller falls back to the Go default. The
 // files are absent off Linux, which also yields not-found.
 func cgroupMemoryLimit() (int64, bool) {
 	const unlimited = int64(1) << 62

+ 34 - 0
internal/util/sys/procmem.go

@@ -0,0 +1,34 @@
+package sys
+
+import (
+	"os"
+	"sync"
+
+	"github.com/shirou/gopsutil/v4/process"
+)
+
+var (
+	selfProc     *process.Process
+	selfProcOnce sync.Once
+)
+
+// SelfRSS returns the resident set size of the current process in bytes — the
+// real physical memory the OS attributes to the panel. Unlike
+// runtime.MemStats.Sys (a never-shrinking high-water mark of reserved address
+// space that also counts memory already returned to the OS), RSS reflects current
+// usage and drops as memory is released. Returns 0 when unavailable.
+func SelfRSS() uint64 {
+	selfProcOnce.Do(func() {
+		if p, err := process.NewProcess(int32(os.Getpid())); err == nil {
+			selfProc = p
+		}
+	})
+
+	if selfProc == nil {
+		return 0
+	}
+	if mi, err := selfProc.MemoryInfo(); err == nil && mi != nil {
+		return mi.RSS
+	}
+	return 0
+}

+ 32 - 0
internal/web/controller/client.go

@@ -64,6 +64,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
+	g.POST("/bulkEnable", a.bulkEnable)
+	g.POST("/bulkDisable", a.bulkDisable)
 	g.POST("/bulkDel", a.bulkDelete)
 	g.POST("/bulkCreate", a.bulkCreate)
 	g.POST("/bulkAttach", a.bulkAttach)
@@ -338,6 +340,36 @@ func (a *ClientController) bulkDelete(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+type bulkEnableRequest struct {
+	Emails []string `json:"emails"`
+}
+
+func (a *ClientController) bulkEnable(c *gin.Context) {
+	a.bulkSetEnable(c, true)
+}
+
+func (a *ClientController) bulkDisable(c *gin.Context) {
+	a.bulkSetEnable(c, false)
+}
+
+func (a *ClientController) bulkSetEnable(c *gin.Context, enable bool) {
+	var req bulkEnableRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.clientService.BulkSetEnable(&a.inboundService, req.Emails, enable)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
 func (a *ClientController) bulkCreate(c *gin.Context) {
 	var payloads []service.ClientCreatePayload
 	if err := c.ShouldBindJSON(&payloads); err != nil {

+ 19 - 0
internal/web/job/free_os_memory.go

@@ -0,0 +1,19 @@
+package job
+
+import "runtime/debug"
+
+// MemoryReleaseJob returns freed heap spans to the OS so steady-state RSS tracks
+// the live heap between the bursty traffic-collection jobs, instead of lingering
+// at the high-water mark until the scavenger lazily reclaims it.
+type MemoryReleaseJob struct{}
+
+// NewMemoryReleaseJob creates a new memory-release job instance.
+func NewMemoryReleaseJob() *MemoryReleaseJob {
+	return new(MemoryReleaseJob)
+}
+
+// Run forces a GC and returns as much free memory to the OS as possible. It is
+// scheduled on a minutes cadence because FreeOSMemory triggers a full GC.
+func (j *MemoryReleaseJob) Run() {
+	debug.FreeOSMemory()
+}

+ 297 - 0
internal/web/service/client_bulk.go

@@ -1291,3 +1291,300 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
 	}
 	return res.Deleted, needRestart, nil
 }
+
+type BulkSetEnableResult struct {
+	Changed int                   `json:"changed"`
+	Skipped []BulkSetEnableReport `json:"skipped,omitempty"`
+}
+
+type BulkSetEnableReport struct {
+	Email  string `json:"email"`
+	Reason string `json:"reason"`
+}
+
+func (s *ClientService) BulkSetEnable(inboundSvc *InboundService, emails []string, enable bool) (BulkSetEnableResult, bool, error) {
+	result := BulkSetEnableResult{}
+
+	seen := map[string]struct{}{}
+	cleanEmails := make([]string, 0, len(emails))
+	for _, e := range emails {
+		e = strings.TrimSpace(e)
+		if e == "" {
+			continue
+		}
+		if _, ok := seen[e]; ok {
+			continue
+		}
+		seen[e] = struct{}{}
+		cleanEmails = append(cleanEmails, e)
+	}
+	if len(cleanEmails) == 0 {
+		return result, false, nil
+	}
+
+	db := database.GetDB()
+
+	var records []model.ClientRecord
+	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+		var rows []model.ClientRecord
+		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+			return result, false, err
+		}
+		records = append(records, rows...)
+	}
+	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
+	for i := range records {
+		recordsByEmail[records[i].Email] = &records[i]
+	}
+
+	skippedReasons := map[string]string{}
+	for _, email := range cleanEmails {
+		if _, ok := recordsByEmail[email]; !ok {
+			skippedReasons[email] = "client not found"
+		}
+	}
+
+	clientIds := make([]int, 0, len(recordsByEmail))
+	recordIdToEmail := make(map[int]string, len(recordsByEmail))
+	for _, r := range recordsByEmail {
+		clientIds = append(clientIds, r.Id)
+		recordIdToEmail[r.Id] = r.Email
+	}
+
+	emailsByInbound := map[int][]string{}
+	if len(clientIds) > 0 {
+		var mappings []model.ClientInbound
+		for _, batch := range chunkInts(clientIds, sqlInChunk) {
+			var rows []model.ClientInbound
+			if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+				return result, false, err
+			}
+			mappings = append(mappings, rows...)
+		}
+		for _, m := range mappings {
+			email, ok := recordIdToEmail[m.ClientId]
+			if !ok {
+				continue
+			}
+			emailsByInbound[m.InboundId] = append(emailsByInbound[m.InboundId], email)
+		}
+	}
+
+	needRestart := false
+	for inboundId, ibEmails := range emailsByInbound {
+		ibRes := s.bulkSetEnableInboundClients(inboundSvc, inboundId, ibEmails, enable)
+		if ibRes.needRestart {
+			needRestart = true
+		}
+		for email, reason := range ibRes.perEmailSkipped {
+			if _, already := skippedReasons[email]; !already {
+				skippedReasons[email] = reason
+			}
+		}
+	}
+
+	successEmails := make([]string, 0, len(recordsByEmail))
+	for email := range recordsByEmail {
+		if _, skipped := skippedReasons[email]; skipped {
+			continue
+		}
+		successEmails = append(successEmails, email)
+	}
+
+	if len(successEmails) > 0 {
+		now := time.Now().UnixMilli()
+		if err := runSerializedTx(func(tx *gorm.DB) error {
+			for _, batch := range chunkStrings(successEmails, sqlInChunk) {
+				if e := tx.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("enable", enable).Error; e != nil {
+					return e
+				}
+				if e := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).
+					Updates(map[string]any{"enable": enable, "updated_at": now}).Error; e != nil {
+					return e
+				}
+			}
+			return nil
+		}); err != nil {
+			return result, needRestart, err
+		}
+	}
+
+	result.Changed = len(successEmails)
+	for email, reason := range skippedReasons {
+		result.Skipped = append(result.Skipped, BulkSetEnableReport{Email: email, Reason: reason})
+	}
+	return result, needRestart, nil
+}
+
+type bulkSetEnableInboundResult struct {
+	perEmailSkipped map[string]string
+	needRestart     bool
+}
+
+func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService, inboundId int, emails []string, enable bool) bulkSetEnableInboundResult {
+	res := bulkSetEnableInboundResult{perEmailSkipped: map[string]string{}}
+
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		for _, e := range emails {
+			res.perEmailSkipped[e] = err.Error()
+		}
+		return res
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
+		for _, e := range emails {
+			res.perEmailSkipped[e] = err.Error()
+		}
+		return res
+	}
+
+	wanted := make(map[string]struct{}, len(emails))
+	for _, email := range emails {
+		wanted[email] = struct{}{}
+	}
+
+	cipher := ""
+	if oldInbound.Protocol == model.Shadowsocks {
+		cipher, _ = settings["method"].(string)
+	}
+
+	type changedClient struct {
+		email     string
+		wasEnable bool
+		client    model.Client
+	}
+	var changed []changedClient
+	found := map[string]bool{}
+	nowMs := time.Now().UnixMilli()
+
+	interfaceClients, _ := settings["clients"].([]any)
+	for i, c := range interfaceClients {
+		entry, ok := c.(map[string]any)
+		if !ok {
+			continue
+		}
+		email, _ := entry["email"].(string)
+		if _, want := wanted[email]; !want || email == "" {
+			continue
+		}
+		found[email] = true
+		prev, _ := entry["enable"].(bool)
+		if prev == enable {
+			continue
+		}
+		entry["enable"] = enable
+		entry["updated_at"] = nowMs
+		interfaceClients[i] = entry
+		// Build the pushed client from the inbound JSON (the per-inbound source of
+		// truth), so a remote UpdateUser carries every field and never zeroes
+		// subId/totalGB/expiry from drifting ClientRecord columns (#4628/#4792).
+		var parsed model.Client
+		if b, mErr := json.Marshal(entry); mErr == nil {
+			_ = json.Unmarshal(b, &parsed)
+		}
+		parsed.Email = email
+		parsed.Enable = enable
+		changed = append(changed, changedClient{email: email, wasEnable: prev, client: parsed})
+	}
+
+	for email := range wanted {
+		if !found[email] {
+			res.perEmailSkipped[email] = "Client Not Found In Inbound"
+		}
+	}
+
+	if len(changed) == 0 {
+		return res
+	}
+
+	settings["clients"] = interfaceClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		for _, ch := range changed {
+			res.perEmailSkipped[ch.email] = err.Error()
+		}
+		return res
+	}
+	oldInbound.Settings = string(newSettings)
+
+	rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+	if perr != nil {
+		for _, ch := range changed {
+			res.perEmailSkipped[ch.email] = perr.Error()
+		}
+		return res
+	}
+	markDirty := dirty
+	if oldInbound.NodeID != nil && push && len(changed) > nodeBulkPushThreshold {
+		markDirty = true
+		push = false
+	}
+
+	txErr := runSerializedTx(func(tx *gorm.DB) error {
+		if e := tx.Save(oldInbound).Error; e != nil {
+			return e
+		}
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		return s.SyncInbound(tx, inboundId, finalClients)
+	})
+	if txErr != nil {
+		for _, ch := range changed {
+			res.perEmailSkipped[ch.email] = txErr.Error()
+		}
+		return res
+	}
+
+	if oldInbound.NodeID == nil {
+		if !push {
+			res.needRestart = true
+		} else {
+			for _, ch := range changed {
+				if enable {
+					err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+						"email":    ch.client.Email,
+						"id":       ch.client.ID,
+						"security": ch.client.Security,
+						"flow":     ch.client.Flow,
+						"auth":     ch.client.Auth,
+						"password": ch.client.Password,
+						"cipher":   cipher,
+					})
+					if err1 != nil {
+						logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+						res.needRestart = true
+					}
+				} else if ch.wasEnable {
+					err1 := rt.RemoveUser(context.Background(), oldInbound, ch.email)
+					if err1 != nil && !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", ch.email)) {
+						logger.Debug("Error in removing client on", rt.Name(), ":", err1)
+						res.needRestart = true
+					}
+				}
+			}
+		}
+	} else if push {
+		for _, ch := range changed {
+			updated := ch.client
+			updated.UpdatedAt = nowMs
+			if err1 := rt.UpdateUser(context.Background(), oldInbound, ch.email, updated); err1 != nil {
+				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+				markDirty = true
+			}
+		}
+	}
+
+	if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
+	}
+
+	return res
+}

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

@@ -380,6 +380,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 	structuralChange := false
 
+	newInboundIDs := make(map[int]struct{})
+
 	snapTags := make(map[string]struct{}, len(snap.Inbounds))
 	for _, snapIb := range snap.Inbounds {
 		if snapIb == nil {
@@ -466,6 +468,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if newIb.Tag != snapIb.Tag {
 				tagToCentral[newIb.Tag] = &newIb
 			}
+			newInboundIDs[newIb.Id] = struct{}{}
 			structuralChange = true
 			continue
 		}
@@ -620,6 +623,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				if dirty {
 					continue
 				}
+				var seedUp, seedDown int64
+				if _, isNewInbound := newInboundIDs[c.Id]; isNewInbound && !isClientEmailTombstoned(cs.Email) {
+					seedUp, seedDown = canon.Up, canon.Down
+				}
 				row := &xray.ClientTraffic{
 					InboundId:  c.Id,
 					Email:      cs.Email,
@@ -627,8 +634,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					Total:      cs.Total,
 					ExpiryTime: cs.ExpiryTime,
 					Reset:      cs.Reset,
-					Up:         0,
-					Down:       0,
+					Up:         seedUp,
+					Down:       seedDown,
 					LastOnline: cs.LastOnline,
 				}
 				if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).

+ 229 - 97
internal/web/service/metric_history.go

@@ -4,7 +4,6 @@ import (
 	"encoding/gob"
 	"os"
 	"path/filepath"
-	"slices"
 	"sync"
 	"time"
 
@@ -19,109 +18,167 @@ type MetricSample struct {
 	V float64 `json:"v"`
 }
 
-// metricCapacityDefault caps each ring buffer at 48h worth of @2s samples.
-// Node metrics arrive less frequently, so they fit the same retention window
-// with room to spare.
-const metricCapacityDefault = 86400
+// tierSpec defines one resolution layer of the rollup ladder: a fixed bucket
+// size in seconds and how many buckets to retain. window = resolution*capacity.
+type tierSpec struct {
+	resolution int
+	capacity   int
+}
 
-// metricHistory is a thread-safe, in-memory ring buffer keyed by
-// arbitrary strings. Two singletons live below: one for system-wide
-// host metrics, one for per-node metrics. Keeping them in this file
-// (rather than scattered across services) makes the storage model
-// easy to reason about and avoids double-locking.
-type metricHistory struct {
-	mu      sync.Mutex
-	metrics map[string][]MetricSample
+// metricTiers is the rollup ladder applied to every series. High resolution is
+// kept only for the recent past; older samples roll up into progressively
+// coarser, cheaper layers (RRDtool-style). Per series this totals ~5700 samples
+// (~90 KiB) yet spans a live 2s view through ~7 days of history.
+var metricTiers = []tierSpec{
+	{resolution: 2, capacity: 1800},   // 1h at 2s
+	{resolution: 60, capacity: 2880},  // 48h at 1m
+	{resolution: 600, capacity: 1008}, // 7d at 10m
 }
 
-func newMetricHistory() *metricHistory {
-	return &metricHistory{metrics: map[string][]MetricSample{}}
+// tierBuf is one fixed-resolution ring of a series. Samples land in an open
+// bucket and are averaged into the ring only when the next bucket begins, so a
+// coarse tier carries one mean per bucket instead of every raw point.
+type tierBuf struct {
+	resolution int
+	capacity   int
+	samples    []MetricSample
+	open       bool
+	openStart  int64
+	openSum    float64
+	openCount  int
 }
 
-// append stores a single sample for the given metric, deduping when
-// two appends happen within the same wall-clock second (which can
-// happen if the cron tick is faster than the metric's natural rate).
-func (h *metricHistory) append(metric string, t time.Time, v float64) {
-	h.mu.Lock()
-	defer h.mu.Unlock()
-	buf := h.metrics[metric]
-	p := MetricSample{T: t.Unix(), V: v}
-	if n := len(buf); n > 0 && buf[n-1].T == p.T {
-		buf[n-1] = p
-	} else {
-		buf = append(buf, p)
-	}
-	if len(buf) > metricCapacityDefault {
-		buf = buf[len(buf)-metricCapacityDefault:]
+func (tb *tierBuf) add(unixSec int64, v float64) {
+	res := int64(tb.resolution)
+	b := (unixSec / res) * res
+	if tb.open && b != tb.openStart {
+		tb.flush()
 	}
-	h.metrics[metric] = buf
+	tb.open = true
+	tb.openStart = b
+	tb.openSum += v
+	tb.openCount++
 }
 
-// drop removes the entire history for one metric. Used when a node is
-// deleted so its old samples don't linger forever in the singleton.
-func (h *metricHistory) drop(metric string) {
-	h.mu.Lock()
-	delete(h.metrics, metric)
-	h.mu.Unlock()
+func (tb *tierBuf) flush() {
+	if tb.openCount == 0 {
+		tb.open = false
+		return
+	}
+	tb.samples = append(tb.samples, MetricSample{T: tb.openStart, V: tb.openSum / float64(tb.openCount)})
+	if len(tb.samples) > tb.capacity {
+		tb.samples = tb.samples[len(tb.samples)-tb.capacity:]
+	}
+	tb.open = false
+	tb.openStart = 0
+	tb.openSum = 0
+	tb.openCount = 0
 }
 
-// snapshot returns a deep copy of every series, safe to serialize without
-// holding the lock during disk I/O.
-func (h *metricHistory) snapshot() map[string][]MetricSample {
-	h.mu.Lock()
-	defer h.mu.Unlock()
-	out := make(map[string][]MetricSample, len(h.metrics))
-	for k, v := range h.metrics {
-		cp := make([]MetricSample, len(v))
-		copy(cp, v)
-		out[k] = cp
+// readSamples returns a copy of the closed buckets plus the still-open one, so
+// the most recent point is visible before its bucket boundary closes.
+func (tb *tierBuf) readSamples() []MetricSample {
+	out := make([]MetricSample, len(tb.samples), len(tb.samples)+1)
+	copy(out, tb.samples)
+	if tb.openCount > 0 {
+		out = append(out, MetricSample{T: tb.openStart, V: tb.openSum / float64(tb.openCount)})
 	}
 	return out
 }
 
-// restore replaces the in-memory series with a previously persisted set,
-// re-applying the per-series capacity cap so a tampered or oversized file
-// can't grow the working set unbounded.
-func (h *metricHistory) restore(data map[string][]MetricSample) {
+// series is the rollup ladder for one metric: a sample is fed to every tier.
+type series struct {
+	tiers []*tierBuf
+}
+
+func newSeries() *series {
+	s := &series{tiers: make([]*tierBuf, len(metricTiers))}
+	for i, spec := range metricTiers {
+		s.tiers[i] = &tierBuf{resolution: spec.resolution, capacity: spec.capacity}
+	}
+	return s
+}
+
+func (s *series) add(unixSec int64, v float64) {
+	for _, tb := range s.tiers {
+		tb.add(unixSec, v)
+	}
+}
+
+// pickTier returns the finest tier whose window covers spanSeconds, falling back
+// to the coarsest (longest-window) tier when nothing covers it.
+func (s *series) pickTier(spanSeconds int64) *tierBuf {
+	for _, tb := range s.tiers {
+		if int64(tb.resolution)*int64(tb.capacity) >= spanSeconds {
+			return tb
+		}
+	}
+	return s.tiers[len(s.tiers)-1]
+}
+
+// metricHistory is a thread-safe, in-memory store of tiered series keyed by
+// arbitrary strings. Three singletons live below: system-wide host metrics,
+// per-node metrics, and xray expvar metrics.
+type metricHistory struct {
+	mu     sync.Mutex
+	series map[string]*series
+}
+
+func newMetricHistory() *metricHistory {
+	return &metricHistory{series: map[string]*series{}}
+}
+
+// append stores a single sample for the given metric across all tiers.
+func (h *metricHistory) append(metric string, t time.Time, v float64) {
 	h.mu.Lock()
 	defer h.mu.Unlock()
-	for k, v := range data {
-		if len(v) > metricCapacityDefault {
-			v = v[len(v)-metricCapacityDefault:]
-		}
-		h.metrics[k] = v
+	s := h.series[metric]
+	if s == nil {
+		s = newSeries()
+		h.series[metric] = s
 	}
+	s.add(t.Unix(), v)
 }
 
-// aggregate returns up to maxPoints buckets of size bucketSeconds,
-// each bucket carrying the arithmetic mean of the underlying samples.
-// Bucket alignment is to absolute Unix-second boundaries so two
-// concurrent calls (e.g. two browser tabs) see identical x-axes.
+// drop removes the entire history for one metric. Used when a node is deleted so
+// its old samples don't linger forever in the singleton.
+func (h *metricHistory) drop(metric string) {
+	h.mu.Lock()
+	delete(h.series, metric)
+	h.mu.Unlock()
+}
+
+// aggregate returns up to maxPoints buckets of size bucketSeconds, each carrying
+// the arithmetic mean of the underlying samples from the finest tier that covers
+// the requested span. Bucket alignment is to absolute Unix-second boundaries so
+// two concurrent calls see identical x-axes.
 func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints int) []map[string]any {
+	empty := []map[string]any{}
 	if bucketSeconds <= 0 || maxPoints <= 0 {
-		return []map[string]any{}
+		return empty
 	}
-	cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
+	span := int64(bucketSeconds) * int64(maxPoints)
+	cutoff := time.Now().Unix() - span
 
 	h.mu.Lock()
-	hist := h.metrics[metric]
-	startIdx := 0
-	for i, h := range slices.Backward(hist) {
-		if h.T < cutoff {
-			startIdx = i + 1
-			break
-		}
-	}
-	if startIdx >= len(hist) {
+	s := h.series[metric]
+	if s == nil {
 		h.mu.Unlock()
-		return []map[string]any{}
+		return empty
 	}
-	tmp := make([]MetricSample, len(hist)-startIdx)
-	copy(tmp, hist[startIdx:])
+	raw := s.pickTier(span).readSamples()
 	h.mu.Unlock()
 
+	startIdx := len(raw)
+	for i := len(raw) - 1; i >= 0; i-- {
+		if raw[i].T < cutoff {
+			break
+		}
+		startIdx = i
+	}
+	tmp := raw[startIdx:]
 	if len(tmp) == 0 {
-		return []map[string]any{}
+		return empty
 	}
 
 	bSize := int64(bucketSeconds)
@@ -152,24 +209,79 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
 		out = out[len(out)-maxPoints:]
 	}
 	if out == nil {
-		return []map[string]any{}
+		return empty
+	}
+	return out
+}
+
+// persistedTier and persistedSeries are the on-disk shape of a series. Tiers are
+// matched back by resolution on restore, so changing the ladder degrades
+// gracefully (unmatched layers are dropped) instead of corrupting state.
+type persistedTier struct {
+	Resolution int
+	Samples    []MetricSample
+}
+
+type persistedSeries struct {
+	Tiers []persistedTier
+}
+
+// snapshot returns a deep copy of every series' closed buckets, safe to
+// serialize without holding the lock during disk I/O.
+func (h *metricHistory) snapshot() map[string]persistedSeries {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	out := make(map[string]persistedSeries, len(h.series))
+	for k, s := range h.series {
+		ps := persistedSeries{Tiers: make([]persistedTier, len(s.tiers))}
+		for i, tb := range s.tiers {
+			cp := make([]MetricSample, len(tb.samples))
+			copy(cp, tb.samples)
+			ps.Tiers[i] = persistedTier{Resolution: tb.resolution, Samples: cp}
+		}
+		out[k] = ps
 	}
 	return out
 }
 
-// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
-// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
-// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
-// process-local — survival across panel restart is not required.
+// restore replaces the in-memory series with a previously persisted set,
+// re-applying each tier's capacity cap so a tampered or oversized file can't grow
+// the working set unbounded.
+func (h *metricHistory) restore(data map[string]persistedSeries) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	for k, ps := range data {
+		s := newSeries()
+		for _, pt := range ps.Tiers {
+			for _, tb := range s.tiers {
+				if tb.resolution != pt.Resolution {
+					continue
+				}
+				samples := pt.Samples
+				if len(samples) > tb.capacity {
+					samples = samples[len(samples)-tb.capacity:]
+				}
+				tb.samples = samples
+				break
+			}
+		}
+		h.series[k] = s
+	}
+}
+
+// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.) fed by
+// ServerService.RefreshStatus every 2s. nodeMetrics holds per-node CPU/Mem fed
+// by NodeHeartbeatJob. xrayMetrics holds xray expvar series. Only systemMetrics
+// is persisted; the others rebuild from live connections.
 var (
 	systemMetrics = newMetricHistory()
 	nodeMetrics   = newMetricHistory()
 	xrayMetrics   = newMetricHistory()
 )
 
-// SystemMetricKeys lists the metric names ServerService writes on every
-// status sample. Exposed for documentation/test purposes; the
-// controller validates incoming names against an allow-list.
+// SystemMetricKeys lists the metric names ServerService writes on every status
+// sample. Exposed for documentation/test purposes; the controller validates
+// incoming names against an allow-list.
 var SystemMetricKeys = []string{
 	"cpu", "mem", "swap", "netUp", "netDown", "pktUp", "pktDown", "diskRead", "diskWrite", "diskUsage", "tcpCount", "udpCount", "online", "load1", "load5", "load15",
 }
@@ -177,18 +289,13 @@ var SystemMetricKeys = []string{
 // NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
 var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
 
-// XrayMetricKeys lists series sourced from xray's /debug/vars expvar
-// endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence
-// as the system metrics, but only when the xray config has a `metrics`
-// block configured.
+// XrayMetricKeys lists series sourced from xray's /debug/vars expvar endpoint.
 var XrayMetricKeys = []string{
 	"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
 }
 
 // systemMetricsStorePath is where the host time-series is persisted between
-// restarts. It lives next to the database so a single volume mount carries
-// both. Only systemMetrics is persisted — node and xray series are cheap to
-// rebuild and tied to live connections.
+// restarts. It lives next to the database so a single volume mount carries both.
 func systemMetricsStorePath() string {
 	return filepath.Join(config.GetDBFolderPath(), "system_metrics.gob")
 }
@@ -216,8 +323,8 @@ func PersistSystemMetrics() error {
 }
 
 // RestoreSystemMetrics loads a previously persisted host time-series on startup.
-// A missing file is not an error (first boot). Aggregation already windows by
-// time, so any gap from downtime is handled by the readers.
+// A missing file is not an error (first boot). A pre-tier flat snapshot is
+// migrated by replaying its samples through the rollup.
 func RestoreSystemMetrics() {
 	path := systemMetricsStorePath()
 	f, err := os.Open(path)
@@ -227,11 +334,36 @@ func RestoreSystemMetrics() {
 		}
 		return
 	}
-	defer f.Close()
-	var data map[string][]MetricSample
-	if err := gob.NewDecoder(f).Decode(&data); err != nil {
-		logger.Warning("decode system metrics failed:", err)
+	var data map[string]persistedSeries
+	decErr := gob.NewDecoder(f).Decode(&data)
+	f.Close()
+	if decErr == nil {
+		systemMetrics.restore(data)
+		return
+	}
+	if migrateLegacySystemMetrics(path) {
 		return
 	}
-	systemMetrics.restore(data)
+	logger.Warning("decode system metrics failed:", decErr)
+}
+
+// migrateLegacySystemMetrics loads a pre-tier flat snapshot
+// (map[string][]MetricSample) and replays it through append so the new tiers are
+// seeded from the existing history instead of starting empty.
+func migrateLegacySystemMetrics(path string) bool {
+	f, err := os.Open(path)
+	if err != nil {
+		return false
+	}
+	defer f.Close()
+	var legacy map[string][]MetricSample
+	if err := gob.NewDecoder(f).Decode(&legacy); err != nil {
+		return false
+	}
+	for metric, samples := range legacy {
+		for _, p := range samples {
+			systemMetrics.append(metric, time.Unix(p.T, 0), p.V)
+		}
+	}
+	return true
 }

+ 167 - 0
internal/web/service/metric_history_test.go

@@ -0,0 +1,167 @@
+package service
+
+import (
+	"fmt"
+	"runtime"
+	"testing"
+	"time"
+)
+
+func TestMetricMemoryFootprint(t *testing.T) {
+	const metrics = 16
+
+	retained := func(build func() any) uint64 {
+		runtime.GC()
+		runtime.GC()
+		var m0 runtime.MemStats
+		runtime.ReadMemStats(&m0)
+		obj := build()
+		runtime.GC()
+		var m1 runtime.MemStats
+		runtime.ReadMemStats(&m1)
+		runtime.KeepAlive(obj)
+		if m1.HeapAlloc < m0.HeapAlloc {
+			return 0
+		}
+		return m1.HeapAlloc - m0.HeapAlloc
+	}
+
+	fill := func(buf []MetricSample) {
+		for j := range buf {
+			buf[j] = MetricSample{T: int64(j), V: float64(j)}
+		}
+	}
+
+	oldFlat := retained(func() any {
+		m := make(map[string][]MetricSample, metrics)
+		for i := range metrics {
+			buf := make([]MetricSample, 86400)
+			fill(buf)
+			m[fmt.Sprintf("m%d", i)] = buf
+		}
+		return m
+	})
+
+	newTiered := retained(func() any {
+		h := newMetricHistory()
+		for i := range metrics {
+			s := newSeries()
+			for _, tb := range s.tiers {
+				buf := make([]MetricSample, tb.capacity)
+				fill(buf)
+				tb.samples = buf
+			}
+			h.series[fmt.Sprintf("m%d", i)] = s
+		}
+		return h
+	})
+
+	t.Logf("metric history footprint (16 system metrics, full):")
+	t.Logf("  before (flat 48h@2s): %d KiB", oldFlat/1024)
+	t.Logf("  after  (tiered 7d):   %d KiB", newTiered/1024)
+	if newTiered >= oldFlat {
+		t.Fatalf("expected tiered footprint smaller: old=%d new=%d", oldFlat, newTiered)
+	}
+}
+
+func TestTierBufRollupAveragesClosedBuckets(t *testing.T) {
+	tb := &tierBuf{resolution: 10, capacity: 100}
+	tb.add(0, 2)
+	tb.add(2, 4)
+	tb.add(5, 6)
+	tb.add(10, 10)
+
+	if len(tb.samples) != 1 || tb.samples[0].T != 0 || tb.samples[0].V != 4 {
+		t.Fatalf("expected one closed bucket {0,4}, got %+v", tb.samples)
+	}
+
+	got := tb.readSamples()
+	if len(got) != 2 || got[1].T != 10 || got[1].V != 10 {
+		t.Fatalf("expected open bucket {10,10} appended on read, got %+v", got)
+	}
+}
+
+func TestTierBufRespectsCapacity(t *testing.T) {
+	tb := &tierBuf{resolution: 1, capacity: 5}
+	for i := range int64(20) {
+		tb.add(i, float64(i))
+	}
+	if len(tb.samples) != 5 {
+		t.Fatalf("expected closed buckets capped at 5, got %d", len(tb.samples))
+	}
+	if last := tb.samples[len(tb.samples)-1]; last.T != 18 {
+		t.Fatalf("expected last closed bucket T=18 (19 still open), got %d", last.T)
+	}
+}
+
+func TestSeriesPickTierBySpan(t *testing.T) {
+	s := newSeries()
+	cases := []struct {
+		span int64
+		res  int
+	}{
+		{120, 2},
+		{3600, 2},
+		{7200, 60},
+		{172800, 60},
+		{604800, 600},
+		{9999999, 600},
+	}
+	for _, c := range cases {
+		if got := s.pickTier(c.span); got.resolution != c.res {
+			t.Errorf("span %d: expected resolution %d, got %d", c.span, c.res, got.resolution)
+		}
+	}
+}
+
+func TestAggregateFineRealtime(t *testing.T) {
+	h := newMetricHistory()
+	now := time.Now().Unix()
+	for i := int64(59); i >= 0; i-- {
+		h.append("cpu", time.Unix(now-i*2, 0), float64(100-i))
+	}
+
+	out := h.aggregate("cpu", 2, 60)
+	if len(out) == 0 {
+		t.Fatalf("expected non-empty realtime aggregate")
+	}
+	if _, ok := out[len(out)-1]["v"].(float64); !ok {
+		t.Fatalf("expected float64 value, got %T", out[len(out)-1]["v"])
+	}
+}
+
+func TestAggregateLongSpanUsesCoarseTier(t *testing.T) {
+	h := newMetricHistory()
+	now := time.Now().Unix()
+	for i := range int64(200) {
+		ts := now - (200-i)*600
+		h.append("cpu", time.Unix(ts, 0), float64(i))
+	}
+
+	out := h.aggregate("cpu", 10080, 60)
+	if len(out) == 0 {
+		t.Fatalf("expected non-empty 7d aggregate from the archive tier")
+	}
+}
+
+func TestSnapshotRestoreRoundTrip(t *testing.T) {
+	h := newMetricHistory()
+	now := time.Now().Unix()
+	for i := range int64(10) {
+		h.append("cpu", time.Unix(now-(9-i)*2, 0), float64(i))
+	}
+
+	h2 := newMetricHistory()
+	h2.restore(h.snapshot())
+
+	if out := h2.aggregate("cpu", 2, 60); len(out) == 0 {
+		t.Fatalf("expected restored series to aggregate")
+	}
+}
+
+func TestAggregateMissingMetricIsEmpty(t *testing.T) {
+	h := newMetricHistory()
+	if out := h.aggregate("nope", 2, 60); len(out) != 0 {
+		t.Fatalf("expected empty result for unknown metric, got %d points", len(out))
+	}
+}

+ 26 - 0
internal/web/service/node_client_traffic_sum_test.go

@@ -120,6 +120,32 @@ func TestSingleNode_MirrorsCorrectly(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 200, 200, "second sync — delta accrues")
 }
 
+func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	const email = "newnode-client"
+	const histUp, histDown int64 = 6_000_000_000, 200_000_000_000
+
+	syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp, Down: histDown, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), histUp, histDown, "node-add: client history imported with its brand-new inbound")
+
+	syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp + 1024, Down: histDown + 2048, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count")
+}
+
+func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	const email = "deleted-ghost"
+	const stale int64 = 50_000_000_000
+
+	tombstoneClientEmail(email)
+	syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: stale, Down: stale, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "tombstoned client must not resurrect via node-add seed")
+}
+
 func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
 	db := initTrafficTestDB(t)
 	createNodeInbound(t, db, 1, "n1-in", 41001)

+ 16 - 12
internal/web/service/server.go

@@ -166,15 +166,15 @@ const xrayVersionsCacheTTL = 15 * time.Minute
 // callers from triggering arbitrary aggregation work and keeps the
 // frontend's bucket selector self-documenting.
 var allowedHistoryBuckets = map[int]bool{
-	2:    true, // Real-time view
-	30:   true, // 30s intervals
-	60:   true, // 1m intervals
-	120:  true, // 2m intervals
-	180:  true, // 3m intervals
-	300:  true, // 5m intervals
-	720:  true, // 12m intervals
-	1440: true, // 24m intervals
-	2880: true, // 48m intervals
+	2:     true, // 2m
+	30:    true, // 30m
+	60:    true, // 1h
+	180:   true, // 3h
+	360:   true, // 6h
+	720:   true, // 12h
+	1440:  true, // 24h
+	2880:  true, // 2d
+	10080: true, // 7d
 }
 
 // IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
@@ -610,9 +610,13 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	}
 
 	// Application stats
-	var rtm runtime.MemStats
-	runtime.ReadMemStats(&rtm)
-	status.AppStats.Mem = rtm.Sys
+	if rss := sys.SelfRSS(); rss > 0 {
+		status.AppStats.Mem = rss
+	} else {
+		var rtm runtime.MemStats
+		runtime.ReadMemStats(&rtm)
+		status.AppStats.Mem = rtm.Sys
+	}
 	status.AppStats.Threads = uint32(runtime.NumGoroutine())
 	if p != nil && p.IsRunning() {
 		status.AppStats.Uptime = p.GetUptime()

+ 12 - 0
internal/web/translation/ar-EG.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "لا توجد ملفات مهيأة. في قواعد التوجيه يُشار إلى الملفات بالشكل ext:geosite_custom.dat:category.",
       "dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
       "logs": "السجلات",
+      "accessLogs": "سجلات الوصول",
+      "autoUpdate": "تحديث تلقائي",
       "config": "الإعدادات",
       "backup": "نسخ احتياطي",
       "backupTitle": "نسخ احتياطي واستعادة",
@@ -816,6 +818,12 @@
       "attach": "إرفاق",
       "adjust": "ضبط",
       "subLinks": "روابط الاشتراك",
+      "enable": "تفعيل",
+      "disable": "تعطيل",
+      "bulkEnableConfirmTitle": "تفعيل {count} عميل؟",
+      "bulkEnableConfirmContent": "يُفعّل كل عميل محدد على جميع الإدخالات المرفقة. العملاء الذين استُنفدت حصتهم أو انتهت صلاحيتهم سيُعطَّلون تلقائيًا مرة أخرى.",
+      "bulkDisableConfirmTitle": "تعطيل {count} عميل؟",
+      "bulkDisableConfirmContent": "يُعطّل كل عميل محدد على جميع الإدخالات المرفقة. يفقدون الوصول فورًا لكن تُحفَظ سجلاتهم وحركة بياناتهم.",
       "selectedCount": "{count} محدد",
       "attachSelected": "إرفاق ({count})",
       "attachToInboundsTitle": "إرفاق {count} عميل بالواردات",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "تمت إعادة ضبط حركة مرور كل العملاء",
         "bulkDeleted": "تم حذف {count} عميل",
         "bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
+        "bulkEnabled": "تم تفعيل {count} عميل",
+        "bulkEnabledMixed": "تم تفعيل {ok}, وفشل {failed}",
+        "bulkDisabled": "تم تعطيل {count} عميل",
+        "bulkDisabledMixed": "تم تعطيل {ok}, وفشل {failed}",
         "bulkCreated": "تم إنشاء {count} عميل",
         "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
         "bulkAdjusted": "تم تعديل {count} عميل",

+ 12 - 0
internal/web/translation/en-US.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "No files configured. Reference files in routing rules as ext:geosite_custom.dat:category.",
       "dontRefresh": "Installation is in progress, please do not refresh this page",
       "logs": "Logs",
+      "accessLogs": "Access Logs",
+      "autoUpdate": "Auto Update",
       "config": "Config",
       "backup": "Backup",
       "backupTitle": "Backup & Restore",
@@ -816,6 +818,12 @@
       "attach": "Attach",
       "adjust": "Adjust",
       "subLinks": "Sub links",
+      "enable": "Enable",
+      "disable": "Disable",
+      "bulkEnableConfirmTitle": "Enable {count} clients?",
+      "bulkEnableConfirmContent": "Enables each selected client on every attached inbound. Clients whose quota is exhausted or whose expiry has passed will be disabled again automatically.",
+      "bulkDisableConfirmTitle": "Disable {count} clients?",
+      "bulkDisableConfirmContent": "Disables each selected client on every attached inbound. They lose access immediately but their records and traffic are kept.",
       "selectedCount": "{count} selected",
       "attachSelected": "Attach ({count})",
       "attachToInboundsTitle": "Attach {count} client(s) to inbound(s)",
@@ -875,6 +883,10 @@
         "allTrafficsReset": "All client traffic reset",
         "bulkDeleted": "{count} clients deleted",
         "bulkDeletedMixed": "{ok} deleted, {failed} failed",
+        "bulkEnabled": "{count} clients enabled",
+        "bulkEnabledMixed": "{ok} enabled, {failed} failed",
+        "bulkDisabled": "{count} clients disabled",
+        "bulkDisabledMixed": "{ok} disabled, {failed} failed",
         "bulkCreated": "{count} clients created",
         "bulkCreatedMixed": "{ok} created, {failed} failed",
         "bulkAdjusted": "{count} clients adjusted",

+ 12 - 0
internal/web/translation/es-ES.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "No hay archivos configurados. En las reglas de enrutamiento se referencian como ext:geosite_custom.dat:category.",
       "dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
       "logs": "Registros",
+      "accessLogs": "Registros de acceso",
+      "autoUpdate": "Actualización automática",
       "config": "Configuración",
       "backup": "Copia de seguridad",
       "backupTitle": "Copia & Restauración",
@@ -816,6 +818,12 @@
       "attach": "Asociar",
       "adjust": "Ajustar",
       "subLinks": "Enlaces sub",
+      "enable": "Habilitar",
+      "disable": "Deshabilitar",
+      "bulkEnableConfirmTitle": "¿Habilitar {count} clientes?",
+      "bulkEnableConfirmContent": "Habilita cada cliente seleccionado en todos los inbounds asociados. Los clientes cuya cuota se haya agotado o cuya caducidad haya pasado se deshabilitarán de nuevo automáticamente.",
+      "bulkDisableConfirmTitle": "¿Deshabilitar {count} clientes?",
+      "bulkDisableConfirmContent": "Deshabilita cada cliente seleccionado en todos los inbounds asociados. Pierden el acceso de inmediato, pero se conservan sus registros y su tráfico.",
       "selectedCount": "{count} seleccionado(s)",
       "attachSelected": "Asociar ({count})",
       "attachToInboundsTitle": "Asociar {count} cliente(s) a entrada(s)",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Tráfico de todos los clientes restablecido",
         "bulkDeleted": "{count} clientes eliminados",
         "bulkDeletedMixed": "{ok} eliminados, {failed} fallidos",
+        "bulkEnabled": "{count} clientes habilitados",
+        "bulkEnabledMixed": "{ok} habilitados, {failed} fallidos",
+        "bulkDisabled": "{count} clientes deshabilitados",
+        "bulkDisabledMixed": "{ok} deshabilitados, {failed} fallidos",
         "bulkCreated": "{count} clientes creados",
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
         "bulkAdjusted": "{count} clientes ajustados",

+ 12 - 0
internal/web/translation/fa-IR.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "فایلی پیکربندی نشده است. در قوانین مسیریابی فایل‌ها به صورت ext:geosite_custom.dat:category استفاده می‌شوند.",
       "dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
       "logs": "لاگ‌ها",
+      "accessLogs": "لاگ‌های دسترسی",
+      "autoUpdate": "به‌روزرسانی خودکار",
       "config": "پیکربندی",
       "backup": "پشتیبان‌گیری",
       "backupTitle": "پشتیبان‌گیری و بازیابی",
@@ -816,6 +818,12 @@
       "attach": "الصاق",
       "adjust": "تنظیم",
       "subLinks": "لینک‌های اشتراک",
+      "enable": "فعال‌سازی",
+      "disable": "غیرفعال‌سازی",
+      "bulkEnableConfirmTitle": "{count} کلاینت فعال شوند؟",
+      "bulkEnableConfirmContent": "هر کلاینت انتخاب‌شده روی تمام اینباندهای متصل فعال می‌شود. کلاینت‌هایی که سهمیه آن‌ها تمام شده یا تاریخ انقضایشان گذشته، به‌طور خودکار دوباره غیرفعال می‌شوند.",
+      "bulkDisableConfirmTitle": "{count} کلاینت غیرفعال شوند؟",
+      "bulkDisableConfirmContent": "هر کلاینت انتخاب‌شده روی تمام اینباندهای متصل غیرفعال می‌شود. دسترسی آن‌ها بلافاصله قطع می‌شود اما رکورد و ترافیکشان حفظ می‌گردد.",
       "selectedCount": "{count} انتخاب‌شده",
       "attachSelected": "الصاق ({count})",
       "attachToInboundsTitle": "الصاق {count} کاربر به ورودی‌(ها)",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "ترافیک همه کلاینت‌ها بازنشانی شد",
         "bulkDeleted": "{count} کلاینت حذف شد",
         "bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
+        "bulkEnabled": "{count} کلاینت فعال شد",
+        "bulkEnabledMixed": "{ok} فعال، {failed} ناموفق",
+        "bulkDisabled": "{count} کلاینت غیرفعال شد",
+        "bulkDisabledMixed": "{ok} غیرفعال، {failed} ناموفق",
         "bulkCreated": "{count} کلاینت ساخته شد",
         "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
         "bulkAdjusted": "{count} کلاینت تنظیم شد",

+ 12 - 0
internal/web/translation/id-ID.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "Belum ada berkas yang dikonfigurasi. Pada aturan routing, rujuk berkas sebagai ext:geosite_custom.dat:category.",
       "dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
       "logs": "Log",
+      "accessLogs": "Log Akses",
+      "autoUpdate": "Pembaruan Otomatis",
       "config": "Konfigurasi",
       "backup": "Cadangan",
       "backupTitle": "Cadangan & Pulihkan",
@@ -816,6 +818,12 @@
       "attach": "Lampirkan",
       "adjust": "Atur",
       "subLinks": "Tautan sub",
+      "enable": "Aktifkan",
+      "disable": "Nonaktifkan",
+      "bulkEnableConfirmTitle": "Aktifkan {count} klien?",
+      "bulkEnableConfirmContent": "Mengaktifkan setiap klien yang dipilih di semua inbound yang terlampir. Klien yang kuotanya habis atau masa berlakunya telah lewat akan dinonaktifkan kembali secara otomatis.",
+      "bulkDisableConfirmTitle": "Nonaktifkan {count} klien?",
+      "bulkDisableConfirmContent": "Menonaktifkan setiap klien yang dipilih di semua inbound yang terlampir. Mereka langsung kehilangan akses, tetapi catatan dan trafiknya tetap disimpan.",
       "selectedCount": "{count} dipilih",
       "attachSelected": "Lampirkan ({count})",
       "attachToInboundsTitle": "Lampirkan {count} klien ke inbound",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Lalu lintas semua klien direset",
         "bulkDeleted": "{count} klien dihapus",
         "bulkDeletedMixed": "{ok} dihapus, {failed} gagal",
+        "bulkEnabled": "{count} klien diaktifkan",
+        "bulkEnabledMixed": "{ok} diaktifkan, {failed} gagal",
+        "bulkDisabled": "{count} klien dinonaktifkan",
+        "bulkDisabledMixed": "{ok} dinonaktifkan, {failed} gagal",
         "bulkCreated": "{count} klien dibuat",
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
         "bulkAdjusted": "{count} klien disesuaikan",

+ 12 - 0
internal/web/translation/ja-JP.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "ファイルが設定されていません。ルーティングルールでは ext:geosite_custom.dat:category の形式で参照します。",
       "dontRefresh": "インストール中、このページをリロードしないでください",
       "logs": "ログ",
+      "accessLogs": "アクセスログ",
+      "autoUpdate": "自動更新",
       "config": "設定",
       "backup": "バックアップ",
       "backupTitle": "バックアップと復元",
@@ -816,6 +818,12 @@
       "attach": "アタッチ",
       "adjust": "調整",
       "subLinks": "サブリンク",
+      "enable": "有効化",
+      "disable": "無効化",
+      "bulkEnableConfirmTitle": "{count} 件のクライアントを有効化しますか?",
+      "bulkEnableConfirmContent": "選択した各クライアントを、接続されているすべてのインバウンドで有効化します。クォータを使い切ったクライアントや有効期限が過ぎたクライアントは、自動的に再度無効化されます。",
+      "bulkDisableConfirmTitle": "{count} 件のクライアントを無効化しますか?",
+      "bulkDisableConfirmContent": "選択した各クライアントを、接続されているすべてのインバウンドで無効化します。アクセスはすぐに失われますが、記録とトラフィックは保持されます。",
       "selectedCount": "{count} 選択中",
       "attachSelected": "アタッチ ({count})",
       "attachToInboundsTitle": "{count} クライアントをインバウンドにアタッチ",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "すべてのクライアントのトラフィックをリセットしました",
         "bulkDeleted": "{count} 件のクライアントを削除しました",
         "bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
+        "bulkEnabled": "{count} 件のクライアントを有効化しました",
+        "bulkEnabledMixed": "{ok} 件有効化、{failed} 件失敗",
+        "bulkDisabled": "{count} 件のクライアントを無効化しました",
+        "bulkDisabledMixed": "{ok} 件無効化、{failed} 件失敗",
         "bulkCreated": "{count} 件のクライアントを作成しました",
         "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
         "bulkAdjusted": "{count} 件のクライアントを調整しました",

+ 12 - 0
internal/web/translation/pt-BR.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "Nenhum arquivo configurado. Nas regras de roteamento, referencie como ext:geosite_custom.dat:category.",
       "dontRefresh": "Instalação em andamento, por favor não atualize a página",
       "logs": "Logs",
+      "accessLogs": "Logs de acesso",
+      "autoUpdate": "Atualização automática",
       "config": "Configuração",
       "backup": "Backup",
       "backupTitle": "Backup & Restauração",
@@ -816,6 +818,12 @@
       "attach": "Associar",
       "adjust": "Ajustar",
       "subLinks": "Links de assinatura",
+      "enable": "Ativar",
+      "disable": "Desativar",
+      "bulkEnableConfirmTitle": "Ativar {count} clientes?",
+      "bulkEnableConfirmContent": "Ativa cada cliente selecionado em todos os inbounds associados. Clientes cuja cota se esgotou ou cuja validade expirou serão desativados novamente de forma automática.",
+      "bulkDisableConfirmTitle": "Desativar {count} clientes?",
+      "bulkDisableConfirmContent": "Desativa cada cliente selecionado em todos os inbounds associados. Eles perdem o acesso imediatamente, mas seus registros e tráfego são mantidos.",
       "selectedCount": "{count} selecionado(s)",
       "attachSelected": "Associar ({count})",
       "attachToInboundsTitle": "Associar {count} cliente(s) a entrada(s)",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Tráfego de todos os clientes redefinido",
         "bulkDeleted": "{count} clientes excluídos",
         "bulkDeletedMixed": "{ok} excluídos, {failed} com falha",
+        "bulkEnabled": "{count} clientes ativados",
+        "bulkEnabledMixed": "{ok} ativados, {failed} com falha",
+        "bulkDisabled": "{count} clientes desativados",
+        "bulkDisabledMixed": "{ok} desativados, {failed} com falha",
         "bulkCreated": "{count} clientes criados",
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
         "bulkAdjusted": "{count} clientes ajustados",

+ 12 - 0
internal/web/translation/ru-RU.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "Файлы не настроены. В правилах маршрутизации файлы указываются как ext:geosite_custom.dat:category.",
       "dontRefresh": "Установка в процессе. Не обновляйте страницу",
       "logs": "Логи",
+      "accessLogs": "Логи доступа",
+      "autoUpdate": "Автообновление",
       "config": "Конфигурация",
       "backup": "Резервная копия",
       "backupTitle": "Бэкап и восстановление",
@@ -816,6 +818,12 @@
       "attach": "Привязать",
       "adjust": "Корректировка",
       "subLinks": "Sub-ссылки",
+      "enable": "Включить",
+      "disable": "Отключить",
+      "bulkEnableConfirmTitle": "Включить {count} клиентов?",
+      "bulkEnableConfirmContent": "Включает каждого выбранного клиента на всех привязанных подключениях. Клиенты с исчерпанной квотой или истёкшим сроком будут автоматически отключены снова.",
+      "bulkDisableConfirmTitle": "Отключить {count} клиентов?",
+      "bulkDisableConfirmContent": "Отключает каждого выбранного клиента на всех привязанных подключениях. Они сразу теряют доступ, но их записи и трафик сохраняются.",
       "selectedCount": "{count} выбрано",
       "attachSelected": "Привязать ({count})",
       "attachToInboundsTitle": "Привязать {count} клиент(ов) к входящим",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Трафик всех клиентов сброшен",
         "bulkDeleted": "Удалено клиентов: {count}",
         "bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
+        "bulkEnabled": "Включено клиентов: {count}",
+        "bulkEnabledMixed": "Включено: {ok}, не удалось: {failed}",
+        "bulkDisabled": "Отключено клиентов: {count}",
+        "bulkDisabledMixed": "Отключено: {ok}, не удалось: {failed}",
         "bulkCreated": "Создано клиентов: {count}",
         "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
         "bulkAdjusted": "Изменено клиентов: {count}",

+ 12 - 0
internal/web/translation/tr-TR.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "Yapılandırılmış dosya yok. Yönlendirme kurallarında dosyalar ext:geosite_custom.dat:category olarak kullanılır.",
       "dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
       "logs": "Günlükler",
+      "accessLogs": "Erişim Günlükleri",
+      "autoUpdate": "Otomatik Güncelleme",
       "config": "Yapılandırma",
       "backup": "Yedek",
       "backupTitle": "Yedekleme ve Geri Yükleme",
@@ -816,6 +818,12 @@
       "attach": "Bağla",
       "adjust": "Ayarla",
       "subLinks": "Abonelik Bağlantıları",
+      "enable": "Etkinleştir",
+      "disable": "Devre Dışı Bırak",
+      "bulkEnableConfirmTitle": "{count} kullanıcı etkinleştirilsin mi?",
+      "bulkEnableConfirmContent": "Seçili her kullanıcıyı bağlı olduğu tüm gelen bağlantılarda etkinleştirir. Kotası dolmuş veya süresi geçmiş kullanıcılar otomatik olarak yeniden devre dışı bırakılır.",
+      "bulkDisableConfirmTitle": "{count} kullanıcı devre dışı bırakılsın mı?",
+      "bulkDisableConfirmContent": "Seçili her kullanıcıyı bağlı olduğu tüm gelen bağlantılarda devre dışı bırakır. Erişimlerini hemen kaybederler ancak kayıtları ve trafikleri korunur.",
       "selectedCount": "{count} Seçildi",
       "attachSelected": "Bağla ({count})",
       "attachToInboundsTitle": "{count} Kullanıcıyı Gelen Bağlantına Bağla",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Tüm kullanıcıların trafiği sıfırlandı",
         "bulkDeleted": "{count} kullanıcı silindi",
         "bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
+        "bulkEnabled": "{count} kullanıcı etkinleştirildi",
+        "bulkEnabledMixed": "{ok} etkinleştirildi, {failed} başarısız",
+        "bulkDisabled": "{count} kullanıcı devre dışı bırakıldı",
+        "bulkDisabledMixed": "{ok} devre dışı bırakıldı, {failed} başarısız",
         "bulkCreated": "{count} kullanıcı oluşturuldu",
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
         "bulkAdjusted": "{count} kullanıcı ayarlandı",

+ 12 - 0
internal/web/translation/uk-UA.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "Файли не налаштовано. У правилах маршрутизації файли вказуються як ext:geosite_custom.dat:category.",
       "dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
       "logs": "Логи",
+      "accessLogs": "Логи доступу",
+      "autoUpdate": "Автооновлення",
       "config": "Конфігурація",
       "backup": "Резервна копія",
       "backupTitle": "Резервне копіювання та відновлення",
@@ -816,6 +818,12 @@
       "attach": "Прив'язати",
       "adjust": "Коригування",
       "subLinks": "Sub-посилання",
+      "enable": "Увімкнути",
+      "disable": "Вимкнути",
+      "bulkEnableConfirmTitle": "Увімкнути {count} клієнтів?",
+      "bulkEnableConfirmContent": "Вмикає кожного вибраного клієнта на всіх прив'язаних підключеннях. Клієнти з вичерпаною квотою або простроченим терміном будуть автоматично вимкнені знову.",
+      "bulkDisableConfirmTitle": "Вимкнути {count} клієнтів?",
+      "bulkDisableConfirmContent": "Вимикає кожного вибраного клієнта на всіх прив'язаних підключеннях. Вони одразу втрачають доступ, але їхні записи та трафік зберігаються.",
       "selectedCount": "Обрано {count}",
       "attachSelected": "Прив'язати ({count})",
       "attachToInboundsTitle": "Прив'язати {count} клієнт(ів) до вхідних",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Трафік усіх клієнтів скинуто",
         "bulkDeleted": "Видалено клієнтів: {count}",
         "bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
+        "bulkEnabled": "Увімкнено клієнтів: {count}",
+        "bulkEnabledMixed": "Увімкнено: {ok}, не вдалось: {failed}",
+        "bulkDisabled": "Вимкнено клієнтів: {count}",
+        "bulkDisabledMixed": "Вимкнено: {ok}, не вдалось: {failed}",
         "bulkCreated": "Створено клієнтів: {count}",
         "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
         "bulkAdjusted": "Змінено клієнтів: {count}",

+ 12 - 0
internal/web/translation/vi-VN.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "Chưa cấu hình tệp nào. Trong quy tắc định tuyến, tham chiếu tệp dạng ext:geosite_custom.dat:category.",
       "dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
       "logs": "Nhật ký",
+      "accessLogs": "Nhật ký truy cập",
+      "autoUpdate": "Tự động cập nhật",
       "config": "Cấu hình",
       "backup": "Sao lưu",
       "backupTitle": "Sao lưu & Khôi phục",
@@ -816,6 +818,12 @@
       "attach": "Gắn",
       "adjust": "Điều chỉnh",
       "subLinks": "Liên kết sub",
+      "enable": "Bật",
+      "disable": "Tắt",
+      "bulkEnableConfirmTitle": "Bật {count} khách hàng?",
+      "bulkEnableConfirmContent": "Bật từng khách hàng đã chọn trên mọi inbound được gắn. Những khách hàng đã dùng hết hạn mức hoặc đã hết hạn sẽ tự động bị tắt lại.",
+      "bulkDisableConfirmTitle": "Tắt {count} khách hàng?",
+      "bulkDisableConfirmContent": "Tắt từng khách hàng đã chọn trên mọi inbound được gắn. Họ mất quyền truy cập ngay lập tức nhưng hồ sơ và lưu lượng của họ vẫn được giữ lại.",
       "selectedCount": "Đã chọn {count}",
       "attachSelected": "Gắn ({count})",
       "attachToInboundsTitle": "Gắn {count} client vào inbound",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "Đã đặt lại lưu lượng của tất cả khách hàng",
         "bulkDeleted": "Đã xóa {count} khách hàng",
         "bulkDeletedMixed": "Đã xóa {ok}, thất bại {failed}",
+        "bulkEnabled": "Đã bật {count} khách hàng",
+        "bulkEnabledMixed": "Đã bật {ok}, thất bại {failed}",
+        "bulkDisabled": "Đã tắt {count} khách hàng",
+        "bulkDisabledMixed": "Đã tắt {ok}, thất bại {failed}",
         "bulkCreated": "Đã tạo {count} khách hàng",
         "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
         "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",

+ 12 - 0
internal/web/translation/zh-CN.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "尚未配置文件。路由规则中可通过 ext:geosite_custom.dat:category 引用文件。",
       "dontRefresh": "安装中,请勿刷新此页面",
       "logs": "日志",
+      "accessLogs": "访问日志",
+      "autoUpdate": "自动更新",
       "config": "配置",
       "backup": "备份",
       "backupTitle": "备份和恢复",
@@ -816,6 +818,12 @@
       "attach": "附加",
       "adjust": "调整",
       "subLinks": "订阅链接",
+      "enable": "启用",
+      "disable": "禁用",
+      "bulkEnableConfirmTitle": "启用 {count} 个客户端?",
+      "bulkEnableConfirmContent": "在每个已附加的入站上启用所选的客户端。配额已用尽或已过期的客户端将被自动重新禁用。",
+      "bulkDisableConfirmTitle": "禁用 {count} 个客户端?",
+      "bulkDisableConfirmContent": "在每个已附加的入站上禁用所选的客户端。他们将立即失去访问权限,但其记录和流量将被保留。",
       "selectedCount": "已选 {count} 项",
       "attachSelected": "附加 ({count})",
       "attachToInboundsTitle": "将 {count} 个客户端附加到入站",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "所有客户端流量已重置",
         "bulkDeleted": "已删除 {count} 个客户端",
         "bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
+        "bulkEnabled": "已启用 {count} 个客户端",
+        "bulkEnabledMixed": "已启用 {ok} 个,失败 {failed} 个",
+        "bulkDisabled": "已禁用 {count} 个客户端",
+        "bulkDisabledMixed": "已禁用 {ok} 个,失败 {failed} 个",
         "bulkCreated": "已创建 {count} 个客户端",
         "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
         "bulkAdjusted": "已调整 {count} 个客户端",

+ 12 - 0
internal/web/translation/zh-TW.json

@@ -245,6 +245,8 @@
       "geodataEmpty": "尚未設定檔案。路由規則中可透過 ext:geosite_custom.dat:category 引用檔案。",
       "dontRefresh": "安裝中,請勿重新整理此頁面",
       "logs": "記錄",
+      "accessLogs": "存取記錄",
+      "autoUpdate": "自動更新",
       "config": "配置",
       "backup": "備份",
       "backupTitle": "備份和恢復",
@@ -816,6 +818,12 @@
       "attach": "附加",
       "adjust": "調整",
       "subLinks": "訂閱連結",
+      "enable": "啟用",
+      "disable": "停用",
+      "bulkEnableConfirmTitle": "啟用 {count} 個客戶端?",
+      "bulkEnableConfirmContent": "在每個已附加的入站上啟用所選的客戶端。配額已用盡或已過期的客戶端將被自動重新停用。",
+      "bulkDisableConfirmTitle": "停用 {count} 個客戶端?",
+      "bulkDisableConfirmContent": "在每個已附加的入站上停用所選的客戶端。他們將立即失去存取權限,但其記錄與流量將被保留。",
       "selectedCount": "已選 {count} 項",
       "attachSelected": "附加 ({count})",
       "attachToInboundsTitle": "將 {count} 個客戶端附加到入站",
@@ -872,6 +880,10 @@
         "allTrafficsReset": "所有客戶端流量已重設",
         "bulkDeleted": "已刪除 {count} 個客戶端",
         "bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
+        "bulkEnabled": "已啟用 {count} 個客戶端",
+        "bulkEnabledMixed": "已啟用 {ok} 個,失敗 {failed} 個",
+        "bulkDisabled": "已停用 {count} 個客戶端",
+        "bulkDisabledMixed": "已停用 {ok} 個,失敗 {failed} 個",
         "bulkCreated": "已建立 {count} 個客戶端",
         "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
         "bulkAdjusted": "已調整 {count} 個客戶端",

+ 9 - 0
internal/web/web.go

@@ -21,6 +21,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/job"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
@@ -394,6 +395,14 @@ func (s *Server) startTask(restartXray bool) {
 	if s.memoryAlarmWanted() {
 		s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob())
 	}
+
+	if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
+		s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
+		go func() {
+			time.Sleep(time.Minute)
+			job.NewMemoryReleaseJob().Run()
+		}()
+	}
 }
 
 // cpuAlarmWanted reports whether any notifier is configured to receive cpu.high

+ 2 - 4
main.go

@@ -53,10 +53,8 @@ func runWebServer() {
 
 	godotenv.Load()
 
-	if limit, source := sys.ApplyMemoryLimit(); limit > 0 {
-		logger.Infof("Go memory soft limit set to %d MiB (%s)", limit>>20, source)
-	} else {
-		logger.Info("Go memory soft limit not enforced: ", source)
+	for _, line := range sys.ApplyMemoryTuning() {
+		logger.Info(line)
 	}
 
 	if os.Getenv("XUI_PPROF") == "true" {

+ 69 - 1
x-ui.sh

@@ -253,9 +253,20 @@ uninstall() {
         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 "$(xui_env_file_path)"
+    rm -f "$db_env_file"
+
+    if [[ "$panel_used_postgres" == "true" ]] && postgresql_installed; then
+        purge_postgresql
+    fi
 
     echo ""
     echo -e "Uninstalled Successfully.\n"
@@ -2727,6 +2738,63 @@ pg_require_installed() {
     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

Vissa filer visades inte eftersom för många filer har ändrats