mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 16:45:03 +03:00
Compare commits
30 Commits
5dbd5b1d12
..
v3.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b1fb39c486 | |||
| 9381fa284b | |||
| 30796dc2ce | |||
| dc6d13b58f | |||
| e27f2490b2 | |||
| df0e52cda8 | |||
| 1d69508263 | |||
| 8f65aa7e4b | |||
| 293c1e44dc | |||
| 69ad8b76e1 | |||
| b32837e523 | |||
| 9dec15bd4b | |||
| e64e998194 | |||
| a4be5a0deb | |||
| e4b881e58a | |||
| 2adb59bd64 | |||
| bcd1358032 | |||
| e8878b71a4 | |||
| 11c5b53fac | |||
| 896016f7f6 | |||
| e2d25d0ac7 | |||
| fe025e8af3 | |||
| 3ba43bd86d | |||
| ae9bbdf267 | |||
| 2830f97f50 | |||
| 1d1128cf94 | |||
| aad2b3eb1e | |||
| 93ff60e568 | |||
| 23e73cd4a3 | |||
| b0c1156dd6 |
@@ -4,3 +4,16 @@ XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
XUI_INIT_WEB_BASE_PATH=/
|
||||
# XUI_PORT=8080
|
||||
|
||||
# Optional tunnel health monitor (disabled by default). It periodically probes a
|
||||
# URL and restarts xray-core after repeated failures. Point XUI_TUNNEL_HEALTH_PROXY
|
||||
# at a local xray inbound so the probe tests the tunnel; without it the probe only
|
||||
# checks host connectivity and a restart will not fix host network issues. A restart
|
||||
# drops every connected client.
|
||||
# XUI_TUNNEL_HEALTH_MONITOR=true
|
||||
# XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
|
||||
# XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
|
||||
# XUI_TUNNEL_HEALTH_INTERVAL=30s
|
||||
# XUI_TUNNEL_HEALTH_TIMEOUT=10s
|
||||
# XUI_TUNNEL_HEALTH_FAILURES=3
|
||||
# XUI_TUNNEL_HEALTH_COOLDOWN=5m
|
||||
|
||||
+1
-4
@@ -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
|
||||
@@ -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"
|
||||
@@ -97,7 +97,13 @@ jobs:
|
||||
export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
|
||||
[ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
|
||||
cd -
|
||||
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
||||
# Stamp the commit into per-commit (dev channel) builds only; tagged
|
||||
# stable releases stay unstamped so config.IsDevBuild() returns false.
|
||||
LDFLAGS="-w -s -linkmode external -extldflags '-static'"
|
||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||
LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA::8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
fi
|
||||
go build -ldflags "$LDFLAGS" -o xui-release -v main.go
|
||||
file xui-release
|
||||
ldd xui-release || echo "Static binary confirmed"
|
||||
|
||||
@@ -245,7 +251,12 @@ jobs:
|
||||
go version
|
||||
gcc --version
|
||||
|
||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||
# Stamp the commit into per-commit (dev channel) builds only.
|
||||
LDFLAGS="-w -s"
|
||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||
LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA:0:8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
fi
|
||||
go build -ldflags "$LDFLAGS" -o xui-release.exe -v main.go
|
||||
|
||||
- name: Copy and download resources
|
||||
shell: pwsh
|
||||
@@ -302,3 +313,61 @@ jobs:
|
||||
asset_name: x-ui-windows-amd64.zip
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
# =================================
|
||||
# Rolling dev channel (per-commit)
|
||||
# =================================
|
||||
# Publishes/overwrites the build artifacts to a single fixed-tag pre-release
|
||||
# `dev-latest`, force-moved to the new commit on every push to main. The panel's
|
||||
# "Dev" update channel installs from this tag. `--latest=false` is load-bearing:
|
||||
# it keeps releases/latest pointing at the real stable tag, so the stable
|
||||
# channel is unaffected.
|
||||
publish-dev:
|
||||
name: Publish rolling dev release
|
||||
needs: [build, build-windows]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
# Serialize racing pushes; never cancel an in-flight upload, or the dev
|
||||
# release could be left with a partial asset set.
|
||||
concurrency:
|
||||
group: dev-release
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dev-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish dev-latest pre-release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMIT: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
short="${COMMIT::8}"
|
||||
notes="Rolling development build — installs via the panel's Dev update channel.
|
||||
|
||||
commit=${COMMIT}
|
||||
built=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
Automated per-commit build from main. Not a stable release."
|
||||
|
||||
# Force-move the dev-latest tag to this commit so the release tracks it.
|
||||
git tag -f dev-latest "${COMMIT}"
|
||||
git push -f origin refs/tags/dev-latest
|
||||
|
||||
if gh release view dev-latest >/dev/null 2>&1; then
|
||||
gh release edit dev-latest --prerelease --latest=false \
|
||||
--title "Dev build ${short}" --notes "${notes}"
|
||||
else
|
||||
gh release create dev-latest --prerelease --latest=false \
|
||||
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
|
||||
fi
|
||||
|
||||
gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-1
@@ -33,7 +33,7 @@
|
||||
- **إحصائيات الترافيك** — لكل اتصال وارد، ولكل عميل، ولكل اتصال صادر، مع عناصر تحكم لإعادة التعيين.
|
||||
- **دعم العقد المتعددة** — إدارة وتوسيع عبر عدة خوادم من لوحة واحدة.
|
||||
- **الاتصالات الصادرة والتوجيه** — WARP، NordVPN، قواعد توجيه مخصصة، موازنات تحميل، وتسلسل الوكلاء الصادرة.
|
||||
- **خادم اشتراك مدمج** بصيغ إخراج متعددة.
|
||||
- **خادم اشتراك مدمج** بصيغ إخراج متعددة و[قوالب صفحات مخصصة](docs/custom-subscription-templates.md).
|
||||
- **روبوت تيليجرام** للمراقبة والإدارة عن بُعد.
|
||||
- **واجهة RESTful API** مع توثيق Swagger داخل اللوحة.
|
||||
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
|
||||
@@ -73,10 +73,32 @@
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
لتثبيت إصدار محدد، أضِف وسمه (مثل `v3.4.0`):
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
لتثبيت بنية **dev** المتجددة (أحدث إصدار أولي لكل التزام (commit) من `main`، وليس إصدارًا مستقرًا)، مرّر `dev-latest`:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
|
||||
|
||||
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
### التثبيت غير التفاعلي
|
||||
|
||||
يعمل المثبِّت أيضًا **بشكل غير تفاعلي** لـ 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)
|
||||
- [ملاحظات Hetzner Cloud](deploy/marketplace/hetzner/) — نشر يعتمد على cloud-init على Hetzner
|
||||
|
||||
## المنصات المدعومة
|
||||
|
||||
**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
|
||||
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | تفعيل فرض حدود IP المعتمد على Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | مستوى السجل (`debug`، `info`، `warning`، `error`) | `info` |
|
||||
| `XUI_DEBUG` | تفعيل وضع التصحيح | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | تفعيل مراقب صحة النفق (يفحص عنوان URL ويعيد تشغيل xray بعد فشل متكرر؛ إعادة التشغيل تقطع جميع العملاء) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | الوكيل الذي يُرسَل عبره الفحص؛ وجّهه إلى اتصال xray وارد محلي ليختبر الفحص النفق (مثل `socks5://127.0.0.1:1080`). القيمة الفارغة تعني أن الفحص يتحقق فقط من اتصال المضيف | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | عنوان URL الذي يُفحَص لمعرفة صحة النفق | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | الفترة بين عمليات الفحص | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | مهلة كل عملية فحص | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | عدد حالات الفشل المتتالية قبل تشغيل إعادة التشغيل | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | الحد الأدنى للتأخير بين عمليات إعادة التشغيل المتتالية | `5m` |
|
||||
|
||||
## اللغات المدعومة
|
||||
|
||||
|
||||
+30
-1
@@ -33,7 +33,7 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
|
||||
- **Estadísticas de tráfico** — por entrada, por cliente y por salida, con controles de reinicio.
|
||||
- **Soporte multinodo** — gestiona y escala a través de varios servidores desde un único panel.
|
||||
- **Salida y enrutamiento** — WARP, NordVPN, reglas de enrutamiento personalizadas, balanceadores de carga y encadenamiento de proxy de salida.
|
||||
- **Servidor de suscripción integrado** con múltiples formatos de salida.
|
||||
- **Servidor de suscripción integrado** con múltiples formatos de salida y [plantillas de página personalizables](docs/custom-subscription-templates.md).
|
||||
- **Bot de Telegram** para monitorización y gestión remotas.
|
||||
- **API RESTful** con documentación Swagger dentro del panel.
|
||||
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
|
||||
@@ -73,10 +73,32 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Para instalar una versión específica, añade su etiqueta (p. ej. `v3.4.0`):
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
Para instalar la versión **dev** continua (la última prelanzamiento por commit desde `main`, no una versión estable), pasa `dev-latest`:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
Durante la instalación se generan un nombre de usuario, una contraseña y una ruta de acceso aleatorios. Tras la instalación, ejecuta `x-ui` para abrir el menú de gestión, donde puedes iniciar/detener el servicio, ver o restablecer tus credenciales de acceso, gestionar certificados SSL y mucho más.
|
||||
|
||||
Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
### Instalación desatendida
|
||||
|
||||
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)
|
||||
- [Notas de Hetzner Cloud](deploy/marketplace/hetzner/) — despliegue basado en cloud-init en Hetzner
|
||||
|
||||
## Plataformas Compatibles
|
||||
|
||||
**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine y Windows.
|
||||
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | Habilitar la aplicación de límites de IP basada en Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | Nivel de registro (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Habilitar el modo de depuración | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | Habilitar el monitor de salud del túnel (sondea una URL y reinicia xray tras fallos repetidos; un reinicio desconecta a todos los clientes) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | Proxy a través del cual se envía el sondeo; apúntalo a una entrada local de xray para que el sondeo pruebe el túnel (p. ej. `socks5://127.0.0.1:1080`). Vacío significa que el sondeo solo comprueba la conectividad del host | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | URL sondeada para verificar la salud del túnel | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | Intervalo entre sondeos | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Tiempo de espera por sondeo | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | Fallos consecutivos antes de que se active un reinicio | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Retardo mínimo entre reinicios consecutivos | `5m` |
|
||||
|
||||
## Idiomas Compatibles
|
||||
|
||||
|
||||
+30
-1
@@ -33,7 +33,7 @@
|
||||
- **آمار ترافیک** — بهازای هر اینباند، هر کلاینت و هر اوتباند، همراه با کنترل بازنشانی (reset).
|
||||
- **پشتیبانی از چند نود** — مدیریت و مقیاسدهی روی چندین سرور از یک پنل واحد.
|
||||
- **اوتباند و مسیریابی** — WARP، NordVPN، قوانین مسیریابی سفارشی، متعادلکنندههای بار (load balancer) و زنجیرهکردن پراکسی اوتباند.
|
||||
- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی.
|
||||
- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی و [قالبهای صفحهی سفارشی](docs/custom-subscription-templates.md).
|
||||
- **ربات تلگرام** برای نظارت و مدیریت از راه دور.
|
||||
- **RESTful API** همراه با مستندات Swagger درونپنل.
|
||||
- **ذخیرهسازی منعطف** — SQLite (پیشفرض) یا PostgreSQL.
|
||||
@@ -73,10 +73,32 @@
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
برای نصب یک نسخهی مشخص، تگ آن را در انتها اضافه کنید (مثلاً `v3.4.0`):
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
برای نصب نسخهی غلتانِ **dev** (آخرین پیشانتشار بهازای هر کامیت از شاخهی `main`، نه یک انتشار پایدار)، مقدار `dev-latest` را پاس دهید:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید میشود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا میتوانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهیهای SSL را مدیریت کنید و کارهای دیگری انجام دهید.
|
||||
|
||||
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
|
||||
|
||||
### نصب بدون نظارت
|
||||
|
||||
نصبکننده بهصورت **غیرتعاملی** نیز برای 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)
|
||||
- [یادداشتهای Hetzner Cloud](deploy/marketplace/hetzner/) — استقرار مبتنی بر cloud-init روی Hetzner
|
||||
|
||||
## پلتفرمهای پشتیبانیشده
|
||||
|
||||
**سیستمعاملها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
|
||||
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | فعالسازی اعمال محدودیت IP مبتنی بر Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | سطح گزارشگیری (`debug`، `info`، `warning`، `error`) | `info` |
|
||||
| `XUI_DEBUG` | فعالسازی حالت دیباگ | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | فعالسازی پایشگر سلامت تونل (یک URL را پروب میکند و پس از خطاهای مکرر، xray را ریاستارت میکند؛ یک ریاستارت همهی کلاینتها را قطع میکند) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | پراکسیای که پروب از طریق آن ارسال میشود؛ آن را به یک اینباند محلی xray اشاره دهید تا پروب خودِ تونل را آزمایش کند (مثلاً `socks5://127.0.0.1:1080`). خالی بودن یعنی پروب فقط اتصال به هاست را بررسی میکند | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | URL ای که برای سلامت تونل پروب میشود | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | فاصلهی زمانی بین پروبها | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | مهلت زمانی هر پروب | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | تعداد خطاهای متوالی پیش از آنکه یک ریاستارت فعال شود | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | حداقل تأخیر بین ریاستارتهای متوالی | `5m` |
|
||||
|
||||
## زبانهای پشتیبانیشده
|
||||
|
||||
|
||||
@@ -73,21 +73,31 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
To install a specific version, append its tag (e.g. `v3.4.0`):
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
To install the rolling **dev** build (latest per-commit pre-release from `main`, not a stable release), pass `dev-latest`:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
During installation a random username, password, and access path are generated. After installation, run `x-ui` to open the management menu, where you can start/stop the service, view or reset your login credentials, manage SSL certificates, and more.
|
||||
|
||||
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
|
||||
|
||||
@@ -146,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
|
||||
| `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Enable debug mode | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | Enable the tunnel health monitor (probes a URL and restarts xray after repeated failures; a restart drops all clients) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | Proxy the probe is sent through; point it at a local xray inbound so the probe tests the tunnel (e.g. `socks5://127.0.0.1:1080`). Empty means the probe only checks host connectivity | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | URL probed for tunnel health | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | Interval between probes | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Per-probe timeout | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | Consecutive failures before a restart is triggered | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Minimum delay between consecutive restarts | `5m` |
|
||||
|
||||
## Supported Languages
|
||||
|
||||
|
||||
+30
-1
@@ -33,7 +33,7 @@
|
||||
- **Статистика трафика** — по каждому входящему, по каждому клиенту и по каждому исходящему, с возможностью сброса.
|
||||
- **Поддержка нескольких узлов** — управление и масштабирование на несколько серверов из одной панели.
|
||||
- **Исходящие подключения и маршрутизация** — WARP, NordVPN, пользовательские правила маршрутизации, балансировщики нагрузки и цепочки исходящих прокси.
|
||||
- **Встроенный сервер подписок** с несколькими форматами вывода.
|
||||
- **Встроенный сервер подписок** с несколькими форматами вывода и [пользовательскими шаблонами страниц](docs/custom-subscription-templates.md).
|
||||
- **Telegram-бот** для удалённого мониторинга и управления.
|
||||
- **RESTful API** с документацией Swagger внутри панели.
|
||||
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
|
||||
@@ -73,10 +73,32 @@
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Чтобы установить конкретную версию, добавьте её тег (например, `v3.4.0`):
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
Чтобы установить скользящую **dev**-сборку (новейший предварительный релиз по каждому коммиту из ветки `main`, а не стабильный релиз), передайте `dev-latest`:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
|
||||
|
||||
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
### Автоматическая установка
|
||||
|
||||
Установщик также работает в **неинтерактивном** режиме для 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)
|
||||
- [Заметки по Hetzner Cloud](deploy/marketplace/hetzner/) — развёртывание на Hetzner на базе cloud-init
|
||||
|
||||
## Поддерживаемые платформы
|
||||
|
||||
**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine и Windows.
|
||||
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | Включить применение лимитов IP на основе Fail2ban | `true` |
|
||||
| `XUI_LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Включить режим отладки | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | Включить монитор состояния туннеля (опрашивает URL и перезапускает xray после многократных сбоев; перезапуск отключает всех клиентов) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | Прокси, через который отправляется проба; укажите локальный входящий xray, чтобы проба проверяла туннель (например, `socks5://127.0.0.1:1080`). Пустое значение означает, что проба проверяет только связь с хостом | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | URL, опрашиваемый для проверки состояния туннеля | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | Интервал между пробами | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Таймаут на одну пробу | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | Число последовательных сбоев до запуска перезапуска | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Минимальная задержка между последовательными перезапусками | `5m` |
|
||||
|
||||
## Поддерживаемые языки
|
||||
|
||||
|
||||
+30
-1
@@ -33,7 +33,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
|
||||
- **Trafik istatistikleri** — Gelen bağlantı (Inbound), istemci ve giden bağlantı (Outbound) bazında istatistikler ve sıfırlama kontrolleri.
|
||||
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden birden fazla sunucuyu yönetin ve ölçeklendirin.
|
||||
- **Giden bağlantı (Outbound) ve yönlendirme** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve giden bağlantı proxy zincirleme (proxy chaining).
|
||||
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ile).
|
||||
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ve [özel sayfa şablonları](docs/custom-subscription-templates.md) ile).
|
||||
- Uzaktan izleme ve yönetim için **Telegram botu**.
|
||||
- Panel içi Swagger dokümantasyonuna sahip **RESTful API**.
|
||||
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
|
||||
@@ -73,10 +73,32 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
Belirli bir sürümü kurmak için, etiketini (ör. `v3.4.0`) ekleyin:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
Sürekli güncellenen **dev** sürümünü (kararlı bir sürüm değil; `main` dalından her commit'te oluşturulan en son ön sürüm) kurmak için `dev-latest` değerini geçirin:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
|
||||
|
||||
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
|
||||
|
||||
### Etkileşimsiz kurulum
|
||||
|
||||
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)
|
||||
- [Hetzner Cloud notları](deploy/marketplace/hetzner/) — Hetzner üzerinde cloud-init tabanlı dağıtım
|
||||
|
||||
## Desteklenen Platformlar
|
||||
|
||||
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine ve Windows.
|
||||
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
|
||||
| `XUI_LOG_LEVEL` | Günlük (Log) ayrıntı seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | Tünel sağlık izleyicisini etkinleştir (bir URL'yi yoklar ve tekrarlanan başarısızlıklardan sonra xray'i yeniden başlatır; yeniden başlatma tüm istemcilerin bağlantısını düşürür) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | Yoklamanın gönderildiği proxy; yoklamanın tüneli test etmesi için bunu yerel bir xray gelen bağlantısına yönlendirin (ör. `socks5://127.0.0.1:1080`). Boş bırakılırsa yoklama yalnızca ana makine bağlantısını kontrol eder | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | Tünel sağlığı için yoklanan URL | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | Yoklamalar arasındaki aralık | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Yoklama başına zaman aşımı | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | Yeniden başlatma tetiklenmeden önceki ardışık başarısızlık sayısı | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Ardışık yeniden başlatmalar arasındaki minimum gecikme | `5m` |
|
||||
|
||||
## Desteklenen Diller
|
||||
|
||||
|
||||
+30
-1
@@ -33,7 +33,7 @@
|
||||
- **流量统计** — 按入站、按客户端、按出站统计,并支持重置控制。
|
||||
- **多节点支持** — 从单一面板管理并扩展到多台服务器。
|
||||
- **出站与路由** — WARP、NordVPN、自定义路由规则、负载均衡器和出站代理链。
|
||||
- **内置订阅服务器**,支持多种输出格式。
|
||||
- **内置订阅服务器**,支持多种输出格式和[自定义页面模板](docs/custom-subscription-templates.md)。
|
||||
- **Telegram 机器人**,用于远程监控和管理。
|
||||
- **RESTful API**,带有面板内置的 Swagger 文档。
|
||||
- **灵活的存储** — SQLite(默认)或 PostgreSQL。
|
||||
@@ -73,10 +73,32 @@
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
若要安装特定版本,请在命令后附加对应的标签(例如 `v3.4.0`):
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
```
|
||||
|
||||
若要安装滚动更新的 **dev** 版本(来自 `main` 的最新逐次提交预发布版本,而非稳定版本),请传入 `dev-latest`:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
|
||||
安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
|
||||
|
||||
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
|
||||
|
||||
### 无人值守安装
|
||||
|
||||
安装程序也可以**非交互式**运行,适用于 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)
|
||||
- [Hetzner Cloud 说明](deploy/marketplace/hetzner/) — 在 Hetzner 上基于 cloud-init 的部署
|
||||
|
||||
## 支持的平台
|
||||
|
||||
**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap)、Alpine 和 Windows。
|
||||
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
| `XUI_ENABLE_FAIL2BAN` | 启用基于 Fail2ban 的 IP 限制 | `true` |
|
||||
| `XUI_LOG_LEVEL` | 日志级别(`debug`、`info`、`warning`、`error`) | `info` |
|
||||
| `XUI_DEBUG` | 启用调试模式 | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | 启用隧道健康监控(探测某个 URL,在连续多次失败后重启 xray;重启会断开所有客户端) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | 探测请求所经过的代理;将其指向本地 xray 入站,使探测能够测试隧道(例如 `socks5://127.0.0.1:1080`)。留空表示探测仅检查主机连通性 | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | 用于检测隧道健康状况的探测 URL | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | 两次探测之间的间隔 | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | 单次探测的超时时间 | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | 触发重启前的连续失败次数 | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | 两次连续重启之间的最小间隔 | `5m` |
|
||||
|
||||
## 支持的语言
|
||||
|
||||
|
||||
+9
-16
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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 "================================================================"
|
||||
@@ -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="you@example.com"
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
@@ -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."
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Packer build artifacts (never commit images or manifests)
|
||||
output-qemu/
|
||||
*.qcow2
|
||||
*.raw
|
||||
packer-manifest.json
|
||||
packer_cache/
|
||||
crash.log
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+377
-376
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -4240,6 +4240,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/setUpdateChannel": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.",
|
||||
"operationId": "post_panel_api_server_setUpdateChannel",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/updateGeofile": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -5532,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": [
|
||||
@@ -7400,7 +7556,7 @@
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.",
|
||||
"summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Set \"dev\": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.",
|
||||
"operationId": "post_panel_api_nodes_updatePanel",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -7414,7 +7570,8 @@
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
],
|
||||
"dev": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ export function useNodeMutations() {
|
||||
});
|
||||
|
||||
const updatePanelsMut = useMutation({
|
||||
mutationFn: (ids: number[]) =>
|
||||
HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids }, {
|
||||
mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) =>
|
||||
HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids, dev }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
@@ -72,7 +72,7 @@ export function useNodeMutations() {
|
||||
remove: (id: number) => removeMut.mutateAsync(id),
|
||||
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
||||
probe: (id: number) => probeMut.mutateAsync(id),
|
||||
updatePanels: (ids: number[]): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync(ids),
|
||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync({ ids, dev }),
|
||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
|
||||
@@ -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,15 +350,24 @@ 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),
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const setExternalLinksMut = useMutation({
|
||||
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, { ...JSON_HEADERS, silentSuccess: true }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
@@ -370,7 +381,7 @@ export function useClients() {
|
||||
|
||||
const detachMut = useMutation({
|
||||
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import './AppSidebar.css';
|
||||
@@ -84,7 +85,7 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
|
||||
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
|
||||
if (!version) return null;
|
||||
const label = `v${version}`;
|
||||
const label = formatPanelVersion(version);
|
||||
return (
|
||||
<a
|
||||
href={REPO_URL}
|
||||
|
||||
@@ -14,6 +14,18 @@ function parseVersionParts(version: string): [number, number, number] | null {
|
||||
return [out[0], out[1], out[2]];
|
||||
}
|
||||
|
||||
// Format a panel version for display. Dev builds report a "dev+<commit>"
|
||||
// identity (see config.GetPanelVersion); show those — and any other
|
||||
// non-numeric label — verbatim. Semantic versions get a single normalized "v"
|
||||
// prefix, so a raw "v3.4.0" tag and a bare "3.4.0" both render as "v3.4.0"
|
||||
// instead of doubling up to "vv3.4.0".
|
||||
export function formatPanelVersion(version: string | undefined | null): string {
|
||||
const v = (version || '').trim();
|
||||
if (!v) return '';
|
||||
const normalized = v.replace(/^v/i, '');
|
||||
return /^\d/.test(normalized) ? `v${normalized}` : v;
|
||||
}
|
||||
|
||||
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
||||
if (!latest || !current) return false;
|
||||
const a = parseVersionParts(latest);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// per client. This file is the single frontend source of truth for the picker
|
||||
// UI and the live preview — keep the token list in sync with remark_vars.go.
|
||||
|
||||
export type RemarkVarGroup = 'client' | 'traffic' | 'time';
|
||||
export type RemarkVarGroup = 'client' | 'traffic' | 'time' | 'connection';
|
||||
|
||||
export interface RemarkVar {
|
||||
/** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
|
||||
@@ -13,7 +13,7 @@ export interface RemarkVar {
|
||||
sample: string;
|
||||
}
|
||||
|
||||
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
|
||||
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time', 'connection'];
|
||||
|
||||
export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
// Client identity
|
||||
@@ -36,11 +36,19 @@ export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
{ token: 'DOWN', group: 'traffic', sample: '3.20GB' },
|
||||
// Time / status
|
||||
{ token: 'STATUS', group: 'time', sample: 'active' },
|
||||
{ token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
|
||||
{ token: 'DAYS_LEFT', group: 'time', sample: '12' },
|
||||
{ token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
|
||||
{ token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3%' },
|
||||
{ token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
|
||||
{ token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
|
||||
{ token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
|
||||
{ token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
|
||||
{ token: 'RESET_DAYS', group: 'time', sample: '30' },
|
||||
// Connection (inbound config descriptors)
|
||||
{ token: 'PROTOCOL', group: 'connection', sample: 'VLESS' },
|
||||
{ token: 'TRANSPORT', group: 'connection', sample: 'ws' },
|
||||
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
|
||||
];
|
||||
|
||||
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||
|
||||
@@ -13,7 +13,7 @@ export class AllSetting {
|
||||
pageSize = 25;
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
|
||||
@@ -400,6 +400,15 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/server/updatePanel',
|
||||
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/setUpdateChannel',
|
||||
summary: 'Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.',
|
||||
params: [
|
||||
{ name: 'dev', in: 'body (form)', type: 'boolean', desc: 'true = dev channel, false = stable.' },
|
||||
],
|
||||
body: 'dev=true',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile',
|
||||
@@ -639,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',
|
||||
@@ -936,8 +959,8 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/updatePanel',
|
||||
summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.',
|
||||
body: '{\n "ids": [1, 2, 3]\n}',
|
||||
summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Set "dev": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.',
|
||||
body: '{\n "ids": [1, 2, 3],\n "dev": false\n}',
|
||||
response: '{\n "success": true,\n "obj": [\n { "id": 1, "name": "de-1", "ok": true },\n { "id": 2, "name": "fr-1", "ok": false, "error": "node is offline" }\n ]\n}',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function ClientQrModal({
|
||||
children: (
|
||||
<QrPanel
|
||||
value={link}
|
||||
remark={`${client?.email || ''} #${idx + 1}`}
|
||||
remark={parts?.remark || `${client?.email || ''} #${idx + 1}`}
|
||||
showQr={!isPostQuantumLink(link)}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
@@ -685,16 +716,18 @@ export default function ClientsPage() {
|
||||
}
|
||||
const updateMsg = await update(meta.email, payload);
|
||||
if (!updateMsg?.success) return updateMsg;
|
||||
const rawEmail = (payload as { email?: unknown }).email;
|
||||
const emailKey = typeof rawEmail === 'string' && rawEmail.trim() ? rawEmail.trim() : meta.email;
|
||||
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
|
||||
const r = await attach(meta.email, meta.attach);
|
||||
const r = await attach(emailKey, meta.attach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
|
||||
const r = await detach(meta.email, meta.detach);
|
||||
const r = await detach(emailKey, meta.detach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
// Always replace the client's external links (an empty set clears them).
|
||||
const r = await setExternalLinks(meta.email, meta.externalLinks);
|
||||
const r = await setExternalLinks(emailKey, meta.externalLinks);
|
||||
if (!r?.success) return r;
|
||||
return updateMsg;
|
||||
}, [create, update, attach, detach, setExternalLinks]);
|
||||
@@ -1010,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']}
|
||||
@@ -1039,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 />,
|
||||
|
||||
@@ -285,8 +285,12 @@ export default function InboundFormModal({
|
||||
) => {
|
||||
if (block?.id === authId) return true;
|
||||
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
if (authId === 'mlkem768') return label.includes('mlkem768');
|
||||
if (authId === 'x25519') return label.includes('x25519');
|
||||
if (authId === 'mlkem768') return label.includes('mlkem768') && !label.includes('xorpub') && !label.includes('random');
|
||||
if (authId === 'x25519') return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random');
|
||||
if (authId === 'mlkem768_xorpub') return label.includes('mlkem768') && label.includes('xorpub');
|
||||
if (authId === 'mlkem768_random') return label.includes('mlkem768') && label.includes('random');
|
||||
if (authId === 'x25519_xorpub') return label.includes('x25519') && label.includes('xorpub');
|
||||
if (authId === 'x25519_random') return label.includes('x25519') && label.includes('random');
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -319,7 +323,19 @@ export default function InboundFormModal({
|
||||
const parts = enc.split('.').filter(Boolean);
|
||||
const authKey = parts[parts.length - 1] || '';
|
||||
if (!authKey) return t('pages.inbounds.vlessAuthCustom');
|
||||
return authKey.length > 300
|
||||
const mode = parts[1] || 'native';
|
||||
const keyType = authKey.length > 300 ? 'mlkem768' : 'x25519';
|
||||
if (mode === 'xorpub') {
|
||||
return keyType === 'mlkem768'
|
||||
? t('pages.inbounds.vlessAuthMlkem768Xorpub')
|
||||
: t('pages.inbounds.vlessAuthX25519Xorpub');
|
||||
}
|
||||
if (mode === 'random') {
|
||||
return keyType === 'mlkem768'
|
||||
? t('pages.inbounds.vlessAuthMlkem768Random')
|
||||
: t('pages.inbounds.vlessAuthX25519Random');
|
||||
}
|
||||
return keyType === 'mlkem768'
|
||||
? t('pages.inbounds.vlessAuthMlkem768')
|
||||
: t('pages.inbounds.vlessAuthX25519');
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
|
||||
type VlessAuthKind =
|
||||
| 'x25519'
|
||||
| 'x25519_xorpub'
|
||||
| 'x25519_random'
|
||||
| 'mlkem768'
|
||||
| 'mlkem768_xorpub'
|
||||
| 'mlkem768_random';
|
||||
|
||||
interface VlessFieldsProps {
|
||||
saving: boolean;
|
||||
selectedVlessAuth: string;
|
||||
network: string;
|
||||
security: string;
|
||||
getNewVlessEnc: (kind: 'x25519' | 'mlkem768') => void;
|
||||
getNewVlessEnc: (kind: VlessAuthKind) => void;
|
||||
clearVlessEnc: () => void;
|
||||
}
|
||||
|
||||
@@ -19,6 +28,17 @@ export default function VlessFields({
|
||||
clearVlessEnc,
|
||||
}: VlessFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519');
|
||||
|
||||
const authOptions = [
|
||||
{ value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') },
|
||||
{ value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') },
|
||||
{ value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
|
||||
{ value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') },
|
||||
{ value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') },
|
||||
{ value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
|
||||
@@ -27,13 +47,16 @@ export default function VlessFields({
|
||||
<Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
|
||||
<Space size={8} wrap>
|
||||
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc('x25519')}>
|
||||
{t('pages.inbounds.vlessAuthX25519')}
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc('mlkem768')}>
|
||||
{t('pages.inbounds.vlessAuthMlkem768')}
|
||||
<Select
|
||||
value={authKind}
|
||||
onChange={(v) => setAuthKind(v)}
|
||||
options={authOptions}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc(authKind)}>
|
||||
{t('pages.inbounds.vlessAuthGenerateButton')}
|
||||
</Button>
|
||||
<Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
|
||||
</Space>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -65,6 +66,8 @@ export default function IndexPage() {
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const [accessLogEnable, setAccessLogEnable] = useState(false);
|
||||
const [isDevBuild, setIsDevBuild] = useState(false);
|
||||
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||
currentVersion: '',
|
||||
latestVersion: '',
|
||||
@@ -87,8 +90,14 @@ export default function IndexPage() {
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
useEffect(() => {
|
||||
HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
|
||||
if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
|
||||
HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>(
|
||||
'/panel/api/setting/defaultSettings',
|
||||
).then((msg) => {
|
||||
if (msg?.success && msg.obj) {
|
||||
setAccessLogEnable(!!msg.obj.accessLogEnable);
|
||||
setIsDevBuild(!!msg.obj.isDevBuild);
|
||||
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
||||
}
|
||||
});
|
||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
@@ -96,7 +105,7 @@ export default function IndexPage() {
|
||||
}, []);
|
||||
|
||||
const displayVersion = useMemo(
|
||||
() => panelUpdateInfo.currentVersion || window.X_UI_CUR_VER || '?',
|
||||
() => window.X_UI_CUR_VER || panelUpdateInfo.currentVersion || '?',
|
||||
[panelUpdateInfo.currentVersion],
|
||||
);
|
||||
|
||||
@@ -119,13 +128,21 @@ export default function IndexPage() {
|
||||
}, [refresh]);
|
||||
|
||||
function openPanelVersion() {
|
||||
if (panelUpdateInfo.updateAvailable) {
|
||||
if (panelUpdateInfo.updateAvailable || isDevBuild) {
|
||||
setPanelUpdateOpen(true);
|
||||
} else {
|
||||
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChannelChange(dev: boolean) {
|
||||
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
||||
if (!res?.success) return;
|
||||
setDevChannelEnable(dev);
|
||||
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
}
|
||||
|
||||
function openTelegram() {
|
||||
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
@@ -224,8 +241,8 @@ export default function IndexPage() {
|
||||
{isMobile && displayVersion && (
|
||||
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
|
||||
{panelUpdateInfo.updateAvailable
|
||||
? `v${panelUpdateInfo.latestVersion}`
|
||||
: `v${displayVersion}`}
|
||||
? formatPanelVersion(panelUpdateInfo.latestVersion)
|
||||
: formatPanelVersion(displayVersion)}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
@@ -254,8 +271,8 @@ export default function IndexPage() {
|
||||
{!isMobile && (
|
||||
<span>
|
||||
{panelUpdateInfo.updateAvailable
|
||||
? `${t('update')} ${panelUpdateInfo.latestVersion}`
|
||||
: `v${displayVersion}`}
|
||||
? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
|
||||
: formatPanelVersion(displayVersion)}
|
||||
</span>
|
||||
)}
|
||||
</Space>,
|
||||
@@ -446,6 +463,9 @@ export default function IndexPage() {
|
||||
<PanelUpdateModal
|
||||
open={panelUpdateOpen}
|
||||
info={panelUpdateInfo}
|
||||
isDevBuild={isDevBuild}
|
||||
devChannelEnable={devChannelEnable}
|
||||
onChannelChange={handleChannelChange}
|
||||
onClose={() => setPanelUpdateOpen(false)}
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Modal, Tag } from 'antd';
|
||||
import { Alert, Button, Modal, Switch, Tag } from 'antd';
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import './PanelUpdateModal.css';
|
||||
|
||||
export interface PanelUpdateInfo {
|
||||
channel?: string;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
currentCommit?: string;
|
||||
latestCommit?: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
@@ -20,13 +25,27 @@ interface BusyEvent {
|
||||
interface PanelUpdateModalProps {
|
||||
open: boolean;
|
||||
info: PanelUpdateInfo;
|
||||
isDevBuild?: boolean;
|
||||
devChannelEnable?: boolean;
|
||||
onChannelChange?: (dev: boolean) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
onBusy: (e: BusyEvent) => void;
|
||||
}
|
||||
|
||||
export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) {
|
||||
export default function PanelUpdateModal({
|
||||
open,
|
||||
info,
|
||||
isDevBuild,
|
||||
devChannelEnable,
|
||||
onChannelChange,
|
||||
onClose,
|
||||
onBusy,
|
||||
}: PanelUpdateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [channelBusy, setChannelBusy] = useState(false);
|
||||
|
||||
const isDev = info.channel === 'dev';
|
||||
|
||||
async function pollUntilBack(): Promise<boolean> {
|
||||
await PromiseUtil.sleep(5000);
|
||||
@@ -43,6 +62,16 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleChannel(checked: boolean) {
|
||||
if (!onChannelChange) return;
|
||||
setChannelBusy(true);
|
||||
try {
|
||||
await onChannelChange(checked);
|
||||
} finally {
|
||||
setChannelBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePanel() {
|
||||
modal.confirm({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
@@ -84,15 +113,41 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDevBuild && (
|
||||
<div className="version-list">
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.devChannel')}</span>
|
||||
<Switch
|
||||
checked={!!devChannelEnable}
|
||||
loading={channelBusy}
|
||||
onChange={handleChannel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devChannelEnable && (
|
||||
<Alert
|
||||
type="info"
|
||||
className="mb-12"
|
||||
title={t('pages.index.devChannelWarning')}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="version-list">
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.currentPanelVersion')}</span>
|
||||
<Tag color="green">v{info.currentVersion || '?'}</Tag>
|
||||
<span>{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}</span>
|
||||
{isDev ? (
|
||||
<Tag color="green">{info.currentCommit || '?'}</Tag>
|
||||
) : (
|
||||
<Tag color="green">{formatPanelVersion(window.X_UI_CUR_VER || info.currentVersion) || '?'}</Tag>
|
||||
)}
|
||||
</div>
|
||||
{info.updateAvailable ? (
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.latestPanelVersion')}</span>
|
||||
<Tag color="purple">{info.latestVersion || '-'}</Tag>
|
||||
<span>{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}</span>
|
||||
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<div className="version-list-item">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
|
||||
import { Alert, Button, Card, Checkbox, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
@@ -21,6 +21,33 @@ import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
|
||||
|
||||
// Confirm-dialog body that lets the operator pick the stable or dev channel for
|
||||
// a node panel update. Reports changes via onChange so the imperative
|
||||
// modal.confirm onOk can read the latest choice through a ref.
|
||||
function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [dev, setDev] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<p>{t('pages.nodes.updateConfirmContent')}</p>
|
||||
<Checkbox
|
||||
checked={dev}
|
||||
onChange={(e) => { setDev(e.target.checked); onChange(e.target.checked); }}
|
||||
>
|
||||
{t('pages.nodes.updateDevChannel')}
|
||||
</Checkbox>
|
||||
{dev && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 8 }}
|
||||
message={t('pages.index.devChannelWarning')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
@@ -136,8 +163,10 @@ export default function NodesPage() {
|
||||
await setEnable(node.id, next);
|
||||
}, [setEnable]);
|
||||
|
||||
const runUpdate = useCallback(async (ids: number[]) => {
|
||||
const msg = await updatePanels(ids);
|
||||
const devRef = useRef(false);
|
||||
|
||||
const runUpdate = useCallback(async (ids: number[], dev: boolean) => {
|
||||
const msg = await updatePanels(ids, dev);
|
||||
if (!msg?.success) {
|
||||
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
return;
|
||||
@@ -156,12 +185,13 @@ export default function NodesPage() {
|
||||
}, [updatePanels, messageApi, t]);
|
||||
|
||||
const onUpdateNode = useCallback((node: NodeRecord) => {
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateConfirmContent'),
|
||||
content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id]),
|
||||
onOk: () => runUpdate([node.id], devRef.current),
|
||||
});
|
||||
}, [modal, t, runUpdate]);
|
||||
|
||||
@@ -173,12 +203,13 @@ export default function NodesPage() {
|
||||
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
|
||||
content: t('pages.nodes.updateConfirmContent'),
|
||||
content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate(eligible),
|
||||
onOk: () => runUpdate(eligible, devRef.current),
|
||||
});
|
||||
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@ export default function SubPage() {
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `Link ${idx + 1}`;
|
||||
const rowTitle = parts?.remark || fallback;
|
||||
const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
|
||||
const qrLabel = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
return (
|
||||
<div key={link} className="sub-link-row">
|
||||
|
||||
@@ -221,6 +221,7 @@ export default function XrayPage() {
|
||||
testingAll={testingAll}
|
||||
inboundTags={inboundTags}
|
||||
subscriptionOutbounds={subscriptionOutbounds}
|
||||
subscriptionOutboundTags={subscriptionOutboundTags}
|
||||
isMobile={isMobile}
|
||||
onResetTraffic={resetOutboundsTraffic}
|
||||
onTest={onTestOutbound}
|
||||
|
||||
@@ -83,6 +83,7 @@ interface OutboundFormModalProps {
|
||||
open: boolean;
|
||||
outbound: Record<string, unknown> | null;
|
||||
existingTags: string[];
|
||||
dialerProxyTags?: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: (outbound: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -92,6 +93,7 @@ export default function OutboundFormModal({
|
||||
open,
|
||||
outbound: outboundProp,
|
||||
existingTags,
|
||||
dialerProxyTags,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: OutboundFormModalProps) {
|
||||
@@ -514,7 +516,7 @@ export default function OutboundFormModal({
|
||||
{security === 'reality' && realityAllowed && <RealityForm />}
|
||||
|
||||
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
|
||||
<SockoptForm form={form} outboundTags={existingTags} />
|
||||
<SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
|
||||
)}
|
||||
|
||||
<FinalMaskForm
|
||||
|
||||
@@ -75,6 +75,7 @@ interface OutboundsTabProps {
|
||||
testingAll: boolean;
|
||||
inboundTags: string[];
|
||||
subscriptionOutbounds?: unknown[];
|
||||
subscriptionOutboundTags?: string[];
|
||||
isMobile: boolean;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
@@ -94,6 +95,7 @@ export default function OutboundsTab({
|
||||
testingAll,
|
||||
inboundTags: _inboundTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
isMobile,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
@@ -140,6 +142,19 @@ export default function OutboundsTab({
|
||||
|
||||
const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
|
||||
|
||||
const dialerProxyTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
(templateSettings?.outbounds || []).forEach((o, i) => {
|
||||
if (i === editingIndex) return;
|
||||
if (o?.protocol === 'blackhole') return;
|
||||
if (o?.tag) tags.add(o.tag);
|
||||
});
|
||||
for (const tag of subscriptionOutboundTags || []) {
|
||||
if (tag) tags.add(tag);
|
||||
}
|
||||
return [...tags];
|
||||
}, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
|
||||
|
||||
const mutate = useCallback(
|
||||
(mutator: (next: XraySettingsValue) => void) => {
|
||||
setTemplateSettings((prev) => {
|
||||
@@ -521,6 +536,7 @@ export default function OutboundsTab({
|
||||
open={modalOpen}
|
||||
outbound={editingOutbound}
|
||||
existingTags={existingTags}
|
||||
dialerProxyTags={dialerProxyTags}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
import { formatPanelVersion, isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
|
||||
// Parity with web/service/panel.go isNewerVersion.
|
||||
describe('isPanelUpdateAvailable', () => {
|
||||
@@ -31,3 +31,26 @@ describe('isPanelUpdateAvailable', () => {
|
||||
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPanelVersion', () => {
|
||||
it('adds a single v prefix to bare semantic versions', () => {
|
||||
expect(formatPanelVersion('3.4.0')).toBe('v3.4.0');
|
||||
expect(formatPanelVersion('2.6.5')).toBe('v2.6.5');
|
||||
});
|
||||
|
||||
it('does not double up the v on already-prefixed tags', () => {
|
||||
expect(formatPanelVersion('v3.4.0')).toBe('v3.4.0');
|
||||
expect(formatPanelVersion('V3.4.0')).toBe('v3.4.0');
|
||||
});
|
||||
|
||||
it('shows dev builds verbatim without a v prefix', () => {
|
||||
expect(formatPanelVersion('dev+1a2b3c4d')).toBe('dev+1a2b3c4d');
|
||||
expect(formatPanelVersion('dev')).toBe('dev');
|
||||
});
|
||||
|
||||
it('returns empty for blank input and leaves unknown markers untouched', () => {
|
||||
expect(formatPanelVersion('')).toBe('');
|
||||
expect(formatPanelVersion(undefined)).toBe('');
|
||||
expect(formatPanelVersion('?')).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
+19
-14
@@ -19,6 +19,7 @@ export class Msg<T = unknown> {
|
||||
|
||||
export interface HttpOptions extends AxiosRequestConfig {
|
||||
silent?: boolean;
|
||||
silentSuccess?: boolean;
|
||||
}
|
||||
|
||||
export interface HttpModal {
|
||||
@@ -27,20 +28,24 @@ export interface HttpModal {
|
||||
}
|
||||
|
||||
export class HttpUtil {
|
||||
static _handleMsg(msg: unknown): void {
|
||||
static _handleMsg(msg: unknown, silentSuccess = false): void {
|
||||
if (!(msg instanceof Msg) || msg.msg === '') {
|
||||
return;
|
||||
}
|
||||
const messageType = msg.success ? 'success' : 'error';
|
||||
getMessage()[messageType](msg.msg);
|
||||
if (
|
||||
msg.success &&
|
||||
msg.obj &&
|
||||
typeof msg.obj === 'object' &&
|
||||
(msg.obj as { nodePending?: unknown }).nodePending === true
|
||||
) {
|
||||
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
|
||||
if (msg.success) {
|
||||
if (!silentSuccess) {
|
||||
getMessage().success(msg.msg);
|
||||
}
|
||||
if (
|
||||
msg.obj &&
|
||||
typeof msg.obj === 'object' &&
|
||||
(msg.obj as { nodePending?: unknown }).nodePending === true
|
||||
) {
|
||||
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
getMessage().error(msg.msg);
|
||||
}
|
||||
|
||||
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
||||
@@ -59,11 +64,11 @@ export class HttpUtil {
|
||||
}
|
||||
|
||||
static async get<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
|
||||
const { silent, ...axiosOpts } = options;
|
||||
const { silent, silentSuccess, ...axiosOpts } = options;
|
||||
try {
|
||||
const resp = await axios.get(url, { params, ...axiosOpts });
|
||||
const msg = this._respToMsg(resp) as Msg<T>;
|
||||
if (!silent) this._handleMsg(msg);
|
||||
if (!silent) this._handleMsg(msg, silentSuccess);
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('GET request failed:', error);
|
||||
@@ -75,11 +80,11 @@ export class HttpUtil {
|
||||
}
|
||||
|
||||
static async post<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
|
||||
const { silent, ...axiosOpts } = options;
|
||||
const { silent, silentSuccess, ...axiosOpts } = options;
|
||||
try {
|
||||
const resp = await axios.post(url, data, axiosOpts);
|
||||
const msg = this._respToMsg(resp) as Msg<T>;
|
||||
if (!silent) this._handleMsg(msg);
|
||||
if (!silent) this._handleMsg(msg, silentSuccess);
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('POST request failed:', error);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+15
-7
@@ -1344,19 +1344,27 @@ install_x-ui() {
|
||||
fi
|
||||
else
|
||||
tag_version=$1
|
||||
tag_version_numeric=${tag_version#v}
|
||||
min_version="2.3.5"
|
||||
# The rolling dev channel ships under a fixed, non-semver tag that is
|
||||
# force-moved to the latest main commit on every push. Accept `dev` as a
|
||||
# convenient alias and skip the numeric floor check for it.
|
||||
if [[ "$tag_version" == "dev" || "$tag_version" == "dev-latest" ]]; then
|
||||
tag_version="dev-latest"
|
||||
echo -e "${yellow}Installing the rolling dev build (tag: dev-latest). This is a per-commit pre-release, not a stable version.${plain}"
|
||||
else
|
||||
tag_version_numeric=${tag_version#v}
|
||||
min_version="2.3.5"
|
||||
|
||||
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
|
||||
echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
|
||||
exit 1
|
||||
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
|
||||
echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
|
||||
echo -e "Beginning to install x-ui $1"
|
||||
echo -e "Beginning to install x-ui ${tag_version}"
|
||||
curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}"
|
||||
echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -20,6 +20,15 @@ var version string
|
||||
//go:embed name
|
||||
var name string
|
||||
|
||||
// buildCommit and buildDate are injected at build time via `-ldflags -X` for
|
||||
// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
|
||||
// stay empty for a plain `go build` and for stable tagged releases, which is how
|
||||
// IsDevBuild tells a rolling dev build apart from a stable/local one.
|
||||
var (
|
||||
buildCommit string
|
||||
buildDate string
|
||||
)
|
||||
|
||||
// LogLevel represents the logging level for the application.
|
||||
type LogLevel string
|
||||
|
||||
@@ -32,8 +41,11 @@ const (
|
||||
Error LogLevel = "error"
|
||||
)
|
||||
|
||||
// GetVersion returns the version string of the 3x-ui application.
|
||||
func GetVersion() string {
|
||||
// GetBaseVersion returns the raw embedded release version of the 3x-ui panel
|
||||
// (e.g. "3.4.0"). This is the panel's own version, not the Xray version. For the
|
||||
// version a panel advertises/displays (which adds a "dev+<sha>" label on dev
|
||||
// builds), use GetPanelVersion.
|
||||
func GetBaseVersion() string {
|
||||
return strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
@@ -42,6 +54,39 @@ func GetName() string {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// GetBuildCommit returns the short git commit this binary was built from, or an
|
||||
// empty string for a plain/local build or a stable tagged release.
|
||||
func GetBuildCommit() string {
|
||||
return strings.TrimSpace(buildCommit)
|
||||
}
|
||||
|
||||
// GetBuildDate returns the UTC build timestamp injected at build time, or empty.
|
||||
func GetBuildDate() string {
|
||||
return strings.TrimSpace(buildDate)
|
||||
}
|
||||
|
||||
// IsDevBuild reports whether this binary is a CI per-commit (dev channel) build,
|
||||
// detected by the injected commit. Stable releases and local builds return false.
|
||||
func IsDevBuild() bool {
|
||||
return GetBuildCommit() != ""
|
||||
}
|
||||
|
||||
// GetPanelVersion returns the version a panel advertises to a managing master
|
||||
// node and displays in the UI: the plain version for stable builds, or
|
||||
// "dev+<short commit>" for dev builds. The dev form mirrors the master's
|
||||
// getPanelUpdateInfo latestVersion so a node on the current dev commit compares
|
||||
// as up to date instead of always showing "update available".
|
||||
func GetPanelVersion() string {
|
||||
if !IsDevBuild() {
|
||||
return GetBaseVersion()
|
||||
}
|
||||
commit := GetBuildCommit()
|
||||
if len(commit) > 8 {
|
||||
commit = commit[:8]
|
||||
}
|
||||
return "dev+" + commit
|
||||
}
|
||||
|
||||
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
|
||||
func GetLogLevel() LogLevel {
|
||||
if IsDebug() {
|
||||
|
||||
@@ -5,6 +5,26 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetPanelVersion(t *testing.T) {
|
||||
orig := buildCommit
|
||||
t.Cleanup(func() { buildCommit = orig })
|
||||
|
||||
buildCommit = ""
|
||||
if got := GetPanelVersion(); got != GetBaseVersion() {
|
||||
t.Fatalf("stable build: GetPanelVersion = %q, want %q", got, GetBaseVersion())
|
||||
}
|
||||
|
||||
buildCommit = "1d1128cf"
|
||||
if got := GetPanelVersion(); got != "dev+1d1128cf" {
|
||||
t.Fatalf("dev build: GetPanelVersion = %q, want %q", got, "dev+1d1128cf")
|
||||
}
|
||||
|
||||
buildCommit = "1d1128cf945c4615efa05cf41ba7fa766e2ee428"
|
||||
if got := GetPanelVersion(); got != "dev+1d1128cf" {
|
||||
t.Fatalf("dev build (full sha): GetPanelVersion = %q, want %q", got, "dev+1d1128cf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPortOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.0
|
||||
3.4.1
|
||||
@@ -0,0 +1,37 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// A single getSubs entry can hold several links (one per host of an inbound)
|
||||
// joined by newlines. BuildPageData must split them into one entry per link, with
|
||||
// the email replicated, so the subpage renders one row per host instead of
|
||||
// collapsing them onto a single mangled line.
|
||||
func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) {
|
||||
s := &SubService{}
|
||||
subs := []string{
|
||||
"vless://a@h1:443?type=tcp#DE-john@x\nvless://a@h2:443?type=tcp#DE-john@x\nvless://a@h3:443?type=tcp#DE-john@x",
|
||||
"vless://b@h:443?type=tcp#FR-alice@x",
|
||||
}
|
||||
emails := []string{"john@x", "alice@x"}
|
||||
|
||||
page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "", "", "/", "", "")
|
||||
|
||||
if len(page.Result) != 4 {
|
||||
t.Fatalf("Result len = %d, want 4 (3 host links + 1 single link)", len(page.Result))
|
||||
}
|
||||
for i, link := range page.Result {
|
||||
if strings.Contains(link, "\n") {
|
||||
t.Fatalf("Result[%d] still multi-line: %q", i, link)
|
||||
}
|
||||
}
|
||||
wantEmails := []string{"john@x", "john@x", "john@x", "alice@x"}
|
||||
if !reflect.DeepEqual(page.Emails, wantEmails) {
|
||||
t.Fatalf("Emails = %v, want %v", page.Emails, wantEmails)
|
||||
}
|
||||
}
|
||||
+108
-39
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
@@ -25,6 +24,7 @@ type remarkContext struct {
|
||||
inbound *model.Inbound
|
||||
hostRemark string
|
||||
transport string
|
||||
security string
|
||||
}
|
||||
|
||||
// configName is the display name for a link: always the inbound's own remark.
|
||||
@@ -71,6 +71,7 @@ var uiTokenMap = map[string]string{
|
||||
"USAGE_PERCENTAGE": "USAGE_PERCENTAGE",
|
||||
"PROTOCOL": "PROTOCOL",
|
||||
"TRANSPORT": "TRANSPORT",
|
||||
"SECURITY": "SECURITY",
|
||||
}
|
||||
|
||||
// translateUISingleBrackets converts user-friendly single-brace tokens to the
|
||||
@@ -226,6 +227,8 @@ func remarkVarValue(token string, ctx remarkContext) string {
|
||||
return ""
|
||||
case "TRANSPORT":
|
||||
return ctx.transport
|
||||
case "SECURITY":
|
||||
return strings.ToUpper(ctx.security)
|
||||
case "TIME_LEFT":
|
||||
return timeLeftLabel(st.ExpiryTime)
|
||||
case "JALALI_EXPIRE_DATE":
|
||||
@@ -438,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,
|
||||
@@ -458,52 +468,107 @@ func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Cl
|
||||
return model.Client{Email: email}
|
||||
}
|
||||
|
||||
// usageInfoTokens are the per-client status tokens. On every link of a
|
||||
// subscription except the client's first, these (and the decoration leading
|
||||
// into them) are dropped, so the traffic/expiry info shows once instead of on
|
||||
// every server.
|
||||
var usageInfoTokens = []string{
|
||||
"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
|
||||
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
|
||||
"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
|
||||
"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
|
||||
var usageInfoTokens = map[string]bool{
|
||||
"TRAFFIC_USED": true, "TRAFFIC_LEFT": true, "TRAFFIC_TOTAL": true,
|
||||
"TRAFFIC_USED_BYTES": true, "TRAFFIC_LEFT_BYTES": true, "TRAFFIC_TOTAL_BYTES": true,
|
||||
"UP": true, "DOWN": true, "DAYS_LEFT": true, "EXPIRE_DATE": true, "EXPIRE_UNIX": true,
|
||||
"STATUS": true, "STATUS_EMOJI": true, "USAGE_PERCENTAGE": true, "TIME_LEFT": true,
|
||||
"JALALI_EXPIRE_DATE": true,
|
||||
}
|
||||
|
||||
// nameOnlyTemplate returns template with the trailing per-client info part
|
||||
// removed: everything from the first usage token (and the decoration — emojis,
|
||||
// spaces, separators — leading into it) onward is dropped, leaving the config
|
||||
// name. Returns "" when the template is info-only.
|
||||
func nameOnlyTemplate(template string) string {
|
||||
idx := -1
|
||||
for _, tok := range usageInfoTokens {
|
||||
if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
|
||||
idx = i
|
||||
var connectionTokens = map[string]bool{
|
||||
"PROTOCOL": true,
|
||||
"TRANSPORT": true,
|
||||
"SECURITY": true,
|
||||
}
|
||||
|
||||
var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)
|
||||
|
||||
func mergeTokenSets(sets ...map[string]bool) map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, set := range sets {
|
||||
for tok := range set {
|
||||
out[tok] = true
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return template
|
||||
return out
|
||||
}
|
||||
|
||||
func filterRemarkTemplate(template string, remove map[string]bool) string {
|
||||
segments := strings.Split(template, "|")
|
||||
kept := make([]string, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if out := filterRemarkSegment(seg, remove); out != "" {
|
||||
kept = append(kept, out)
|
||||
}
|
||||
}
|
||||
return strings.TrimRightFunc(template[:idx], func(r rune) bool {
|
||||
return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
return strings.Join(kept, "|")
|
||||
}
|
||||
|
||||
func filterRemarkSegment(seg string, remove map[string]bool) string {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
|
||||
hasRemove := false
|
||||
for _, loc := range locs {
|
||||
if remove[seg[loc[2]:loc[3]]] {
|
||||
hasRemove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRemove {
|
||||
return strings.TrimSpace(seg)
|
||||
}
|
||||
runs := make([]string, 0, 2)
|
||||
runStart, leftRemoved := 0, false
|
||||
for _, loc := range locs {
|
||||
if !remove[seg[loc[2]:loc[3]]] {
|
||||
continue
|
||||
}
|
||||
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
|
||||
runStart, leftRemoved = loc[1], true
|
||||
}
|
||||
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
|
||||
return strings.Join(runs, " ")
|
||||
}
|
||||
|
||||
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
|
||||
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
|
||||
if len(locs) == 0 {
|
||||
return runs
|
||||
}
|
||||
start, end := 0, len(run)
|
||||
if leftRemoved {
|
||||
start = locs[0][0]
|
||||
}
|
||||
if rightRemoved {
|
||||
end = locs[len(locs)-1][1]
|
||||
}
|
||||
if frag := strings.TrimSpace(run[start:end]); frag != "" {
|
||||
runs = append(runs, frag)
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// effectiveTemplate picks which template to expand for one body link: the full
|
||||
// template (with the per-client info) for a client's first link, and the
|
||||
// name-only template for every link thereafter — so the info shows once. Only
|
||||
// called in the subscription-body context (displays bypass the template).
|
||||
func (s *SubService) effectiveTemplate(email string) string {
|
||||
translated := translateUISingleBrackets(s.remarkTemplate)
|
||||
if s.usageShown == nil {
|
||||
s.usageShown = map[string]bool{}
|
||||
}
|
||||
if s.usageShown[email] {
|
||||
return nameOnlyTemplate(translated)
|
||||
return filterRemarkTemplate(translated, usageInfoTokens)
|
||||
}
|
||||
s.usageShown[email] = true
|
||||
return translated
|
||||
}
|
||||
|
||||
func inboundSecurity(inbound *model.Inbound) string {
|
||||
if inbound == nil {
|
||||
return ""
|
||||
}
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
security, _ := stream["security"].(string)
|
||||
return security
|
||||
}
|
||||
|
||||
// genTemplatedRemark expands the remark template for one client. hostRemark is
|
||||
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
|
||||
// token only and never substitutes the inbound remark as the config name.
|
||||
@@ -514,23 +579,27 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
|
||||
inbound: inbound,
|
||||
hostRemark: hostRemark,
|
||||
transport: transport,
|
||||
security: inboundSecurity(inbound),
|
||||
}
|
||||
var tmpl string
|
||||
if s.subscriptionBody {
|
||||
tmpl = s.effectiveTemplate(client.Email)
|
||||
} else {
|
||||
tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
|
||||
}
|
||||
tmpl := s.effectiveTemplate(client.Email)
|
||||
// Fall back to the config name when the template is empty or expands to
|
||||
// nothing (e.g. an all-unlimited template whose only segments dropped out).
|
||||
if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
|
||||
return out
|
||||
}
|
||||
return ctx.configName()
|
||||
}
|
||||
|
||||
// genHostRemark builds one host endpoint's remark for a specific client. In the
|
||||
// subscription body the {{HOST}} token carries the host's remark and the rest of
|
||||
// the template still applies; displays show the config name, host and email.
|
||||
// genHostRemark builds one host endpoint's remark for a specific client. With a
|
||||
// remark template set it is template-driven (body shows the full template on the
|
||||
// first link and the name-only part thereafter; displays render the name-only
|
||||
// part). With no template it falls back to inbound, host and email joined by "-".
|
||||
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
|
||||
if !s.subscriptionBody {
|
||||
name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
|
||||
return fallbackRemark(name, hostRemark, client.Email)
|
||||
if s.remarkTemplate != "" {
|
||||
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
|
||||
}
|
||||
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
|
||||
return fallbackRemark(inbound.Remark, hostRemark, client.Email)
|
||||
}
|
||||
|
||||
@@ -164,15 +164,15 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
|
||||
return s, inbound, client
|
||||
}
|
||||
|
||||
// The config name is always the inbound's own remark; the host endpoint's remark
|
||||
// never substitutes it (it is reachable only through {{HOST}}).
|
||||
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("") // no template → config name only
|
||||
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
|
||||
t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
|
||||
// With no template configured, genHostRemark falls back to the inbound remark,
|
||||
// host and email joined by "-".
|
||||
func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("")
|
||||
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE-Relay-john@example.com" {
|
||||
t.Fatalf("genHostRemark = %q, want %q", got, "DE-Relay-john@example.com")
|
||||
}
|
||||
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
|
||||
if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE-john@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,35 +232,157 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemarkInDisplayContext(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||
s.subscriptionBody = false
|
||||
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE-CDN-john@example.com" {
|
||||
t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com")
|
||||
const want = "DE-john@example.com"
|
||||
if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
|
||||
t.Fatalf("display host link = %q, want %q", got, want)
|
||||
}
|
||||
if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
|
||||
t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com")
|
||||
if got := s.genHostRemark(inbound, client, "", ""); got != want {
|
||||
t.Fatalf("display host link (no host) = %q, want %q", got, want)
|
||||
}
|
||||
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE-john@example.com" {
|
||||
t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com")
|
||||
if got := s.genRemark(inbound, client.Email, "", ""); got != want {
|
||||
t.Fatalf("display genRemark = %q, want %q", got, want)
|
||||
}
|
||||
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
|
||||
s2.subscriptionBody = false
|
||||
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
|
||||
t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
|
||||
}
|
||||
}
|
||||
|
||||
// nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
|
||||
func TestNameOnlyTemplate(t *testing.T) {
|
||||
func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // the default → name only
|
||||
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
|
||||
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
|
||||
"{{TRAFFIC_LEFT}}": "", // info only → empty
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",
|
||||
"{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}": "{{INBOUND}} {{PROTOCOL}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
|
||||
"{{TRAFFIC_LEFT}}|{{SECURITY}}": "{{SECURITY}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{INBOUND}}|{{PROTOCOL}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}": "{{INBOUND}}|{{EMAIL}}",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
|
||||
"{{EMAIL}} {{TRAFFIC_USED}}5h": "{{EMAIL}}",
|
||||
"{{PROTOCOL}} {{TRAFFIC_LEFT}}GB": "{{PROTOCOL}}",
|
||||
"{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}": "{{EMAIL}} {{HOST}}",
|
||||
"{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{EMAIL}} {{PROTOCOL}}",
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := nameOnlyTemplate(tmpl); got != want {
|
||||
t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
|
||||
if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
|
||||
t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRemarkTemplate_Display(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
|
||||
"{{INBOUND}} {{PROTOCOL}}": "{{INBOUND}}",
|
||||
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}",
|
||||
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
|
||||
"{{TRAFFIC_LEFT}}": "",
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}": "{{INBOUND}}|{{HOST}}",
|
||||
"{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}": "{{EMAIL}} {{HOST}}",
|
||||
"{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}": "{{INBOUND}} {{EMAIL}}",
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
|
||||
t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
|
||||
subscriptionBody: true,
|
||||
usageShown: map[string]bool{},
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
client := model.Client{Email: "john@x"}
|
||||
first := s.genTemplatedRemark(inbound, client, "", "ws")
|
||||
second := s.genTemplatedRemark(inbound, client, "", "ws")
|
||||
for _, want := range []string{"VLESS", "ws", "TLS"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Fatalf("first body link %q missing %q", first, want)
|
||||
}
|
||||
if !strings.Contains(second, want) {
|
||||
t.Fatalf("repeat body link %q missing connection token %q", second, want)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
|
||||
t.Fatalf("repeat body link must drop the usage block: %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
|
||||
subscriptionBody: true,
|
||||
usageShown: map[string]bool{},
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"grpc","security":"reality"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
client := model.Client{Email: "john@x"}
|
||||
_ = s.genTemplatedRemark(inbound, client, "", "grpc")
|
||||
second := s.genTemplatedRemark(inbound, client, "", "grpc")
|
||||
for _, want := range []string{"VLESS", "grpc", "REALITY"} {
|
||||
if !strings.Contains(second, want) {
|
||||
t.Fatalf("repeat body link %q missing connection token %q", second, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
|
||||
t.Fatalf("repeat body link must drop the usage block: %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
|
||||
subscriptionBody: false,
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
|
||||
t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityTokensEverywhere(t *testing.T) {
|
||||
const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
Protocol: "vless",
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`,
|
||||
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
|
||||
}
|
||||
client := model.Client{Email: "john@x"}
|
||||
|
||||
body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
|
||||
_ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
|
||||
if second := body.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(second, "john@x") {
|
||||
t.Fatalf("repeat body link %q must keep the identity token", second)
|
||||
}
|
||||
|
||||
display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
|
||||
if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
|
||||
t.Fatalf("display remark %q must keep the identity token", got)
|
||||
}
|
||||
}
|
||||
|
||||
// statsForClient resolves usage from the per-request statsByEmail map when the
|
||||
// link's own inbound doesn't carry the client's (globally unique) traffic row —
|
||||
// the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
|
||||
@@ -371,6 +493,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
stats: stats,
|
||||
inbound: inbound,
|
||||
transport: "ws",
|
||||
security: "reality",
|
||||
}
|
||||
|
||||
cases := []struct{ tmpl, want string }{
|
||||
@@ -378,6 +501,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
{"{{USAGE_PERCENTAGE}}", "50.0%"},
|
||||
{"{{PROTOCOL}}", "VLESS"},
|
||||
{"{{TRANSPORT}}", "ws"},
|
||||
{"{{SECURITY}}", "REALITY"},
|
||||
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -387,6 +511,32 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundSecurity(t *testing.T) {
|
||||
cases := []struct{ stream, want string }{
|
||||
{`{"network":"ws","security":"tls"}`, "tls"},
|
||||
{`{"network":"tcp","security":"reality"}`, "reality"},
|
||||
{`{"network":"tcp","security":"none"}`, "none"},
|
||||
{`{"network":"tcp"}`, ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
|
||||
t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := inboundSecurity(nil); got != "" {
|
||||
t.Errorf("inboundSecurity(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
|
||||
s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
|
||||
inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
|
||||
if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
|
||||
t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateUISingleBrackets(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"{EMAIL}", "{{EMAIL}}"},
|
||||
@@ -413,6 +563,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
|
||||
stats: stats,
|
||||
inbound: inbound,
|
||||
transport: "ws",
|
||||
security: "tls",
|
||||
}
|
||||
cases := []struct{ tmpl, want string }{
|
||||
{"{EMAIL}", "alice@test.com"},
|
||||
@@ -423,6 +574,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
|
||||
{"{USAGE_PERCENTAGE}", "50.0%"},
|
||||
{"{PROTOCOL}", "VLESS"},
|
||||
{"{TRANSPORT}", "ws"},
|
||||
{"{SECURITY}", "TLS"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
|
||||
|
||||
+46
-6
@@ -1634,11 +1634,12 @@ func cloneStringMap(source map[string]string) map[string]string {
|
||||
}
|
||||
|
||||
// genRemark builds the remark for a non-host link (raw default / legacy
|
||||
// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
|
||||
// remark template takes over; otherwise (and in every display context) the
|
||||
// remark is just the config name (inbound remark, then extra).
|
||||
// externalProxy / synthetic JSON-Clash entry). A set remark template drives it
|
||||
// in both the body and display contexts (genTemplatedRemark renders the
|
||||
// name-only part on displays); with no template it falls back to the inbound
|
||||
// remark, extra and email joined by "-".
|
||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
|
||||
if s.remarkTemplate != "" && s.subscriptionBody {
|
||||
if s.remarkTemplate != "" {
|
||||
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
|
||||
}
|
||||
return fallbackRemark(inbound.Remark, extra, email)
|
||||
@@ -1664,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:
|
||||
@@ -2336,6 +2363,19 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
||||
datepicker = "gregorian"
|
||||
}
|
||||
|
||||
pageLinks := make([]string, 0, len(subs))
|
||||
pageEmails := make([]string, 0, len(subs))
|
||||
for i, sub := range subs {
|
||||
email := ""
|
||||
if i < len(emails) {
|
||||
email = emails[i]
|
||||
}
|
||||
for _, link := range splitLinkLines(sub) {
|
||||
pageLinks = append(pageLinks, link)
|
||||
pageEmails = append(pageEmails, email)
|
||||
}
|
||||
}
|
||||
|
||||
return PageData{
|
||||
Host: hostHeader,
|
||||
BasePath: basePath,
|
||||
@@ -2357,8 +2397,8 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
||||
SubClashUrl: subClashURL,
|
||||
SubTitle: subTitle,
|
||||
SubSupportUrl: subSupportUrl,
|
||||
Result: subs,
|
||||
Emails: emails,
|
||||
Result: pageLinks,
|
||||
Emails: pageEmails,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "old-client@example.com"
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package tunnelmonitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHealthURL = "https://www.cloudflare.com/cdn-cgi/trace"
|
||||
defaultInterval = 30 * time.Second
|
||||
defaultTimeout = 10 * time.Second
|
||||
defaultFailureThreshold = 3
|
||||
defaultCooldown = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Config controls the optional tunnel health monitor.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
ProxyURL string
|
||||
Interval time.Duration
|
||||
Timeout time.Duration
|
||||
FailureThreshold int
|
||||
Cooldown time.Duration
|
||||
}
|
||||
|
||||
// RecoveryFunc performs recovery after the monitor reaches the configured
|
||||
// failure threshold. The panel wires this to an Xray core restart.
|
||||
type RecoveryFunc func(context.Context) error
|
||||
|
||||
// Monitor periodically probes a URL and triggers recovery after repeated
|
||||
// failures. It is intentionally independent from panel settings/UI so it can be
|
||||
// enabled safely through service environment variables first.
|
||||
type Monitor struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
recover RecoveryFunc
|
||||
failures int
|
||||
lastRecovery time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// DefaultConfig returns disabled-by-default monitor settings.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Enabled: false,
|
||||
URL: defaultHealthURL,
|
||||
Interval: defaultInterval,
|
||||
Timeout: defaultTimeout,
|
||||
FailureThreshold: defaultFailureThreshold,
|
||||
Cooldown: defaultCooldown,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds Config from XUI_TUNNEL_HEALTH_* environment variables.
|
||||
//
|
||||
// Supported variables:
|
||||
// - XUI_TUNNEL_HEALTH_MONITOR=true
|
||||
// - XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
|
||||
// - XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
|
||||
// - XUI_TUNNEL_HEALTH_INTERVAL=30s
|
||||
// - XUI_TUNNEL_HEALTH_TIMEOUT=10s
|
||||
// - XUI_TUNNEL_HEALTH_FAILURES=3
|
||||
// - XUI_TUNNEL_HEALTH_COOLDOWN=5m
|
||||
func ConfigFromEnv() Config {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
cfg.Enabled = parseBool(os.Getenv("XUI_TUNNEL_HEALTH_MONITOR"))
|
||||
cfg.URL = firstNonEmpty(os.Getenv("XUI_TUNNEL_HEALTH_URL"), cfg.URL)
|
||||
cfg.ProxyURL = strings.TrimSpace(os.Getenv("XUI_TUNNEL_HEALTH_PROXY"))
|
||||
cfg.Interval = parseDurationEnv("XUI_TUNNEL_HEALTH_INTERVAL", cfg.Interval)
|
||||
cfg.Timeout = parseDurationEnv("XUI_TUNNEL_HEALTH_TIMEOUT", cfg.Timeout)
|
||||
cfg.Cooldown = parseDurationEnv("XUI_TUNNEL_HEALTH_COOLDOWN", cfg.Cooldown)
|
||||
cfg.FailureThreshold = parseIntEnv("XUI_TUNNEL_HEALTH_FAILURES", cfg.FailureThreshold)
|
||||
|
||||
return cfg.Normalize()
|
||||
}
|
||||
|
||||
// Normalize applies safe bounds and defaults.
|
||||
func (c Config) Normalize() Config {
|
||||
if strings.TrimSpace(c.URL) == "" {
|
||||
c.URL = defaultHealthURL
|
||||
}
|
||||
c.URL = strings.TrimSpace(c.URL)
|
||||
c.ProxyURL = strings.TrimSpace(c.ProxyURL)
|
||||
|
||||
if c.Interval < time.Second {
|
||||
c.Interval = defaultInterval
|
||||
}
|
||||
if c.Timeout < time.Second {
|
||||
c.Timeout = defaultTimeout
|
||||
}
|
||||
if c.FailureThreshold < 1 {
|
||||
c.FailureThreshold = defaultFailureThreshold
|
||||
}
|
||||
if c.Cooldown < time.Second {
|
||||
c.Cooldown = defaultCooldown
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// New creates a monitor with an HTTP client based on cfg.
|
||||
func New(cfg Config, recover RecoveryFunc) (*Monitor, error) {
|
||||
cfg = cfg.Normalize()
|
||||
|
||||
client, err := netproxy.NewHTTPClient(cfg.ProxyURL, cfg.Timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newWithClient(cfg, client, recover), nil
|
||||
}
|
||||
|
||||
func newWithClient(cfg Config, client *http.Client, recover RecoveryFunc) *Monitor {
|
||||
cfg = cfg.Normalize()
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: cfg.Timeout}
|
||||
}
|
||||
|
||||
return &Monitor{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
recover: recover,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the monitor loop until ctx is cancelled.
|
||||
func (m *Monitor) Run(ctx context.Context) {
|
||||
if m == nil || !m.cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Tunnel health monitor enabled: ", m.cfg.URL)
|
||||
|
||||
ticker := time.NewTicker(m.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("Tunnel health monitor stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
recovered, err := m.Step(ctx)
|
||||
if err != nil {
|
||||
logger.Warning("Tunnel health monitor check failed: ", err)
|
||||
}
|
||||
if recovered {
|
||||
logger.Warning("Tunnel health monitor triggered Xray restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step performs one probe and maybe triggers recovery.
|
||||
func (m *Monitor) Step(ctx context.Context) (bool, error) {
|
||||
if m == nil {
|
||||
return false, errors.New("nil monitor")
|
||||
}
|
||||
|
||||
if err := m.probe(ctx); err != nil {
|
||||
m.failures++
|
||||
|
||||
if m.failures < m.cfg.FailureThreshold {
|
||||
return false, fmt.Errorf("probe failed %d/%d: %w", m.failures, m.cfg.FailureThreshold, err)
|
||||
}
|
||||
|
||||
now := m.now()
|
||||
if !m.lastRecovery.IsZero() && now.Sub(m.lastRecovery) < m.cfg.Cooldown {
|
||||
m.failures = m.cfg.FailureThreshold
|
||||
return false, fmt.Errorf("probe failed %d/%d; recovery cooldown active: %w", m.failures, m.cfg.FailureThreshold, err)
|
||||
}
|
||||
|
||||
if m.recover == nil {
|
||||
m.failures = m.cfg.FailureThreshold
|
||||
return false, errors.New("recovery function is not configured")
|
||||
}
|
||||
|
||||
if recErr := m.recover(ctx); recErr != nil {
|
||||
return false, fmt.Errorf("recovery failed after probe error %v: %w", err, recErr)
|
||||
}
|
||||
|
||||
m.lastRecovery = now
|
||||
m.failures = 0
|
||||
return true, err
|
||||
}
|
||||
|
||||
if m.failures > 0 {
|
||||
logger.Info("Tunnel health monitor recovered after successful probe")
|
||||
}
|
||||
m.failures = 0
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *Monitor) probe(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||
return fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "y", "on", "enable", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseDurationEnv(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func parseIntEnv(name string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func firstNonEmpty(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package tunnelmonitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.InitLogger(logging.ERROR)
|
||||
m.Run()
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestMonitorRestartsAfterFailureThreshold(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 2,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("tunnel down")
|
||||
}),
|
||||
}
|
||||
|
||||
restarts := 0
|
||||
monitor := newWithClient(cfg, client, func(ctx context.Context) error {
|
||||
restarts++
|
||||
return nil
|
||||
})
|
||||
|
||||
monitor.now = func() time.Time {
|
||||
return time.Unix(100, 0)
|
||||
}
|
||||
|
||||
if recovered, _ := monitor.Step(context.Background()); recovered {
|
||||
t.Fatal("first failure must not trigger recovery")
|
||||
}
|
||||
|
||||
if recovered, _ := monitor.Step(context.Background()); !recovered {
|
||||
t.Fatal("second consecutive failure should trigger recovery")
|
||||
}
|
||||
|
||||
if restarts != 1 {
|
||||
t.Fatalf("expected 1 recovery, got %d", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorRespectsRecoveryCooldown(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 1,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("tunnel down")
|
||||
}),
|
||||
}
|
||||
|
||||
now := time.Unix(100, 0)
|
||||
restarts := 0
|
||||
|
||||
monitor := newWithClient(cfg, client, func(ctx context.Context) error {
|
||||
restarts++
|
||||
return nil
|
||||
})
|
||||
|
||||
monitor.now = func() time.Time {
|
||||
return now
|
||||
}
|
||||
|
||||
recovered, _ := monitor.Step(context.Background())
|
||||
if !recovered {
|
||||
t.Fatal("first failure should trigger recovery when threshold is 1")
|
||||
}
|
||||
|
||||
recovered, _ = monitor.Step(context.Background())
|
||||
if recovered {
|
||||
t.Fatal("cooldown should suppress immediate second recovery")
|
||||
}
|
||||
|
||||
if restarts != 1 {
|
||||
t.Fatalf("expected 1 recovery during cooldown, got %d", restarts)
|
||||
}
|
||||
|
||||
now = now.Add(time.Minute + time.Second)
|
||||
|
||||
recovered, _ = monitor.Step(context.Background())
|
||||
if !recovered {
|
||||
t.Fatal("recovery should be allowed after cooldown")
|
||||
}
|
||||
|
||||
if restarts != 2 {
|
||||
t.Fatalf("expected 2 recoveries after cooldown, got %d", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorSuccessResetsFailures(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 2,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
fail := true
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if fail {
|
||||
return nil, errors.New("tunnel down")
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
restarts := 0
|
||||
monitor := newWithClient(cfg, client, func(ctx context.Context) error {
|
||||
restarts++
|
||||
return nil
|
||||
})
|
||||
|
||||
_, _ = monitor.Step(context.Background())
|
||||
|
||||
fail = false
|
||||
if recovered, err := monitor.Step(context.Background()); recovered || err != nil {
|
||||
t.Fatalf("successful probe should not recover or fail, recovered=%v err=%v", recovered, err)
|
||||
}
|
||||
|
||||
fail = true
|
||||
if recovered, _ := monitor.Step(context.Background()); recovered {
|
||||
t.Fatal("failure after success should be counted as first failure again")
|
||||
}
|
||||
|
||||
if restarts != 0 {
|
||||
t.Fatalf("expected no recovery, got %d", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromEnvParsesValues(t *testing.T) {
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_MONITOR", "true")
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_URL", "https://example.com/health")
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_PROXY", "socks5://127.0.0.1:1080")
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_INTERVAL", "15s")
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_TIMEOUT", "3s")
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_FAILURES", "4")
|
||||
t.Setenv("XUI_TUNNEL_HEALTH_COOLDOWN", "2m")
|
||||
|
||||
cfg := ConfigFromEnv()
|
||||
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("expected monitor to be enabled")
|
||||
}
|
||||
|
||||
if cfg.URL != "https://example.com/health" {
|
||||
t.Fatalf("unexpected URL: %s", cfg.URL)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.ProxyURL, "socks5://") {
|
||||
t.Fatalf("unexpected proxy URL: %s", cfg.ProxyURL)
|
||||
}
|
||||
|
||||
if cfg.Interval != 15*time.Second {
|
||||
t.Fatalf("unexpected interval: %s", cfg.Interval)
|
||||
}
|
||||
|
||||
if cfg.Timeout != 3*time.Second {
|
||||
t.Fatalf("unexpected timeout: %s", cfg.Timeout)
|
||||
}
|
||||
|
||||
if cfg.FailureThreshold != 4 {
|
||||
t.Fatalf("unexpected threshold: %d", cfg.FailureThreshold)
|
||||
}
|
||||
|
||||
if cfg.Cooldown != 2*time.Minute {
|
||||
t.Fatalf("unexpected cooldown: %s", cfg.Cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
func failingClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("tunnel down")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func statusClient(code int) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: code, Body: http.NoBody}, nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeStatusCodeClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
healthy bool
|
||||
}{
|
||||
{199, false},
|
||||
{200, true},
|
||||
{204, true},
|
||||
{301, true},
|
||||
{399, true},
|
||||
{400, false},
|
||||
{404, false},
|
||||
{500, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 100,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
monitor := newWithClient(cfg, statusClient(tc.status), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
recovered, err := monitor.Step(context.Background())
|
||||
if recovered {
|
||||
t.Fatalf("status %d: unexpected recovery", tc.status)
|
||||
}
|
||||
if tc.healthy && err != nil {
|
||||
t.Fatalf("status %d: expected healthy probe, got error %v", tc.status, err)
|
||||
}
|
||||
if !tc.healthy && err == nil {
|
||||
t.Fatalf("status %d: expected failure, got nil error", tc.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClampsBounds(t *testing.T) {
|
||||
got := Config{
|
||||
URL: " ",
|
||||
Interval: 0,
|
||||
Timeout: 500 * time.Millisecond,
|
||||
FailureThreshold: 0,
|
||||
Cooldown: 0,
|
||||
}.Normalize()
|
||||
|
||||
if got.URL != defaultHealthURL {
|
||||
t.Fatalf("URL not defaulted: %q", got.URL)
|
||||
}
|
||||
if got.Interval != defaultInterval {
|
||||
t.Fatalf("Interval not clamped: %s", got.Interval)
|
||||
}
|
||||
if got.Timeout != defaultTimeout {
|
||||
t.Fatalf("Timeout not clamped: %s", got.Timeout)
|
||||
}
|
||||
if got.FailureThreshold != defaultFailureThreshold {
|
||||
t.Fatalf("FailureThreshold not clamped: %d", got.FailureThreshold)
|
||||
}
|
||||
if got.Cooldown != defaultCooldown {
|
||||
t.Fatalf("Cooldown not clamped: %s", got.Cooldown)
|
||||
}
|
||||
|
||||
valid := Config{
|
||||
URL: "https://example.com/health",
|
||||
Interval: 15 * time.Second,
|
||||
Timeout: 3 * time.Second,
|
||||
FailureThreshold: 5,
|
||||
Cooldown: 2 * time.Minute,
|
||||
}.Normalize()
|
||||
|
||||
if valid.URL != "https://example.com/health" ||
|
||||
valid.Interval != 15*time.Second ||
|
||||
valid.Timeout != 3*time.Second ||
|
||||
valid.FailureThreshold != 5 ||
|
||||
valid.Cooldown != 2*time.Minute {
|
||||
t.Fatalf("valid config was mutated: %+v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsUnsupportedProxyScheme(t *testing.T) {
|
||||
m, err := New(Config{ProxyURL: "ftp://127.0.0.1:21"}, func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil || m != nil {
|
||||
t.Fatalf("expected error and nil monitor for bad scheme, got m=%v err=%v", m, err)
|
||||
}
|
||||
|
||||
m, err = New(Config{}, func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil || m == nil {
|
||||
t.Fatalf("expected a valid monitor for empty proxy, got m=%v err=%v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorRecoveryErrorDoesNotArmCooldown(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 1,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
attempts := 0
|
||||
monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
|
||||
attempts++
|
||||
return errors.New("restart failed")
|
||||
})
|
||||
monitor.now = func() time.Time {
|
||||
return time.Unix(100, 0)
|
||||
}
|
||||
|
||||
recovered, err := monitor.Step(context.Background())
|
||||
if recovered || err == nil {
|
||||
t.Fatalf("failed recovery must report recovered=false with an error, got recovered=%v err=%v", recovered, err)
|
||||
}
|
||||
if !monitor.lastRecovery.IsZero() {
|
||||
t.Fatal("a failed recovery must not arm the cooldown")
|
||||
}
|
||||
|
||||
if _, err := monitor.Step(context.Background()); err == nil {
|
||||
t.Fatal("expected error on the second failing step")
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("recovery should be retried (no cooldown) after a failure, attempts=%d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorNilRecoverStaysBounded(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 2,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
monitor := newWithClient(cfg, failingClient(), nil)
|
||||
|
||||
for range 5 {
|
||||
recovered, _ := monitor.Step(context.Background())
|
||||
if recovered {
|
||||
t.Fatal("a nil recovery func must never report recovery")
|
||||
}
|
||||
if monitor.failures > cfg.FailureThreshold {
|
||||
t.Fatalf("failures must stay capped at threshold %d, got %d", cfg.FailureThreshold, monitor.failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorFailuresCappedDuringCooldown(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Interval: time.Minute,
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 2,
|
||||
Cooldown: time.Minute,
|
||||
}
|
||||
|
||||
restarts := 0
|
||||
monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
|
||||
restarts++
|
||||
return nil
|
||||
})
|
||||
monitor.now = func() time.Time {
|
||||
return time.Unix(100, 0)
|
||||
}
|
||||
|
||||
monitor.Step(context.Background())
|
||||
if recovered, _ := monitor.Step(context.Background()); !recovered {
|
||||
t.Fatal("expected recovery once the threshold is reached")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if restarts != 1 {
|
||||
t.Fatalf("cooldown should suppress further recoveries, restarts=%d", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorRunStopsOnContextCancel(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
URL: "http://example.test",
|
||||
Timeout: time.Second,
|
||||
FailureThreshold: 1,
|
||||
Cooldown: time.Hour,
|
||||
}
|
||||
|
||||
recovered := make(chan struct{})
|
||||
var once sync.Once
|
||||
monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
|
||||
once.Do(func() { close(recovered) })
|
||||
return nil
|
||||
})
|
||||
monitor.cfg.Interval = 5 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
monitor.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-recovered:
|
||||
case <-time.After(2 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("Run did not trigger recovery within the deadline")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after context cancellation")
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,59 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
)
|
||||
|
||||
// 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
|
||||
const (
|
||||
memLimitHeadroomPercent = 90
|
||||
defaultGCPercent = 75
|
||||
defaultReleaseMinutes = 10
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,9 +2,9 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
htmlpkg "html"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
)
|
||||
|
||||
var distFS embed.FS
|
||||
var distFS fs.FS
|
||||
|
||||
func SetDistFS(fs embed.FS) {
|
||||
distFS = fs
|
||||
func SetDistFS(fsys fs.FS) {
|
||||
distFS = fsys
|
||||
}
|
||||
|
||||
var distPageBuildTime = time.Now()
|
||||
@@ -30,7 +30,7 @@ var distPageBuildTime = time.Now()
|
||||
// produced at frontend build time by scripts/build-openapi.mjs and
|
||||
// embedded into the binary via the dist FS.
|
||||
func ServeOpenAPISpec(c *gin.Context) {
|
||||
body, err := distFS.ReadFile("dist/openapi.json")
|
||||
body, err := fs.ReadFile(distFS, "dist/openapi.json")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
||||
return
|
||||
@@ -72,7 +72,7 @@ func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func serveDistPage(c *gin.Context, name string) {
|
||||
body, err := distFS.ReadFile("dist/" + name)
|
||||
body, err := fs.ReadFile(distFS, "dist/"+name)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
|
||||
return
|
||||
@@ -112,7 +112,7 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
}
|
||||
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
if name != "login.html" {
|
||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||
escapedVer := jsEscape.Replace(config.GetPanelVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
|
||||
}
|
||||
|
||||
@@ -318,6 +318,7 @@ func (a *NodeController) probe(c *gin.Context) {
|
||||
func (a *NodeController) updatePanel(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids"`
|
||||
Dev bool `json:"dev"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
@@ -327,7 +328,7 @@ func (a *NodeController) updatePanel(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
|
||||
return
|
||||
}
|
||||
results, err := a.nodeService.UpdatePanels(req.Ids)
|
||||
results, err := a.nodeService.UpdatePanels(req.Ids, req.Dev)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/restartXrayService", a.restartXrayService)
|
||||
g.POST("/installXray/:version", a.installXray)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.POST("/setUpdateChannel", a.setUpdateChannel)
|
||||
g.POST("/updateGeofile", a.updateGeofile)
|
||||
g.POST("/updateGeofile/:fileName", a.updateGeofile)
|
||||
g.POST("/logs/:count", a.getLogs)
|
||||
@@ -205,12 +206,36 @@ func (a *ServerController) installXray(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
|
||||
}
|
||||
|
||||
// updatePanel starts a panel self-update to the latest release.
|
||||
// updatePanel starts a panel self-update. With no "dev" form value it follows
|
||||
// this panel's own channel setting; an explicit "dev" (sent by the master node
|
||||
// updater) overrides it for this run.
|
||||
func (a *ServerController) updatePanel(c *gin.Context) {
|
||||
err := a.panelService.StartUpdate()
|
||||
devParam := c.PostForm("dev")
|
||||
var err error
|
||||
if devParam == "" {
|
||||
err = a.panelService.StartUpdate()
|
||||
} else {
|
||||
dev, perr := strconv.ParseBool(devParam)
|
||||
if perr != nil {
|
||||
jsonMsg(c, "invalid data", perr)
|
||||
return
|
||||
}
|
||||
err = a.panelService.StartUpdateChannel(dev)
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
|
||||
}
|
||||
|
||||
// setUpdateChannel toggles whether self-update tracks the rolling dev release.
|
||||
func (a *ServerController) setUpdateChannel(c *gin.Context) {
|
||||
dev, err := strconv.ParseBool(c.PostForm("dev"))
|
||||
if err != nil {
|
||||
jsonMsg(c, "invalid data", err)
|
||||
return
|
||||
}
|
||||
err = a.settingService.SetDevChannelEnable(dev)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
|
||||
}
|
||||
|
||||
// updateGeofile updates the specified geo file for Xray.
|
||||
func (a *ServerController) updateGeofile(c *gin.Context) {
|
||||
fileName := c.Param("fileName")
|
||||
|
||||
@@ -88,6 +88,10 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
}
|
||||
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
||||
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
|
||||
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
|
||||
oldTgToken, _ := a.settingService.GetTgBotToken()
|
||||
oldTgChatId, _ := a.settingService.GetTgBotChatId()
|
||||
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
|
||||
err := a.settingService.UpdateAllSetting(allSetting)
|
||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||
@@ -102,6 +106,16 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
logger.Warning("apply panel outbound change failed:", applyErr)
|
||||
}
|
||||
}
|
||||
// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
|
||||
if err == nil && reloadTgbotFunc != nil {
|
||||
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
|
||||
(allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken ||
|
||||
oldTgChatId != allSetting.TgBotChatId ||
|
||||
oldTgAPIServer != allSetting.TgBotAPIServer))
|
||||
if tgChanged {
|
||||
reloadTgbotFunc()
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
}
|
||||
|
||||
@@ -252,6 +266,11 @@ var testTgFunc func() error
|
||||
// SetTestTgFunc registers the function used to test Telegram sending.
|
||||
func SetTestTgFunc(fn func() error) { testTgFunc = fn }
|
||||
|
||||
// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency.
|
||||
var reloadTgbotFunc func()
|
||||
|
||||
func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn }
|
||||
|
||||
// emailService is set from web layer.
|
||||
var emailService *email.EmailService
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
@@ -57,6 +59,85 @@ func (a *XUIController) panelSPA(c *gin.Context) {
|
||||
serveDistPage(c, "index.html")
|
||||
}
|
||||
|
||||
// HandleNoRoutePanelSPA serves the React shell for client-side routes that were
|
||||
// not explicitly registered in Gin. It intentionally runs from engine.NoRoute
|
||||
// instead of a /panel/*path wildcard so explicit JSON/API routes keep their
|
||||
// normal routing semantics.
|
||||
func (a *XUIController) HandleNoRoutePanelSPA(c *gin.Context) bool {
|
||||
if !isPanelSPAFallbackRequest(c) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !session.IsLogin(c) {
|
||||
if isAjax(c) {
|
||||
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
}
|
||||
c.Abort()
|
||||
return true
|
||||
}
|
||||
|
||||
a.panelSPA(c)
|
||||
return true
|
||||
}
|
||||
|
||||
func isPanelSPAFallbackRequest(c *gin.Context) bool {
|
||||
if c.Request.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
if !acceptsHTML(c.GetHeader("Accept")) {
|
||||
return false
|
||||
}
|
||||
|
||||
basePath := c.GetString("base_path")
|
||||
if basePath == "" {
|
||||
basePath = "/"
|
||||
}
|
||||
panelPath := strings.TrimRight(basePath, "/") + "/panel"
|
||||
|
||||
reqPath := c.Request.URL.Path
|
||||
if reqPath != panelPath && !strings.HasPrefix(reqPath, panelPath+"/") {
|
||||
return false
|
||||
}
|
||||
|
||||
if reqPath == panelPath+"/csrf-token" || strings.HasPrefix(reqPath, panelPath+"/csrf-token/") {
|
||||
return false
|
||||
}
|
||||
if reqPath == panelPath+"/api" || strings.HasPrefix(reqPath, panelPath+"/api/") {
|
||||
return false
|
||||
}
|
||||
if isStaticAssetPath(reqPath) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var staticAssetExts = map[string]struct{}{
|
||||
".js": {}, ".mjs": {}, ".cjs": {}, ".css": {}, ".map": {}, ".json": {},
|
||||
".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {}, ".svg": {}, ".ico": {},
|
||||
".webp": {}, ".avif": {}, ".woff": {}, ".woff2": {}, ".ttf": {}, ".eot": {},
|
||||
".otf": {}, ".wasm": {}, ".txt": {}, ".xml": {}, ".webmanifest": {},
|
||||
}
|
||||
|
||||
func isStaticAssetPath(reqPath string) bool {
|
||||
ext := strings.ToLower(path.Ext(reqPath))
|
||||
if ext == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := staticAssetExts[ext]
|
||||
return ok
|
||||
}
|
||||
|
||||
func acceptsHTML(accept string) bool {
|
||||
if accept == "" {
|
||||
return true
|
||||
}
|
||||
accept = strings.ToLower(accept)
|
||||
return strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*")
|
||||
}
|
||||
|
||||
// csrfToken returns the session CSRF token to authenticated SPA clients.
|
||||
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
|
||||
// but checkLogin still gates the response — anonymous callers get 401/redirect.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
)
|
||||
|
||||
func newSPAFallbackTestEngine(t *testing.T) *gin.Engine {
|
||||
return newSPAFallbackTestEngineWithBasePath(t, "/admin-random/")
|
||||
}
|
||||
|
||||
func newSPAFallbackTestEngineWithBasePath(t *testing.T, basePath string) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
oldDistFS := distFS
|
||||
SetDistFS(fstest.MapFS{
|
||||
"dist/index.html": {Data: []byte(`<!doctype html><html><head></head><body>spa shell</body></html>`)},
|
||||
})
|
||||
t.Cleanup(func() { SetDistFS(oldDistFS) })
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("3x-ui", cookie.NewStore([]byte("spa-fallback-test-secret"))))
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("base_path", basePath)
|
||||
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||
if c.GetHeader("X-Test-Login") == "1" {
|
||||
session.SetAPIAuthUser(c, &model.User{Id: 1, Username: "test"})
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
ctrl := NewXUIController(engine.Group(basePath))
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if ctrl.HandleNoRoutePanelSPA(c) {
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestPanelSPAFallbackServesRootBasePath(t *testing.T) {
|
||||
engine := newSPAFallbackTestEngineWithBasePath(t, "/")
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/hosts", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("X-Test-Login", "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "spa shell") {
|
||||
t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelSPAFallbackServesAuthenticatedClientRoutes(t *testing.T) {
|
||||
engine := newSPAFallbackTestEngine(t)
|
||||
|
||||
for _, target := range []string{
|
||||
"/admin-random/panel/hosts",
|
||||
"/admin-random/panel/some/future/route",
|
||||
} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("X-Test-Login", "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "spa shell") {
|
||||
t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelSPAFallbackPreservesAuthSemantics(t *testing.T) {
|
||||
engine := newSPAFallbackTestEngine(t)
|
||||
|
||||
t.Run("browser redirects to login", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("status = %d, want 307", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/admin-random/" {
|
||||
t.Fatalf("Location = %q, want /admin-random/", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ajax gets json unauthorized", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPanelSPAFallbackExclusions(t *testing.T) {
|
||||
engine := newSPAFallbackTestEngine(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
target string
|
||||
want int
|
||||
}{
|
||||
{target: "/admin-random/panel/api", want: http.StatusNotFound},
|
||||
{target: "/admin-random/panel/api/unknown", want: http.StatusNotFound},
|
||||
{target: "/admin-random/panel/csrf-token/", want: http.StatusMovedPermanently},
|
||||
{target: "/admin-random/panel/missing.js", want: http.StatusNotFound},
|
||||
} {
|
||||
t.Run(tc.target, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.target, nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("X-Test-Login", "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "spa shell") {
|
||||
t.Fatalf("excluded route was served by SPA fallback: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelCSRFTokenRemainsExplicit(t *testing.T) {
|
||||
engine := newSPAFallbackTestEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/csrf-token", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("X-Test-Login", "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "spa shell") {
|
||||
t.Fatalf("csrf-token was served by SPA fallback: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelSPAFallbackPredicate(t *testing.T) {
|
||||
oldDistFS := distFS
|
||||
SetDistFS(fstest.MapFS{})
|
||||
t.Cleanup(func() { SetDistFS(oldDistFS) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
accept string
|
||||
want bool
|
||||
}{
|
||||
{name: "panel root", method: http.MethodGet, path: "/admin-random/panel", accept: "text/html", want: true},
|
||||
{name: "panel descendant", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "*/*", want: true},
|
||||
{name: "empty accept", method: http.MethodGet, path: "/admin-random/panel/future", want: true},
|
||||
{name: "post excluded", method: http.MethodPost, path: "/admin-random/panel/hosts", accept: "text/html"},
|
||||
{name: "json accept excluded", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "application/json"},
|
||||
{name: "api root excluded", method: http.MethodGet, path: "/admin-random/panel/api", accept: "text/html"},
|
||||
{name: "api descendant excluded", method: http.MethodGet, path: "/admin-random/panel/api/unknown", accept: "text/html"},
|
||||
{name: "csrf excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token", accept: "text/html"},
|
||||
{name: "csrf descendant excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token/", accept: "text/html"},
|
||||
{name: "file excluded", method: http.MethodGet, path: "/admin-random/panel/missing.css", accept: "text/html"},
|
||||
{name: "outside panel excluded", method: http.MethodGet, path: "/admin-random/hosts", accept: "text/html"},
|
||||
{name: "dotted email route param served", method: http.MethodGet, path: "/admin-random/panel/clients/user@example.com", accept: "text/html", want: true},
|
||||
{name: "dotted version route param served", method: http.MethodGet, path: "/admin-random/panel/sub/1.2.3", accept: "text/html", want: true},
|
||||
{name: "uppercase asset extension excluded", method: http.MethodGet, path: "/admin-random/panel/app.JS", accept: "text/html"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set("base_path", "/admin-random/")
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if tc.accept != "" {
|
||||
req.Header.Set("Accept", tc.accept)
|
||||
}
|
||||
c.Request = req
|
||||
|
||||
if got := isPanelSPAFallbackRequest(c); got != tc.want {
|
||||
t.Fatalf("isPanelSPAFallbackRequest() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -538,9 +538,14 @@ func (r *Remote) RestartXray(ctx context.Context) error {
|
||||
|
||||
// UpdatePanel asks the node to run its own official self-updater (update.sh)
|
||||
// and restart onto the latest release. The node returns as soon as the job is
|
||||
// launched; the new version surfaces on the next heartbeat.
|
||||
func (r *Remote) UpdatePanel(ctx context.Context) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", nil)
|
||||
// launched; the new version surfaces on the next heartbeat. When dev is true the
|
||||
// node is moved to the rolling dev channel instead of the latest stable release.
|
||||
func (r *Remote) UpdatePanel(ctx context.Context, dev bool) error {
|
||||
var body any
|
||||
if dev {
|
||||
body = url.Values{"dev": {"true"}}
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", body)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -787,9 +787,12 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
delStat = traffic != nil
|
||||
}
|
||||
|
||||
// The runtime user is scoped to this inbound's tag + email, so the push plan
|
||||
// is resolved independently of emailShared — a sibling inbound still carrying
|
||||
// the email must not suppress removing the user from this inbound's Xray.
|
||||
var rt runtime.Runtime
|
||||
var push bool
|
||||
if len(email) > 0 && !emailShared && (oldInbound.NodeID != nil || needApiDel) {
|
||||
if len(email) > 0 && (oldInbound.NodeID != nil || needApiDel) {
|
||||
r, p, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||
if perr != nil {
|
||||
return false, perr
|
||||
@@ -828,8 +831,10 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
}
|
||||
|
||||
// Apply the runtime delete after commit — outside the serialized writer so a
|
||||
// slow node call can't stall traffic accounting.
|
||||
if len(email) > 0 && !emailShared {
|
||||
// slow node call can't stall traffic accounting. Independent of emailShared:
|
||||
// Xray users are keyed by inbound tag, so the user must be removed from this
|
||||
// inbound's runtime even when the same email survives in another inbound.
|
||||
if len(email) > 0 {
|
||||
if oldInbound.NodeID == nil {
|
||||
// Local inbound: a disabled client isn't in the running Xray, so only
|
||||
// a live one (needApiDel) needs an API removal.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Deleting a client that is attached to more than one inbound must still remove
|
||||
// the user from the running runtime of the inbound being deleted from. The
|
||||
// runtime user is keyed by inbound tag, so a sibling inbound still carrying the
|
||||
// same email (emailShared) must not suppress the per-inbound runtime removal —
|
||||
// otherwise the deleted user keeps connecting on that inbound until Xray
|
||||
// restart (#5543).
|
||||
func TestDelInboundClientByEmail_SharedEmailStillRemovesFromRuntime(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
nodeID, fake := setupNodeRuntime(t)
|
||||
|
||||
shared := []model.Client{{ID: uuid.NewString(), Email: "shared@x", Enable: true}}
|
||||
ibA := nodeInbound(t, nodeID, 31001, shared)
|
||||
nodeInbound(t, nodeID, 31002, shared)
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
if _, err := svc.DelInboundClientByEmail(inboundSvc, ibA.Id, "shared@x", false); err != nil {
|
||||
t.Fatalf("DelInboundClientByEmail: %v", err)
|
||||
}
|
||||
|
||||
if got := fake.deleteUser.Load(); got != 1 {
|
||||
t.Fatalf("shared-email delete dispatched %d DeleteUser RPCs, want 1 (must remove from the deleted inbound's runtime despite the sibling inbound) (#5543)", got)
|
||||
}
|
||||
}
|
||||
@@ -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}).
|
||||
|
||||
@@ -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
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
func newMetricHistory() *metricHistory {
|
||||
return &metricHistory{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
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
|
||||
func (tb *tierBuf) add(unixSec int64, v float64) {
|
||||
res := int64(tb.resolution)
|
||||
b := (unixSec / res) * res
|
||||
if tb.open && b != tb.openStart {
|
||||
tb.flush()
|
||||
}
|
||||
if len(buf) > metricCapacityDefault {
|
||||
buf = buf[len(buf)-metricCapacityDefault:]
|
||||
tb.open = true
|
||||
tb.openStart = b
|
||||
tb.openSum += v
|
||||
tb.openCount++
|
||||
}
|
||||
|
||||
func (tb *tierBuf) flush() {
|
||||
if tb.openCount == 0 {
|
||||
tb.open = false
|
||||
return
|
||||
}
|
||||
h.metrics[metric] = buf
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
||||
if bucketSeconds <= 0 || maxPoints <= 0 {
|
||||
return []map[string]any{}
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
|
||||
|
||||
h.mu.Lock()
|
||||
hist := h.metrics[metric]
|
||||
startIdx := 0
|
||||
for i, h := range slices.Backward(hist) {
|
||||
if h.T < cutoff {
|
||||
startIdx = i + 1
|
||||
break
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if startIdx >= len(hist) {
|
||||
h.mu.Unlock()
|
||||
return []map[string]any{}
|
||||
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()
|
||||
s := h.series[metric]
|
||||
if s == nil {
|
||||
s = newSeries()
|
||||
h.series[metric] = s
|
||||
}
|
||||
tmp := make([]MetricSample, len(hist)-startIdx)
|
||||
copy(tmp, hist[startIdx:])
|
||||
s.add(t.Unix(), v)
|
||||
}
|
||||
|
||||
// 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 empty
|
||||
}
|
||||
span := int64(bucketSeconds) * int64(maxPoints)
|
||||
cutoff := time.Now().Unix() - span
|
||||
|
||||
h.mu.Lock()
|
||||
s := h.series[metric]
|
||||
if s == nil {
|
||||
h.mu.Unlock()
|
||||
return empty
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
systemMetrics.restore(data)
|
||||
if migrateLegacySystemMetrics(path) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -637,7 +637,7 @@ type NodeUpdateResult struct {
|
||||
// UpdatePanels triggers the official self-updater on each given node. Only
|
||||
// enabled, online nodes are eligible — an offline node can't be reached, so it
|
||||
// is reported as skipped rather than silently dropped.
|
||||
func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
|
||||
func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, error) {
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("runtime manager unavailable")
|
||||
@@ -662,7 +662,7 @@ func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
|
||||
break
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
updErr := remote.UpdatePanel(ctx)
|
||||
updErr := remote.UpdatePanel(ctx, dev)
|
||||
cancel()
|
||||
if updErr != nil {
|
||||
res.Error = updErr.Error()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -25,17 +26,27 @@ import (
|
||||
type PanelService struct{}
|
||||
|
||||
// PanelUpdateInfo contains the current and latest available panel versions.
|
||||
// On the dev channel the version fields carry a "dev+<sha>" label and the commit
|
||||
// fields hold the short SHAs that drive the update-available decision.
|
||||
type PanelUpdateInfo struct {
|
||||
Channel string `json:"channel"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
CurrentCommit string `json:"currentCommit,omitempty"`
|
||||
LatestCommit string `json:"latestCommit,omitempty"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
const (
|
||||
panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
|
||||
maxPanelUpdaterBytes = 2 << 20
|
||||
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
|
||||
// newest main commit; the dev update channel installs from it.
|
||||
devReleaseTag = "dev-latest"
|
||||
)
|
||||
|
||||
var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
|
||||
|
||||
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||
go func() {
|
||||
time.Sleep(delay)
|
||||
@@ -58,22 +69,72 @@ func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpdateInfo checks GitHub for the latest 3x-ui release.
|
||||
// GetUpdateInfo checks GitHub for the latest 3x-ui release. When the dev channel
|
||||
// is enabled on a dev build it compares commits against the rolling dev release;
|
||||
// otherwise it compares versions against the latest stable tag.
|
||||
func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
if devChannelActive() {
|
||||
return getDevUpdateInfo()
|
||||
}
|
||||
latest, err := fetchLatestPanelVersion()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := config.GetVersion()
|
||||
current := config.GetBaseVersion()
|
||||
return &PanelUpdateInfo{
|
||||
Channel: "stable",
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
UpdateAvailable: isNewerVersion(latest, current),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartUpdate starts the official updater outside of the current web request.
|
||||
// devChannelActive reports whether self-update should track the rolling dev
|
||||
// release. It requires both the opt-in setting and a dev build, so a stable
|
||||
// binary with the toggle left on never cross-grades to the dev channel.
|
||||
func devChannelActive() bool {
|
||||
if !config.IsDevBuild() {
|
||||
return false
|
||||
}
|
||||
enabled, err := (&service.SettingService{}).GetDevChannelEnable()
|
||||
return err == nil && enabled
|
||||
}
|
||||
|
||||
// getDevUpdateInfo compares the running commit against the commit recorded in the
|
||||
// rolling dev release.
|
||||
func getDevUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
release, err := fetchPanelRelease(devReleaseTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latestCommit := extractReleaseCommit(release)
|
||||
if latestCommit == "" {
|
||||
return nil, fmt.Errorf("dev release commit is unknown")
|
||||
}
|
||||
currentCommit := config.GetBuildCommit()
|
||||
return &PanelUpdateInfo{
|
||||
Channel: "dev",
|
||||
CurrentVersion: config.GetPanelVersion(),
|
||||
CurrentCommit: shortCommit(currentCommit),
|
||||
LatestCommit: shortCommit(latestCommit),
|
||||
LatestVersion: "dev+" + shortCommit(latestCommit),
|
||||
UpdateAvailable: !commitsEqual(currentCommit, latestCommit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartUpdate starts the official updater using this panel's own channel setting.
|
||||
func (s *PanelService) StartUpdate() error {
|
||||
return s.startUpdate(devChannelActive())
|
||||
}
|
||||
|
||||
// StartUpdateChannel runs the updater against an explicitly chosen channel,
|
||||
// overriding the local dev-channel setting. Used by the master node updater so
|
||||
// a node can be moved to the dev channel from the central panel.
|
||||
func (s *PanelService) StartUpdateChannel(dev bool) error {
|
||||
return s.startUpdate(dev)
|
||||
}
|
||||
|
||||
func (s *PanelService) startUpdate(useDev bool) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("panel web update is supported only on Linux installations")
|
||||
}
|
||||
@@ -89,6 +150,10 @@ func (s *PanelService) StartUpdate() error {
|
||||
}
|
||||
|
||||
mainFolder, serviceFolder := resolveUpdateFolders()
|
||||
updateTag := ""
|
||||
if useDev {
|
||||
updateTag = devReleaseTag
|
||||
}
|
||||
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
|
||||
|
||||
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
|
||||
@@ -97,6 +162,7 @@ func (s *PanelService) StartUpdate() error {
|
||||
"--unit", unitName,
|
||||
"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
|
||||
"--setenv", "XUI_SERVICE="+serviceFolder,
|
||||
"--setenv", "XUI_UPDATE_TAG="+updateTag,
|
||||
bash, "-lc", updateScript,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
@@ -118,6 +184,7 @@ func (s *PanelService) StartUpdate() error {
|
||||
cmd.Env = append(os.Environ(),
|
||||
"XUI_MAIN_FOLDER="+mainFolder,
|
||||
"XUI_SERVICE="+serviceFolder,
|
||||
"XUI_UPDATE_TAG="+updateTag,
|
||||
)
|
||||
setDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -170,26 +237,88 @@ func downloadPanelUpdater() (string, error) {
|
||||
}
|
||||
|
||||
func fetchLatestPanelVersion() (string, error) {
|
||||
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
|
||||
resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
|
||||
release, err := fetchPanelRelease("")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var release service.Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if release.TagName == "" {
|
||||
return "", fmt.Errorf("latest panel release tag is empty")
|
||||
}
|
||||
return release.TagName, nil
|
||||
}
|
||||
|
||||
// fetchPanelRelease fetches a release from GitHub. An empty tag resolves the
|
||||
// latest stable release; a non-empty tag (e.g. dev-latest) resolves that tag.
|
||||
func fetchPanelRelease(tag string) (*service.Release, error) {
|
||||
url := "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest"
|
||||
if tag != "" {
|
||||
url = "https://api.github.com/repos/MHSanaei/3x-ui/releases/tags/" + tag
|
||||
}
|
||||
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var release service.Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// extractReleaseCommit reads the build commit recorded in the dev release: first
|
||||
// the `commit=<sha>` marker the CI writes into the body, falling back to the
|
||||
// tag's target commit.
|
||||
func extractReleaseCommit(release *service.Release) string {
|
||||
if m := releaseCommitRegex.FindStringSubmatch(release.Body); m != nil {
|
||||
return strings.ToLower(m[1])
|
||||
}
|
||||
if isCommitSHA(release.TargetCommitish) {
|
||||
return strings.ToLower(release.TargetCommitish)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCommitSHA(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 7 || len(s) > 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func shortCommit(sha string) string {
|
||||
sha = strings.TrimSpace(sha)
|
||||
if len(sha) > 8 {
|
||||
return sha[:8]
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// commitsEqual compares a short (injected) commit against a full release commit
|
||||
// by prefix, so an 8-char build stamp matches the 40-char release SHA.
|
||||
func commitsEqual(a, b string) bool {
|
||||
a = strings.ToLower(strings.TrimSpace(a))
|
||||
b = strings.ToLower(strings.TrimSpace(b))
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
if len(a) > len(b) {
|
||||
a, b = b, a
|
||||
}
|
||||
return strings.HasPrefix(b, a)
|
||||
}
|
||||
|
||||
func resolveUpdateFolders() (string, string) {
|
||||
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
||||
if mainFolder == "" {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package panel
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestIsNewerVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -39,3 +43,67 @@ func TestShellQuote(t *testing.T) {
|
||||
t.Fatalf("unexpected quote result with single quote: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReleaseCommit(t *testing.T) {
|
||||
full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
|
||||
cases := []struct {
|
||||
name string
|
||||
release service.Release
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "from body marker",
|
||||
release: service.Release{Body: "Rolling build\n\ncommit=" + full + "\nbuilt=2026-06-24T00:00:00Z"},
|
||||
want: full,
|
||||
},
|
||||
{
|
||||
name: "body marker is case-insensitive and wins over target",
|
||||
release: service.Release{Body: "COMMIT=" + full, TargetCommitish: "deadbeef"},
|
||||
want: full,
|
||||
},
|
||||
{
|
||||
name: "fallback to target commit sha",
|
||||
release: service.Release{Body: "no marker here", TargetCommitish: full},
|
||||
want: full,
|
||||
},
|
||||
{
|
||||
name: "branch target is not a commit",
|
||||
release: service.Release{Body: "no marker", TargetCommitish: "main"},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := extractReleaseCommit(&tc.release); got != tc.want {
|
||||
t.Fatalf("%s: extractReleaseCommit = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsEqual(t *testing.T) {
|
||||
full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"1a2b3c4d", full, true}, // injected 8-char prefix matches full release sha
|
||||
{full, "1a2b3c4d", true}, // order independent
|
||||
{"1A2B3C4D", full, true}, // case insensitive
|
||||
{"deadbeef", full, false}, // different commit
|
||||
{"", full, false}, // empty current never matches
|
||||
{"1a2b3c4d", "", false}, // empty latest never matches
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := commitsEqual(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("commitsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortCommit(t *testing.T) {
|
||||
if got := shortCommit("1a2b3c4d5e6f7a8b"); got != "1a2b3c4d" {
|
||||
t.Fatalf("shortCommit truncation = %q, want %q", got, "1a2b3c4d")
|
||||
}
|
||||
if got := shortCommit("abc"); got != "abc" {
|
||||
t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,10 @@ type Status struct {
|
||||
|
||||
// Release represents information about a software release from GitHub.
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"` // The tag name of the release
|
||||
TagName string `json:"tag_name"` // The tag name of the release
|
||||
Body string `json:"body"` // The release notes; the dev channel reads its commit from here
|
||||
TargetCommitish string `json:"target_commitish"` // The branch/commit the tag points at
|
||||
Prerelease bool `json:"prerelease"` // Whether this is a pre-release
|
||||
}
|
||||
|
||||
// ServerService provides business logic for server monitoring and management.
|
||||
@@ -163,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
|
||||
@@ -601,15 +604,19 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
|
||||
}
|
||||
status.Xray.Version = s.xrayService.GetXrayVersion()
|
||||
status.PanelVersion = config.GetVersion()
|
||||
status.PanelVersion = config.GetPanelVersion()
|
||||
if guid, err := s.settingService.GetPanelGuid(); err == nil {
|
||||
status.PanelGuid = guid
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -2061,11 +2068,34 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auths := parseVlessEncAuths(out.String())
|
||||
auths = append(auths, deriveVlessEncModes(auths)...)
|
||||
|
||||
return map[string]any{
|
||||
"auths": parseVlessEncAuths(out.String()),
|
||||
"auths": auths,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func deriveVlessEncModes(auths []map[string]string) []map[string]string {
|
||||
var extra []map[string]string
|
||||
for _, a := range auths {
|
||||
for _, mode := range []string{"xorpub", "random"} {
|
||||
dec := strings.Replace(a["decryption"], ".native.", "."+mode+".", 1)
|
||||
enc := strings.Replace(a["encryption"], ".native.", "."+mode+".", 1)
|
||||
if dec == a["decryption"] && enc == a["encryption"] {
|
||||
continue
|
||||
}
|
||||
extra = append(extra, map[string]string{
|
||||
"id": a["id"] + "_" + mode,
|
||||
"label": a["label"] + " (" + mode + ")",
|
||||
"decryption": dec,
|
||||
"encryption": enc,
|
||||
})
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
func parseVlessEncAuths(output string) []map[string]string {
|
||||
lines := strings.Split(output, "\n")
|
||||
var auths []map[string]string
|
||||
|
||||
@@ -80,3 +80,31 @@ Authentication: X25519, not Post-Quantum
|
||||
t.Fatalf("encryption = %q, want client", auths[0]["encryption"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveVlessEncModes(t *testing.T) {
|
||||
base := []map[string]string{
|
||||
{
|
||||
"id": "x25519",
|
||||
"label": "X25519, not Post-Quantum",
|
||||
"decryption": "mlkem768x25519plus.native.600s.server-key",
|
||||
"encryption": "mlkem768x25519plus.native.0rtt.client-key",
|
||||
},
|
||||
}
|
||||
|
||||
derived := deriveVlessEncModes(base)
|
||||
if len(derived) != 2 {
|
||||
t.Fatalf("expected 2 derived blocks, got %d", len(derived))
|
||||
}
|
||||
if derived[0]["id"] != "x25519_xorpub" {
|
||||
t.Errorf("id = %q, want x25519_xorpub", derived[0]["id"])
|
||||
}
|
||||
if derived[0]["decryption"] != "mlkem768x25519plus.xorpub.600s.server-key" {
|
||||
t.Errorf("decryption = %q", derived[0]["decryption"])
|
||||
}
|
||||
if derived[0]["encryption"] != "mlkem768x25519plus.xorpub.0rtt.client-key" {
|
||||
t.Errorf("encryption = %q", derived[0]["encryption"])
|
||||
}
|
||||
if derived[1]["id"] != "x25519_random" {
|
||||
t.Errorf("id = %q, want x25519_random", derived[1]["id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -55,7 +56,7 @@ var defaultValueMap = map[string]string{
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkTemplate": "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
|
||||
"remarkTemplate": "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
@@ -109,6 +110,7 @@ var defaultValueMap = map[string]string{
|
||||
"restartXrayOnClientDisable": "true",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
"panelOutbound": "",
|
||||
"devChannelEnable": "false",
|
||||
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
@@ -855,6 +857,16 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
|
||||
return s.setBool("restartXrayOnClientDisable", value)
|
||||
}
|
||||
|
||||
// GetDevChannelEnable reports whether the panel self-update tracks the rolling
|
||||
// per-commit dev release instead of the latest stable tag.
|
||||
func (s *SettingService) GetDevChannelEnable() (bool, error) {
|
||||
return s.getBool("devChannelEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetDevChannelEnable(value bool) error {
|
||||
return s.setBool("devChannelEnable", value)
|
||||
}
|
||||
|
||||
// GetIpLimitEnable reports whether the IP-limit feature is available. Always
|
||||
// true since the panel enforces limits via the core's online-stats API; on an
|
||||
// older core the job falls back to access-log parsing and warns there when the
|
||||
@@ -1209,25 +1221,27 @@ func (s *SettingService) BuildSubURIBase(host string) string {
|
||||
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
type settingFunc func() (any, error)
|
||||
settings := map[string]settingFunc{
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
|
||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
|
||||
"webDomain": func() (any, error) { return s.GetWebDomain() },
|
||||
"subDomain": func() (any, error) { return s.GetSubDomain() },
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
|
||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
|
||||
"webDomain": func() (any, error) { return s.GetWebDomain() },
|
||||
"subDomain": func() (any, error) { return s.GetSubDomain() },
|
||||
"devChannelEnable": func() (any, error) { return s.GetDevChannelEnable() },
|
||||
"isDevBuild": func() (any, error) { return config.IsDevBuild(), nil },
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
|
||||
@@ -108,7 +108,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
|
||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetVersion())
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
||||
info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version))
|
||||
|
||||
// get ip address
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user