Compare commits

..

1 Commits

Author SHA1 Message Date
MHSanaei a2343d2041 v2.0.0 2023-12-11 14:58:25 +01:00
665 changed files with 116805 additions and 122646 deletions
-4
View File
@@ -1,4 +0,0 @@
XUI_DEBUG=true
XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
-14
View File
@@ -1,14 +0,0 @@
# These are supported funding model platforms
github: MHSanaei
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mhsanaei
custom: https://donate.sanaei.dev/
-164
View File
@@ -1,164 +0,0 @@
name: Bug report
description: Report something that is broken or behaving unexpectedly
title: "[Bug]: "
labels: ["bug", "needs triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. A complete report helps us
reproduce and fix it quickly. Please **search [existing issues](../issues?q=is%3Aissue)**
before opening a new one — duplicates will be closed.
- type: textarea
id: what-happened
attributes:
label: Describe the bug
description: A clear and concise description of what went wrong.
placeholder: When I … the panel does … but I expected it to …
validations:
required: true
- type: textarea
id: how-repeat-problem
attributes:
label: How to reproduce the problem
description: Numbered steps starting from a clean state. The clearer the steps, the faster the fix.
placeholder: |
1. Open the `Inbounds` page
2. Create a new VLESS inbound with …
3. Click `Save`
4. Observe …
validations:
required: true
- type: textarea
id: expected-action
attributes:
label: Expected behavior
placeholder: I expected the panel to …
validations:
required: false
- type: textarea
id: received-action
attributes:
label: Actual behavior
placeholder: Instead, the panel …
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant logs
description: |
Panel logs (`journalctl -u x-ui -n 200`) and/or the browser DevTools
console output. **Redact** tokens, real domains, IPs, and client UUIDs.
render: shell
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Drag images directly into this field. Redact any sensitive data.
validations:
required: false
- type: input
id: xui-version
attributes:
label: 3x-ui version
description: Shown at the top of the panel sidebar.
placeholder: 3.1.0
validations:
required: true
- type: input
id: xray-version
attributes:
label: Xray-core version
description: Visible on the `Xray Settings` page.
placeholder: 25.x.x
validations:
required: false
- type: dropdown
id: install-method
attributes:
label: How did you install 3x-ui?
options:
- install.sh script
- Docker / Docker Compose
- Manual build from source
- Other (please describe in the bug body)
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
description: Distribution and version.
placeholder: Ubuntu 24.04 / Debian 12 / CentOS Stream 9 …
validations:
required: true
- type: dropdown
id: area
attributes:
label: Which parts of the panel are affected?
multiple: true
options:
- Frontend (UI / panel pages)
- Backend (API endpoints, login, settings)
- Xray config generation
- Subscription (share links / Clash / JSON)
- Statistics / traffic counters
- Database / migrations
- Install / upgrade script
- Docker image
- Multi-node (sub-nodes)
- Telegram bot
- Other
validations:
required: false
- type: input
id: browser
attributes:
label: Browser (only if it is a UI bug)
placeholder: Chrome 132 / Firefox 134 / Safari 18 / mobile Chrome …
validations:
required: false
- type: dropdown
id: reverse-proxy
attributes:
label: Is the panel behind a reverse proxy or CDN?
options:
- "No — direct access"
- "Yes — Nginx"
- "Yes — Caddy"
- "Yes — Cloudflare (proxied DNS)"
- "Yes — Cloudflare Tunnel"
- "Yes — other"
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched [existing issues](../issues?q=is%3Aissue) and this bug has not been reported.
required: true
- label: I am running the latest released version of 3x-ui (or have verified the bug still exists on it).
required: true
- label: This bug report is written in English.
required: true
- label: I have redacted any sensitive data (tokens, real domains, client UUIDs).
required: true
+56
View File
@@ -0,0 +1,56 @@
name: Issue Report
description: "Create a report to help us improve."
body:
- type: checkboxes
id: terms
attributes:
label: Welcome
options:
- label: Yes, I'm using the latest major release. Only such installations are supported.
required: true
- label: Yes, I'm using the supported system. Only such systems are supported.
required: true
- label: Yes, I have read all WIKI document,nothing can help me in my problem.
required: true
- label: Yes, I've searched similar issues on GitHub and didn't find any.
required: true
- label: Yes, I've included all information below (version, config, log, etc).
required: true
- type: textarea
id: problem
attributes:
label: Description of the problem,screencshot would be good
placeholder: Your problem description
validations:
required: true
- type: textarea
id: version
attributes:
label: Version of 3x-ui
value: |-
<details>
```console
# Paste here
```
</details>
validations:
required: true
- type: textarea
id: log
attributes:
label: x-ui log reports or xray log
value: |-
<details>
```console
# paste log here
```
</details>
validations:
required: true
-11
View File
@@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Project Wiki
url: https://github.com/MHSanaei/3x-ui/wiki
about: Setup, install, configuration, and "how do I…" guides live here. Please check before opening a question.
- name: 🔍 Search existing issues
url: https://github.com/MHSanaei/3x-ui/issues?q=is%3Aissue
about: Your bug, feature, or question may already be tracked. Comment on the existing one rather than opening a duplicate.
- name: 🚀 Latest release
url: https://github.com/MHSanaei/3x-ui/releases/latest
about: Reproduce on the latest version before reporting — many bugs are already fixed in the most recent release.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
-102
View File
@@ -1,102 +0,0 @@
name: Feature request
description: Suggest an idea or improvement for 3x-ui
title: "[Feature]: "
labels: ["enhancement", "needs triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature. Please **search
[existing issues](../issues?q=is%3Aissue)** first — duplicates will be closed.
- type: textarea
id: is-related-problem
attributes:
label: Is your feature request related to a problem?
description: A clear and concise description of the problem you're hitting today.
placeholder: I'm always frustrated when …
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: |
What should the panel do? Be specific — UI placement, API shape,
config keys, expected behavior under edge cases.
placeholder: |
On the Inbounds page, add a button that …
The backend should expose a new endpoint at …
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you've considered
description: Other ways you tried to solve this, and why they fell short.
validations:
required: false
- type: textarea
id: use-case
attributes:
label: Concrete use case
description: |
Walk us through a real scenario where this feature would help.
Numbers help (e.g. "I manage 200 clients across 5 inbounds and …").
validations:
required: false
- type: textarea
id: mockup
attributes:
label: Mockups, screenshots, or examples
description: |
Drag images, paste config snippets, or link to similar features in
other tools. Helps us understand the shape of the request.
validations:
required: false
- type: dropdown
id: area
attributes:
label: Which parts of the panel would this affect?
multiple: true
options:
- Frontend (UI / panel pages)
- Backend (API endpoints, login, settings)
- Xray config generation
- Subscription (share links / Clash / JSON)
- Statistics / traffic counters
- Database / migrations
- Install / upgrade script
- Docker image
- Multi-node (sub-nodes)
- Telegram bot
- Other
validations:
required: false
- type: checkboxes
id: contribution
attributes:
label: Are you willing to help?
description: Optional — but maintainers prioritize requests with community help.
options:
- label: I'd like to implement this feature myself and open a PR.
required: false
- label: I can help test once a PR is open.
required: false
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched [existing issues](../issues?q=is%3Aissue) and this feature has not been requested.
required: true
- label: This feature request is written in English.
required: true
+10
View File
@@ -0,0 +1,10 @@
---
name: 'Question '
about: Describe this issue template's purpose here.
title: ''
labels: question
assignees: ''
---
-90
View File
@@ -1,90 +0,0 @@
name: Question
description: Ask how to do something with 3x-ui
title: "[Question]: "
labels: ["question"]
body:
- type: markdown
attributes:
value: |
Before opening a question, please:
- Read the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki) —
most setup, install, and "how do I …" answers live there.
- Search [existing issues](../issues?q=is%3Aissue) — the question
may already have been asked.
Use this template only when you have a usage question that the docs
and previous issues don't answer. If you found something **broken**,
open a Bug report instead.
- type: textarea
id: question
attributes:
label: What are you trying to do?
description: Describe the goal, not just the symptom. The clearer the goal, the better the answer.
placeholder: |
I'm trying to … so that …
I expected the panel to … but I'm not sure how to configure it.
validations:
required: true
- type: textarea
id: tried
attributes:
label: What have you already tried?
description: Pages of the Wiki you read, settings you toggled, commands you ran.
placeholder: |
- Read the `Reverse Proxy` page of the Wiki
- Tried setting `xrayBasePath` to `/proxy` — got 404
validations:
required: false
- type: input
id: xui-version
attributes:
label: 3x-ui version
description: Shown at the top of the panel sidebar.
placeholder: 3.1.0
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: How did you install 3x-ui?
options:
- install.sh script
- Docker / Docker Compose
- Manual build from source
- Other
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
placeholder: Ubuntu 24.04 / Debian 12 / CentOS Stream 9 …
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots or config snippets
description: Drag images or paste relevant config. Redact tokens, real domains, client UUIDs.
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I read the [Wiki](https://github.com/MHSanaei/3x-ui/wiki) and searched [existing issues](../issues?q=is%3Aissue).
required: true
- label: This is a usage question, not a bug report.
required: true
- label: This question is written in English.
required: true
+4 -13
View File
@@ -1,19 +1,10 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions" # See documentation for possible values
- package-ecosystem: "gomod" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
interval: "daily"
-63
View File
@@ -1,63 +0,0 @@
## Summary
<!-- What does this PR do? One or two sentences. -->
## Why
<!--
What problem does this solve, or what use case does it enable?
Link related issues here: "Closes #123", "Refs #456".
-->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactoring (no behavior change)
- [ ] Documentation
- [ ] Tests only
- [ ] Build / CI / tooling
- [ ] Other
## Areas affected
- [ ] Frontend (UI / panel pages)
- [ ] Backend (API endpoints, login, settings)
- [ ] Xray config generation
- [ ] Subscription (share links / Clash / JSON)
- [ ] Statistics / traffic counters
- [ ] Database / migrations
- [ ] Install / upgrade script
- [ ] Docker image
- [ ] Multi-node (sub-nodes)
- [ ] Telegram bot
## How was this tested?
<!--
Concrete steps the reviewer can repeat. For UI changes: which page,
which actions, which browser. For backend: which endpoint, which payload,
which response. Mention any new unit/integration tests added.
-->
## Screenshots / recordings
<!-- Required for UI changes. Drag images or GIFs here. Remove if N/A. -->
## Breaking changes
<!--
Does this change require existing users to update their config, run a
migration, or change their API calls? If yes, describe the migration path.
Write "None" if there are no breaking changes.
-->
## Checklist
- [ ] I tested the change locally and confirmed the described behavior.
- [ ] I added or updated tests for the new behavior (when applicable).
- [ ] `go build ./...` and the test suite pass locally.
- [ ] For frontend changes: `npm run lint`, `npm run typecheck`, and `npm run build` pass.
- [ ] I updated the Wiki / README / API docs if user-facing behavior changed.
- [ ] My commits follow the project's existing message style.
- [ ] I have no unrelated changes mixed into this PR.
-95
View File
@@ -1,95 +0,0 @@
name: CI
on:
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
push:
branches:
- main
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
permissions:
contents: read
jobs:
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Typecheck
run: npm run typecheck
working-directory: frontend
- name: Test
run: npm test
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
working-directory: frontend
-31
View File
@@ -1,31 +0,0 @@
name: Cleanup Caches
on:
schedule:
- cron: "0 3 * * *" # every day
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Delete caches older than 1 day
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
echo "Deleting caches older than: $CUTOFF_DATE"
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
if [ -z "$CACHE_IDS" ]; then
echo "No old caches found to delete."
else
echo "$CACHE_IDS" | while read CACHE_ID; do
echo "Deleting cache: $CACHE_ID"
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
done
echo "Old caches deleted successfully."
fi
-82
View File
@@ -1,82 +0,0 @@
name: "CodeQL Advanced"
on:
push:
branches:
- main
tags-ignore:
- "v*"
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "frontend/package-lock.json"
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "frontend/package-lock.json"
schedule:
- cron: "18 2 * * 2"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node.js
if: matrix.language == 'go'
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
if: matrix.language == 'go'
run: |
npm ci
npm run build
working-directory: frontend
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+19 -38
View File
@@ -1,60 +1,41 @@
name: Release 3X-UI for Docker
permissions:
contents: read
packages: write
name: Release X-ui dockerhub
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
- "*"
workflow_dispatch:
jobs:
build:
build_and_push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: true
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
hsanaeii/3x-ui
ghcr.io/mhsanaei/3x-ui
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Check out the code
uses: actions/checkout@v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5.3.0
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5.1.0
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64, linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+34 -246
View File
@@ -1,289 +1,77 @@
name: Release 3X-UI
name: Release 3X-ui
on:
workflow_dispatch:
push:
branches:
- "**"
tags:
- "v*.*.*"
paths:
- "**.js"
- "**.css"
- "**.html"
- "**.sh"
- "**.go"
- "go.mod"
- "go.sum"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
pull_request:
paths:
- "**.js"
- "**.css"
- "**.html"
- "**.sh"
- "**.go"
- "go.mod"
- "go.sum"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
- "*"
workflow_dispatch:
jobs:
build:
permissions:
contents: write
strategy:
matrix:
platform:
- amd64
- arm64
- armv7
- armv6
- 386
- armv5
- s390x
runs-on: ubuntu-latest
platform: [amd64, arm64]
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4.1.1
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5.0.0
with:
go-version-file: go.mod
check-latest: true
# Frontend dist must be built BEFORE go build — Go's //go:embed
# all:dist directive in web/web.go requires web/dist/ to exist
# at compile time. web/dist/ is .gitignored, so on a fresh CI
# checkout it doesn't exist until vite emits it.
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
go-version: '1.21'
- name: Install dependencies for arm64
if: matrix.platform == 'arm64'
run: |
npm ci
npm run build
working-directory: frontend
sudo apt-get update
sudo apt install gcc-aarch64-linux-gnu
- name: Build 3X-UI
- name: Build x-ui
run: |
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=${{ matrix.platform }}
# Use Bootlin prebuilt cross-toolchains (musl 1.2.5 in stable series)
case "${{ matrix.platform }}" in
amd64) BOOTLIN_ARCH="x86-64" ;;
arm64) BOOTLIN_ARCH="aarch64" ;;
armv7) BOOTLIN_ARCH="armv7-eabihf"; export GOARCH=arm GOARM=7 ;;
armv6) BOOTLIN_ARCH="armv6-eabihf"; export GOARCH=arm GOARM=6 ;;
armv5) BOOTLIN_ARCH="armv5-eabi"; export GOARCH=arm GOARM=5 ;;
386) BOOTLIN_ARCH="x86-i686" ;;
s390x) BOOTLIN_ARCH="s390x-z13" ;;
esac
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
echo "Downloading: $TARBALL_URL"
cd /tmp
curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
tar -xf "$(basename "$TARBALL_URL")"
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
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
file xui-release
ldd xui-release || echo "Static binary confirmed"
if [ "${{ matrix.platform }}" == "arm64" ]; then
export CC=aarch64-linux-gnu-gcc
fi
go build -o xui-release -v main.go
mkdir x-ui
cp xui-release x-ui/
cp x-ui.service.debian x-ui/
cp x-ui.service.arch x-ui/
cp x-ui.service.rhel x-ui/
cp x-ui.service x-ui/
cp x-ui.sh x-ui/
mv x-ui/xui-release x-ui/x-ui
mkdir x-ui/bin
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
wget https://github.com/XTLS/Xray-core/releases/download/v1.8.6/Xray-linux-64.zip
unzip Xray-linux-64.zip
rm -f Xray-linux-64.zip
elif [ "${{ matrix.platform }}" == "arm64" ]; then
wget -q ${Xray_URL}Xray-linux-arm64-v8a.zip
else
wget https://github.com/XTLS/Xray-core/releases/download/v1.8.6/Xray-linux-arm64-v8a.zip
unzip Xray-linux-arm64-v8a.zip
rm -f Xray-linux-arm64-v8a.zip
elif [ "${{ matrix.platform }}" == "armv7" ]; then
wget -q ${Xray_URL}Xray-linux-arm32-v7a.zip
unzip Xray-linux-arm32-v7a.zip
rm -f Xray-linux-arm32-v7a.zip
elif [ "${{ matrix.platform }}" == "armv6" ]; then
wget -q ${Xray_URL}Xray-linux-arm32-v6.zip
unzip Xray-linux-arm32-v6.zip
rm -f Xray-linux-arm32-v6.zip
elif [ "${{ matrix.platform }}" == "386" ]; then
wget -q ${Xray_URL}Xray-linux-32.zip
unzip Xray-linux-32.zip
rm -f Xray-linux-32.zip
elif [ "${{ matrix.platform }}" == "armv5" ]; then
wget -q ${Xray_URL}Xray-linux-arm32-v5.zip
unzip Xray-linux-arm32-v5.zip
rm -f Xray-linux-arm32-v5.zip
elif [ "${{ matrix.platform }}" == "s390x" ]; then
wget -q ${Xray_URL}Xray-linux-s390x.zip
unzip Xray-linux-s390x.zip
rm -f Xray-linux-s390x.zip
fi
rm -f geoip.dat geosite.dat
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
rm -f geoip.dat geosite.dat geoip_IR.dat geosite_IR.dat
wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
wget -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
mv xray xray-linux-${{ matrix.platform }}
cd ../..
- name: Package
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
- name: Upload files to Artifacts
uses: actions/upload-artifact@v7
with:
name: x-ui-linux-${{ matrix.platform }}
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
- name: Upload
uses: svenstaro/upload-release-action@2.7.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
tag: ${{ github.ref }}
file: x-ui-linux-${{ matrix.platform }}.tar.gz
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
overwrite: true
prerelease: true
# =================================
# Windows Build
# =================================
build-windows:
name: Build for Windows
permissions:
contents: write
strategy:
matrix:
platform:
- amd64
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
# Frontend dist must be built BEFORE go build — see comment on the
# Linux job above. This step is identical except npm runs on the
# Windows runner here.
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
shell: pwsh
run: |
npm ci
npm run build
working-directory: frontend
- name: Install MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-sqlite3
mingw-w64-x86_64-pkg-config
- name: Build 3X-UI for Windows (CGO)
shell: msys2 {0}
run: |
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
export CGO_ENABLED=1
export GOOS=windows
export GOARCH=amd64
export CC=x86_64-w64-mingw32-gcc
which go
go version
gcc --version
go build -ldflags "-w -s" -o xui-release.exe -v main.go
- name: Copy and download resources
shell: pwsh
run: |
mkdir x-ui
Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
Rename-Item xray.exe xray-windows-amd64.exe
cd ..
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
cd ..
- name: Package to Zip
shell: pwsh
run: |
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
- name: Upload files to Artifacts
uses: actions/upload-artifact@v7
with:
name: x-ui-windows-amd64
path: ./x-ui-windows-amd64.zip
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
file: x-ui-windows-amd64.zip
asset_name: x-ui-windows-amd64.zip
overwrite: true
prerelease: true
+7 -38
View File
@@ -1,46 +1,15 @@
# Ignore editor and IDE settings
.idea/
.vscode/
.claude/
.cache/
.idea
.vscode
.cache
.sync*
# Ignore log files
*.log
# Ignore temporary files
tmp/
*.tar.gz
# Ignore build and distribution directories
access.log
error.log
tmp
main
backup/
bin/
dist/
!web/dist/
web/dist/*
!web/dist/.gitkeep
release/
node_modules/
# Ignore compiled binaries
main
# Ignore script and executable files
/release.sh
/x-ui
# Ignore OS specific files
.DS_Store
Thumbs.db
# Ignore Go build files
*.exe
x-ui.db
x-ui.db-shm
x-ui.db-wal
# Ignore Docker specific files
docker-compose.override.yml
# Ignore .env (Environment Variables) file
.env
-1
View File
@@ -1 +0,0 @@
22
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "vscode://schemas/launch",
"version": "0.2.0",
"configurations": [
{
"name": "Run 3x-ui (Debug)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true",
"XUI_DB_FOLDER": "x-ui",
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui"
},
"console": "integratedTerminal"
},
]
}
-89
View File
@@ -1,89 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "go: build",
"type": "shell",
"command": "go",
"args": [
"build",
"-o",
"bin/3x-ui.exe",
"./main.go"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "go: run",
"type": "shell",
"command": "go",
"args": [
"run",
"./main.go"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
}
},
"problemMatcher": [
"$go"
]
},
{
"label": "go: test",
"type": "shell",
"command": "go",
"args": [
"test",
"./..."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test"
},
{
"label": "go: vet",
"type": "shell",
"command": "go",
"args": [
"vet",
"./..."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
]
},
{
"label": "go: fmt",
"type": "shell",
"command": "gofmt",
"args": [
"-l",
"-w",
"."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
}
]
}
-226
View File
@@ -1,226 +0,0 @@
# Contributing
Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running locally and explains the conventions the project follows so changes land cleanly.
## Prerequisites
- **Go 1.26+** (the version pinned in `go.mod`)
- **Node.js 22+** and npm 10+ (for the React frontend)
- **Git**
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
### Windows: MinGW-w64
`go build` on Windows fails with `cgo: C compiler "gcc" not found` until a GCC toolchain is installed. Two options — pick whichever fits.
**Option A — standalone zip (fastest, no package manager)**
1. Download the latest build from <https://github.com/niXman/mingw-builds-binaries/releases>. For most setups, pick a release named:
```
x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
```
(64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
2. Extract it somewhere stable, e.g. `C:\mingw64\`.
3. Add `C:\mingw64\bin` to the **Windows** `PATH` (System Properties → Environment Variables → Path → New).
4. Open a fresh terminal and confirm:
```powershell
gcc --version
```
**Option B — MSYS2 (when a Unix shell is also useful)**
1. Install MSYS2 from <https://www.msys2.org/>.
2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
```bash
pacman -Syu
```
3. Install the UCRT64 toolchain:
```bash
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
```
4. Add `C:\msys64\ucrt64\bin` to the Windows `PATH`.
5. Verify with `gcc --version` in a fresh terminal.
After either path, `go build ./...` and `go run .` work normally.
> **Why MinGW-w64 over MSVC:** `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain does not require a Visual Studio install. If Visual Studio Build Tools are already present that works too — just make sure `CC=cl` is **not** set in the environment.
Cross-building the Linux SQLite target from Windows (or vice versa) requires a separate cross-compiler and is out of scope here; build natively on the target OS.
## First-time setup
```bash
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
cp .env.example .env
mkdir x-ui
go mod download
cd frontend
npm install
npm run build
cd ..
```
`.env.example` ships with defaults that keep the database, logs, and xray binary inside the local `x-ui/` folder so nothing escapes the project directory:
```
XUI_DEBUG=true
XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
```
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
## Running
```bash
go run .
```
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
### Inside VS Code
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
```jsonc
{
"version": "0.2.0",
"configurations": [
{
"name": "Run 3x-ui (Debug)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true",
"XUI_DB_FOLDER": "x-ui",
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui"
},
"console": "integratedTerminal"
}
]
}
```
## Working on the frontend
The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`, built with Vite 8. The sections below cover the architecture, the conventions, and the two dev workflows.
### Architecture
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
### State and data flow
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
### i18n
Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
### Two dev workflows
| Goal | Command |
|------|---------|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
### Adding a new page
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
### Conventions
- **TypeScript strict mode** — all new code in `.ts` / `.tsx`. Run `npm run typecheck` (`tsc --noEmit`) before pushing. The path alias `@/*` resolves to `src/*`.
- **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
- **Function components + hooks** everywhere. No class components.
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
### Project layout
```
frontend/
├── *.html — Vite entry HTML, one per panel route
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
├── vite.config.js
└── src/
├── entries/ — per-page bootstrap (createRoot + render)
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
├── api/ — Axios setup + CSRF interceptor + WebSocket client
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
├── styles/ — shared CSS (page-cards, …)
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
```
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
## Project layout
| Path | Contents |
|------|----------|
| `main.go` | Process entry point, CLI subcommands, signal handling |
| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
| `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
| `xray/` | Xray-core process lifecycle and gRPC API client |
| `sub/` | Subscription endpoints (raw, JSON, Clash) |
| `config/` | Environment-variable helpers, paths, defaults |
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
## Sending a pull request
1. Branch off `main` (e.g. `feat/short-description`).
2. Keep the diff focused — separate refactors from feature work.
3. Run the relevant checks before pushing:
- `go build ./...`
- `go test ./...` (when Go code changed)
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
5. Open the PR against `main` with a brief description of what changed and how to test it.
## Useful environment variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
## Issues and discussion
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
+2 -56
View File
@@ -1,61 +1,7 @@
#!/bin/sh
# Start fail2ban with the 3x-ipl jail
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
mkdir -p "$LOG_FOLDER"
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
[3x-ipl]
enabled=true
backend=auto
filter=3x-ipl
action=3x-ipl
logpath=$LOG_FOLDER/3xipl.log
maxretry=1
findtime=32
bantime=30m
EOF
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
[Definition]
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex =
EOF
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
[INCLUDES]
before = iptables-allports.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init]
name = default
protocol = tcp
chain = INPUT
EOF
fail2ban-client -x start
fi
# Start fail2ban
fail2ban-client -x start
# Run x-ui
exec /app/x-ui
+10 -21
View File
@@ -1,40 +1,29 @@
#!/bin/sh
case $1 in
amd64)
ARCH="64"
FNAME="amd64"
;;
i386)
ARCH="32"
FNAME="i386"
;;
armv8 | arm64 | aarch64)
ARCH="arm64-v8a"
FNAME="arm64"
;;
armv7 | arm | arm32)
ARCH="arm32-v7a"
FNAME="arm32"
;;
armv6)
ARCH="arm32-v6"
FNAME="armv6"
;;
*)
ARCH="64"
FNAME="amd64"
;;
esac
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-${ARCH}.zip"
wget "https://github.com/XTLS/Xray-core/releases/download/v1.8.6/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat geoip_IR.dat geosite_IR.dat
mv xray "xray-linux-${FNAME}"
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
cd ../../
wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
wget -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
+9 -33
View File
@@ -1,33 +1,21 @@
# ========================================================
# Stage: Frontend (Vite)
# ========================================================
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
COPY web/translation /src/web/translation
RUN npm run build
# ========================================================
# Stage: Builder
# ========================================================
FROM golang:1.26-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder
WORKDIR /app
ARG TARGETARCH
ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
RUN apk --no-cache --update add \
build-base \
gcc \
curl \
wget \
unzip
COPY . .
COPY --from=frontend /src/web/dist ./web/dist
ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
RUN go build -ldflags "-w -s" -o build/x-ui main.go
RUN go build -o build/x-ui main.go
RUN ./DockerInit.sh "$TARGETARCH"
# ========================================================
@@ -40,16 +28,11 @@ WORKDIR /app
RUN apk add --no-cache --update \
ca-certificates \
tzdata \
fail2ban \
bash \
curl \
openssl
COPY --from=builder /app/build/ /app/
COPY --from=builder /app/DockerEntrypoint.sh /app/
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
COPY --from=builder /app/web/translation /app/web/translation
fail2ban
COPY --from=builder /app/build/ /app/
COPY --from=builder /app/DockerEntrypoint.sh /app/
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
# Configure fail2ban
RUN rm -f /etc/fail2ban/jail.d/alpine-ssh.conf \
@@ -63,12 +46,5 @@ RUN chmod +x \
/app/x-ui \
/usr/bin/x-ui
ENV XUI_ENABLE_FAIL2BAN="true"
# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
# Default (unset) is SQLite stored under /etc/x-ui.
ENV XUI_DB_TYPE=""
ENV XUI_DB_DSN=""
EXPOSE 2053
VOLUME [ "/etc/x-ui" ]
CMD [ "./x-ui" ]
ENTRYPOINT [ "/app/DockerEntrypoint.sh" ]
-62
View File
@@ -1,62 +0,0 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
> [!IMPORTANT]
> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج.
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
## البدء السريع
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
## شكر خاص إلى
- [alireza0](https://github.com/alireza0/)
## الاعتراف
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
## أدوات المجتمع
أدوات وتكاملات بناها المجتمع حول 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## النجوم عبر الزمن
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
-63
View File
@@ -1,63 +0,0 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
> [!IMPORTANT]
> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción.
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
## Inicio Rápido
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
## Un Agradecimiento Especial a
- [alireza0](https://github.com/alireza0/)
## Reconocimientos
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
## Herramientas de la Comunidad
Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
## Apoyar el Proyecto
**Si este proyecto te es útil, puedes darle una**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Estrellas a lo Largo del Tiempo
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
-63
View File
@@ -1,63 +0,0 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکل‌های مختلف VPN و پراکسی ارائه می‌دهد.
> [!IMPORTANT]
> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید.
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
## شروع سریع
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
## تشکر ویژه از
- [alireza0](https://github.com/alireza0/)
## قدردانی
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._
## ابزارهای جامعه
ابزارها و یکپارچه‌سازی‌هایی که توسط جامعه پیرامون 3x-ui ساخته شده‌اند.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینت‌ها، تنظیمات پنل و پیکربندی Xray به‌صورت کد با Terraform / OpenTofu._
## پشتیبانی از پروژه
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## ستاره‌ها در طول زمان
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+340 -58
View File
@@ -1,95 +1,377 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
# 3x-ui
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
> **Disclaimer: This project is only for personal learning and communication, please do not use it for illegal purposes, please do not use it in a production environment**
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](#)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](#)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
3x-ui panel supporting multi-protocol, **Multi-lang (English,Farsi,Chinese,Russian,Vietnamese,Spanish)**
**If you think this project is helpful to you, you may wish to give a** :star2:
> [!IMPORTANT]
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
**Buy Me a Coffee :**
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
- Tron USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
## Quick Start
# Install & Upgrade
```bash
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
# Install custom version
## Database Options
3X-UI supports two backends, chosen during the install:
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
To install your desired version you can add the version to the end of install command. Example for ver `v2.0.0`:
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v2.0.0
```
### Migrating an existing SQLite install to PostgreSQL
# SSL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
```
apt-get install certbot -y
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
certbot renew --dry-run
```
You also can use `x-ui` menu then select `SSL Certificate Management`
# Features
- System Status Monitoring
- Search within all inbounds and clients
- Support Dark/Light theme UI
- Support multi-user multi-protocol, web page visualization operation
- Supported protocols: vmess, vless, trojan, shadowsocks, dokodemo-door, socks, http
- Support for configuring more transport configurations
- Traffic statistics, limit traffic, limit expiration time
- Customizable xray configuration templates
- Support https access panel (self-provided domain name + ssl certificate)
- Support one-click SSL certificate application and automatic renewal
- For more advanced configuration items, please refer to the panel
- Fix api routes (user setting will create with api)
- Support to change configs by different items provided in panel
- Support export/import database from panel
# Manual Install & Upgrade
<details>
<summary>Click for Manual Install details</summary>
1. To download the latest version of the compressed package directly to your server, run the following command:
```sh
ARCH=$(uname -m)
[[ "${ARCH}" == "aarch64" || "${ARCH}" == "arm64" ]] && XUI_ARCH="arm64" || XUI_ARCH="amd64"
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
```
2. Once the compressed package is downloaded, execute the following commands to install or upgrade x-ui:
```sh
ARCH=$(uname -m)
[[ "${ARCH}" == "aarch64" || "${ARCH}" == "arm64" ]] && XUI_ARCH="arm64" || XUI_ARCH="amd64"
cd /root/
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
cp x-ui/x-ui.sh /usr/bin/x-ui
cp -f x-ui/x-ui.service /etc/systemd/system/
mv x-ui/ /usr/local/
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
```
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
</details>
### Docker
# Install with Docker
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
<details>
<summary>Click for Docker details</summary>
```bash
docker compose --profile postgres up -d
1. Install Docker:
```sh
bash <(curl -sSL https://get.docker.com)
```
2. Clone the Project Repository:
```sh
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
```
3. Start the Service
```sh
docker compose up -d
```
OR
```sh
docker run -itd \
-e XRAY_VMESS_AEAD_FORCED=false \
-v $PWD/db/:/etc/x-ui/ \
-v $PWD/cert/:/root/cert/ \
--network=host \
--restart=unless-stopped \
--name 3x-ui \
ghcr.io/mhsanaei/3x-ui:latest
```
</details>
# Default settings
<details>
<summary>Click for Default settings details</summary>
- Port: 2053
- username and password will be generated randomly if you skip to modify your own security(x-ui "7")
- database path: /etc/x-ui/x-ui.db
- xray config path: /usr/local/x-ui/bin/config.json
Before you set ssl on settings
- http://ip:2053/panel
- http://domain:2053/panel
After you set ssl on settings
- https://yourdomain:2053/panel
</details>
# Xray Configurations:
<details>
<summary>Click for Xray Configurations details</summary>
**copy and paste to xray Configuration :** (you don't need to do this if you have a fresh install)
- [traffic](./media/configs/traffic.json)
- [traffic + Block all Iran IP address](./media/configs/traffic+block-iran-ip.json)
- [traffic + Block all Iran Domains](./media/configs/traffic+block-iran-domains.json)
- [traffic + Block Ads + Use IPv4 for Google](./media/configs/traffic+block-ads+ipv4-google.json)
- [traffic + Block Ads + Route Google + Netflix + Spotify + OpenAI (ChatGPT) to WARP](./media/configs/traffic+block-ads+warp.json)
</details>
# [WARP Configuration](https://gitlab.com/fscarmen/warp) (Optional)
<details>
<summary>Click for WARP Configuration details</summary>
If you want to use routing to WARP follow steps as below:
1. If you already installed warp, you can uninstall using below command:
```sh
warp u
```
2. Install WARP on **socks proxy mode**:
```sh
bash <(curl -sSL https://raw.githubusercontent.com/hamid-gh98/x-ui-scripts/main/install_warp_proxy.sh)
```
3. Turn on the config you need in panel or [Copy and paste this file to Xray Configuration](./media/configs/traffic+block-ads+warp.json)
Config Features:
- Block Ads
- Route Google + Netflix + Spotify + OpenAI (ChatGPT) to WARP
- Fix Google 403 error
</details>
# IP Limit
<details>
<summary>Click for IP Limit details</summary>
**Note: IP Limit won't work correctly when using IP Tunnel**
- For versions up to `v1.6.1`:
- IP limit is built-in into the panel.
- For versions `v1.7.0` and newer:
- To make IP Limit work properly, you need to install fail2ban and its required files by following these steps:
1. Use the `x-ui` command inside the shell.
2. Select `IP Limit Management`.
3. Choose the appropriate options based on your needs.
- make sure you have access.log on your Xray Configuration
```sh
"log": {
"loglevel": "warning",
"access": "./access.log",
"error": "./error.log"
},
```
</details>
# Telegram Bot
<details>
<summary>Click for Telegram Bot details</summary>
X-UI supports daily traffic notification, panel login reminder and other functions through the Tg robot. To use the Tg robot, you need to apply for the specific application tutorial. You can refer to the [blog](https://coderfan.net/how-to-use-telegram-bot-to-alarm-you-when-someone-login-into-your-vps.html)
Set the robot-related parameters in the panel background, including:
- Tg robot Token
- Tg robot ChatId
- Tg robot cycle runtime, in crontab syntax
- Tg robot Expiration threshold
- Tg robot Traffic threshold
- Tg robot Enable send backup in cycle runtime
- Tg robot Enable CPU usage alarm threshold
Reference syntax:
- 30 \* \* \* \* \* //Notify at the 30s of each point
- 0 \*/10 \* \* \* \* //Notify at the first second of each 10 minutes
- @hourly // hourly notification
- @daily // Daily notification (00:00 in the morning)
- @weekly // weekly notification
- @every 8h // notify every 8 hours
# Telegram Bot Features
- Report periodic
- Login notification
- CPU threshold notification
- Threshold for Expiration time and Traffic to report in advance
- Support client report menu if client's telegram username added to the user's configurations
- Support telegram traffic report searched with UUID (VMESS/VLESS) or Password (TROJAN) - anonymously
- Menu based bot
- Search client by email ( only admin )
- Check all inbounds
- Check server status
- Check depleted users
- Receive backup by request and in periodic reports
- Multi language bot
</details>
# Setting up Telegram bot
- Start [Botfather](https://t.me/BotFather) in your Telegram account:
![Botfather](./media/botfather.png)
- Create a new Bot using /newbot command: It will ask you 2 questions, A name and a username for your bot. Note that the username has to end with the word "bot".
![Create new bot](./media/newbot.png)
- Start the bot you've just created. You can find the link to your bot here.
![token](./media/token.png)
- Enter your panel and config Telegram bot settings like below:
![Panel Config](./media/panel-bot-config.png)
Enter your bot token in input field number 3.
Enter the user ID in input field number 4. The Telegram accounts with this id will be the bot admin. (You can enter more than one, Just separate them with ,)
- How to get Telegram user ID? Use this [bot](https://t.me/useridinfobot), Start the bot and it will give you the Telegram user ID.
![User ID](./media/user-id.png)
# API routes
<details>
<summary>Click for API routes details</summary>
- `/login` with `POST` user data: `{username: '', password: ''}` for login
- `/panel/api/inbounds` base for following actions:
| Method | Path | Action |
| :----: | ---------------------------------- | ------------------------------------------- |
| `GET` | `"/list"` | Get all inbounds |
| `GET` | `"/get/:id"` | Get inbound with inbound.id |
| `GET` | `"/getClientTraffics/:email"` | Get Client Traffics with email |
| `GET` | `"/createbackup"` | Telegram bot sends backup to admins |
| `POST` | `"/add"` | Add inbound |
| `POST` | `"/del/:id"` | Delete Inbound |
| `POST` | `"/update/:id"` | Update Inbound |
| `POST` | `"/clientIps/:email"` | Client Ip address |
| `POST` | `"/clearClientIps/:email"` | Clear Client Ip address |
| `POST` | `"/addClient"` | Add Client to inbound |
| `POST` | `"/:id/delClient/:clientId"` | Delete Client by clientId\* |
| `POST` | `"/updateClient/:clientId"` | Update Client by clientId\* |
| `POST` | `"/:id/resetClientTraffic/:email"` | Reset Client's Traffic |
| `POST` | `"/resetAllTraffics"` | Reset traffics of all inbounds |
| `POST` | `"/resetAllClientTraffics/:id"` | Reset traffics of all clients in an inbound |
| `POST` | `"/delDepletedClients/:id"` | Delete inbound depleted clients (-1: all) |
| `POST` | `"/onlines"` | Get Online users ( list of emails ) |
\*- The field `clientId` should be filled by:
- `client.id` for VMESS and VLESS
- `client.password` for TROJAN
- `client.email` for Shadowsocks
- [API Documentation](https://documenter.getpostman.com/view/16802678/2s9YkgD5jm)
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/16802678-1a4c9270-ac77-40ed-959a-7aa56dc4a415?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16802678-1a4c9270-ac77-40ed-959a-7aa56dc4a415%26entityType%3Dcollection%26workspaceId%3D2cd38c01-c851-4a15-a972-f181c23359d9)
</details>
# Environment Variables
<details>
<summary>Click for Environment Variables details</summary>
| Variable | Type | Default |
| -------------- | :--------------------------------------------: | :------------ |
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
| XUI_DEBUG | `boolean` | `false` |
| XUI_BIN_FOLDER | `string` | `"bin"` |
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
Example:
```sh
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
```
## A Special Thanks to
</details>
# A Special Thanks To
- [alireza0](https://github.com/alireza0/)
## Acknowledgment
# Acknowledgment
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
- [Iran Hosted Domains](https://github.com/bootmortis/iran-hosted-domains) (License: **MIT**): _A comprehensive list of Iranian domains and services that are hosted within the country._
- [PersianBlocker](https://github.com/MasterKia/PersianBlocker) (License: **AGPLv3**): _An optimal and extensive list to block ads and trackers on Persian websites._
## Community Tools
# Suggestion System
Tools and integrations built by the community around 3x-ui.
- Ubuntu 20.04+
- Debian 10+
- CentOS 8+
- Fedora 36+
- Arch Linux
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
# Pictures
## Support project
![1](./media/1.png)
![2](./media/2.png)
![3](./media/3.png)
![4](./media/4.png)
![5](./media/5.png)
![6](./media/6.png)
**If this project is helpful to you, you may wish to give it a**:star2:
## Stargazers over time
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Stargazers over Time
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg)](https://starchart.cc/MHSanaei/3x-ui)
-63
View File
@@ -1,63 +0,0 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
> [!IMPORTANT]
> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде.
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
## Быстрый старт
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
## Особая благодарность
- [alireza0](https://github.com/alireza0/)
## Благодарности
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
## Инструменты сообщества
Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
## Поддержка проекта
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Звезды с течением времени
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
-63
View File
@@ -1,63 +0,0 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
> [!IMPORTANT]
> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
## 快速开始
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
## 特别感谢
- [alireza0](https://github.com/alireza0/)
## 致谢
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
## 社区工具
社区围绕 3x-ui 构建的工具和集成。
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
## 支持项目
**如果这个项目对您有帮助,您可以给它一个**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## 随时间变化的星标数
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+11 -113
View File
@@ -1,14 +1,9 @@
// Package config provides configuration management utilities for the 3x-ui panel,
// including version information, logging levels, database paths, and environment variable handling.
package config
import (
_ "embed"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -18,29 +13,24 @@ var version string
//go:embed name
var name string
// LogLevel represents the logging level for the application.
type LogLevel string
// Logging level constants
const (
Debug LogLevel = "debug"
Info LogLevel = "info"
Notice LogLevel = "notice"
Warning LogLevel = "warning"
Error LogLevel = "error"
Debug LogLevel = "debug"
Info LogLevel = "info"
Notice LogLevel = "notice"
Warn LogLevel = "warn"
Error LogLevel = "error"
)
// GetVersion returns the version string of the 3x-ui application.
func GetVersion() string {
return strings.TrimSpace(version)
}
// GetName returns the name of the 3x-ui application.
func GetName() string {
return strings.TrimSpace(name)
}
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
func GetLogLevel() LogLevel {
if IsDebug() {
return Debug
@@ -52,17 +42,10 @@ func GetLogLevel() LogLevel {
return LogLevel(logLevel)
}
// IsDebug returns true if debug mode is enabled via the XUI_DEBUG environment variable.
func IsDebug() bool {
return os.Getenv("XUI_DEBUG") == "true"
}
// IsSkipHSTS returns true if skipping HSTS mode is enabled via the XUI_SKIP_HSTS environment variable.
func IsSkipHSTS() bool {
return os.Getenv("XUI_SKIP_HSTS") == "true"
}
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
func GetBinFolderPath() string {
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
if binFolderPath == "" {
@@ -71,107 +54,22 @@ func GetBinFolderPath() string {
return binFolderPath
}
func getBaseDir() string {
exePath, err := os.Executable()
if err != nil {
return "."
}
exeDir := filepath.Dir(exePath)
exeDirLower := strings.ToLower(filepath.ToSlash(exeDir))
if strings.Contains(exeDirLower, "/appdata/local/temp/") || strings.Contains(exeDirLower, "/go-build") {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
return exeDir
}
// GetDBFolderPath returns the path to the database folder based on environment variables or platform defaults.
func GetDBFolderPath() string {
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
if dbFolderPath != "" {
return dbFolderPath
if dbFolderPath == "" {
dbFolderPath = "/etc/x-ui"
}
if runtime.GOOS == "windows" {
return getBaseDir()
}
return "/etc/x-ui"
return dbFolderPath
}
// GetDBPath returns the full path to the database file.
func GetDBPath() string {
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
}
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
func GetDBKind() string {
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
switch v {
case "postgres", "postgresql", "pg":
return "postgres"
default:
return "sqlite"
}
}
// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
func GetDBDSN() string {
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
}
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
func GetLogFolder() string {
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
if logFolderPath != "" {
return logFolderPath
if logFolderPath == "" {
logFolderPath = "/var/log"
}
if runtime.GOOS == "windows" {
return filepath.Join(".", "log")
}
return "/var/log/x-ui"
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
if err != nil {
return err
}
return out.Sync()
}
func init() {
if runtime.GOOS != "windows" {
return
}
if os.Getenv("XUI_DB_FOLDER") != "" {
return
}
oldDBFolder := "/etc/x-ui"
oldDBPath := fmt.Sprintf("%s/%s.db", oldDBFolder, GetName())
newDBFolder := GetDBFolderPath()
newDBPath := fmt.Sprintf("%s/%s.db", newDBFolder, GetName())
_, err := os.Stat(newDBPath)
if err == nil {
return // new exists
}
_, err = os.Stat(oldDBPath)
if os.IsNotExist(err) {
return // old does not exist
}
_ = copyFile(oldDBPath, newDBPath) // ignore error
return logFolderPath
}
+1 -1
View File
@@ -1 +1 @@
3.2.0
2.0.0
+45 -603
View File
@@ -1,28 +1,16 @@
// Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
package database
import (
"bytes"
"encoding/json"
"errors"
"io"
"log"
"math"
"io/fs"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
"github.com/mhsanaei/3x-ui/v3/util/random"
"github.com/mhsanaei/3x-ui/v3/xray"
"x-ui/config"
"x-ui/database/model"
"x-ui/xray"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -30,611 +18,91 @@ import (
var db *gorm.DB
const (
DialectSQLite = "sqlite"
DialectPostgres = "postgres"
)
// IsPostgres reports whether the active connection is a PostgreSQL backend.
func IsPostgres() bool {
if db == nil {
return config.GetDBKind() == "postgres"
}
return db.Dialector.Name() == "postgres"
var initializers = []func() error{
initUser,
initInbound,
initSetting,
initInboundClientIps,
initClientTraffic,
}
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
func Dialect() string {
if db == nil {
return ""
}
return db.Dialector.Name()
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
)
func initModels() error {
models := []any{
&model.User{},
&model.Inbound{},
&model.OutboundTraffics{},
&model.Setting{},
&model.InboundClientIps{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
&model.Node{},
&model.ApiToken{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.ClientGroup{},
&model.InboundFallback{},
}
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(err, mdl) {
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
continue
}
log.Printf("Error auto migrating model: %v", err)
return err
}
}
return nil
}
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
if err == nil {
return false
}
errMsg := strings.ToLower(err.Error())
// SQLite: "duplicate column name: foo"
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
const sqlitePrefix = "duplicate column name:"
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
col := strings.TrimSpace(after)
col = strings.Trim(col, "`\"[]")
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
}
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
// Best effort: extract the column name between the first pair of double quotes.
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
rest := after
if e := strings.Index(rest, "\""); e > 0 {
col := rest[:e]
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
}
}
}
return false
}
// initUser creates a default admin user if the users table is empty.
func initUser() error {
empty, err := isTableEmpty("users")
err := db.AutoMigrate(&model.User{})
if err != nil {
log.Printf("Error checking if users table is empty: %v", err)
return err
}
if empty {
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
if err != nil {
log.Printf("Error hashing default password: %v", err)
return err
}
var count int64
err = db.Model(&model.User{}).Count(&count).Error
if err != nil {
return err
}
if count == 0 {
user := &model.User{
Username: defaultUsername,
Password: hashedPassword,
Username: "admin",
Password: "admin",
LoginSecret: "",
}
return db.Create(user).Error
}
return nil
}
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
func runSeeders(isUsersEmpty bool) error {
empty, err := isTableEmpty("history_of_seeders")
if err != nil {
log.Printf("Error checking if users table is empty: %v", err)
return err
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
}
}
return seedApiTokens()
}
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
return err
}
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
}
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
if err := db.Create(hashSeeder).Error; err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "ApiTokensTable") {
if err := seedApiTokens(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "ClientsTable") {
if err := seedClientsFromInboundJSON(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "InboundClientsArrayFix") {
if err := normalizeInboundClientsArray(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "InboundClientTgIdFix") {
if err := normalizeInboundClientTgId(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "InboundClientSubIdFix") {
if err := normalizeInboundClientSubId(); err != nil {
return err
}
}
return nil
func initInbound() error {
return db.AutoMigrate(&model.Inbound{})
}
func normalizeInboundClientTgId() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("InboundClientTgIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
mutated := false
for i, raw := range clients {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
tgRaw, present := obj["tgId"]
if !present {
continue
}
v, isFloat := tgRaw.(float64)
if isFloat && !math.IsNaN(v) && !math.IsInf(v, 0) && v == math.Trunc(v) {
continue
}
obj["tgId"] = int64(0)
clients[i] = obj
mutated = true
}
if !mutated {
continue
}
settings["clients"] = clients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("InboundClientTgIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientTgIdFix"}).Error
})
func initSetting() error {
return db.AutoMigrate(&model.Setting{})
}
func normalizeInboundClientSubId() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("InboundClientSubIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
mutated := false
for i, raw := range clients {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
existing, _ := obj["subId"].(string)
if strings.TrimSpace(existing) != "" {
continue
}
obj["subId"] = random.NumLower(16)
clients[i] = obj
mutated = true
}
if !mutated {
continue
}
settings["clients"] = clients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("InboundClientSubIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientSubIdFix"}).Error
})
func initInboundClientIps() error {
return db.AutoMigrate(&model.InboundClientIps{})
}
func normalizeInboundClientsArray() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("InboundClientsArrayFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
raw, exists := settings["clients"]
if !exists || raw != nil {
continue
}
settings["clients"] = []any{}
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Printf("InboundClientsArrayFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
continue
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientsArrayFix"}).Error
})
func initClientTraffic() error {
return db.AutoMigrate(&xray.ClientTraffic{})
}
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
// drop the key so the field falls back to its zero value.
func normalizeClientJSONFields(obj map[string]any) {
normalizeInt := func(key string) {
raw, exists := obj[key]
if !exists {
return
}
s, ok := raw.(string)
if !ok {
return
}
trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
if trimmed == "" {
delete(obj, key)
return
}
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
obj[key] = n
} else {
delete(obj, key)
}
}
for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
normalizeInt(k)
}
}
func seedClientsFromInboundJSON() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
byEmail := map[string]*model.ClientRecord{}
var existing []model.ClientRecord
if err := tx.Find(&existing).Error; err != nil {
return err
}
for i := range existing {
byEmail[existing[i].Email] = &existing[i]
}
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
rawList, ok := settings["clients"].([]any)
if !ok {
continue
}
for _, raw := range rawList {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
normalizeClientJSONFields(obj)
blob, err := json.Marshal(obj)
if err != nil {
continue
}
var c model.Client
if err := json.Unmarshal(blob, &c); err != nil {
log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
inbound.Id, err, string(blob))
continue
}
email := strings.TrimSpace(c.Email)
if email == "" {
continue
}
incoming := c.ToRecord()
row, dup := byEmail[email]
if !dup {
if err := tx.Create(incoming).Error; err != nil {
return err
}
byEmail[email] = incoming
row = incoming
} else {
conflicts := model.MergeClientRecord(row, incoming)
for _, x := range conflicts {
log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
email, x.Field, x.Old, x.New, x.Kept)
}
if err := tx.Save(row).Error; err != nil {
return err
}
}
link := model.ClientInbound{
ClientId: row.Id,
InboundId: inbound.Id,
FlowOverride: c.Flow,
}
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
FirstOrCreate(&link).Error; err != nil {
return err
}
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
})
}
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens")
if err != nil {
return err
}
if empty {
var legacy model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
if err == nil && legacy.Value != "" {
row := &model.ApiToken{
Name: "default",
Token: legacy.Value,
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
log.Printf("Error migrating legacy apiToken: %v", err)
return err
}
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
}
// isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) {
var count int64
err := db.Table(tableName).Count(&count).Error
return count == 0, err
}
// InitDB sets up the database connection, migrates models, and runs seeders.
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
func InitDB(dbPath string) error {
dir := path.Dir(dbPath)
err := os.MkdirAll(dir, fs.ModePerm)
if err != nil {
return err
}
var gormLogger logger.Interface
if config.IsDebug() {
gormLogger = logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
gormLogger = logger.Default
} else {
gormLogger = logger.Discard
}
c := &gorm.Config{Logger: gormLogger}
var err error
switch config.GetDBKind() {
case "postgres":
dsn := config.GetDBDSN()
if dsn == "" {
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
}
db, err = gorm.Open(postgres.Open(dsn), c)
if err != nil {
return err
}
default:
dir := path.Dir(dbPath)
if err = os.MkdirAll(dir, 0755); err != nil {
return err
}
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c)
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return err
}
c := &gorm.Config{
Logger: gormLogger,
}
sqlDB, err := db.DB()
if err != nil {
return err
}
var maxOpen, maxIdle int
switch config.GetDBKind() {
case "postgres":
maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 25)
maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 25)
default:
maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 8)
maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 4)
}
sqlDB.SetMaxOpenConns(maxOpen)
sqlDB.SetMaxIdleConns(maxIdle)
sqlDB.SetConnMaxLifetime(time.Hour)
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
if err := initModels(); err != nil {
return err
}
isUsersEmpty, err := isTableEmpty("users")
db, err = gorm.Open(sqlite.Open(dbPath), c)
if err != nil {
return err
}
if err := initUser(); err != nil {
return err
}
return runSeeders(isUsersEmpty)
}
func envInt(key string, def int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return def
}
return n
}
// CloseDB closes the database connection if it exists.
func CloseDB() error {
if db != nil {
sqlDB, err := db.DB()
if err != nil {
for _, initialize := range initializers {
if err := initialize(); err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
// GetDB returns the global GORM database instance.
func GetDB() *gorm.DB {
return db
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
return err == gorm.ErrRecordNotFound
}
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
signature := []byte("SQLite format 3\x00")
buf := make([]byte, len(signature))
@@ -645,37 +113,11 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil
}
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
// No-op on PostgreSQL (WAL there is managed by the server).
func Checkpoint() error {
if IsPostgres() {
return nil
}
return db.Exec("PRAGMA wal_checkpoint;").Error
}
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
// It does not mutate global state or run migrations.
func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil { // file must exist
return err
}
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
// Update WAL
err := db.Exec("PRAGMA wal_checkpoint;").Error
if err != nil {
return err
}
sqlDB, err := gdb.DB()
if err != nil {
return err
}
defer sqlDB.Close()
var res string
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
return err
}
if res != "ok" {
return errors.New("sqlite integrity check failed: " + res)
}
return nil
}
-155
View File
@@ -1,155 +0,0 @@
package database
import (
"encoding/json"
"path/filepath"
"regexp"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
settings, err := json.Marshal(map[string]any{
"clients": []any{
map[string]any{
"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
"email": "alice@example.com",
"enable": true,
"flow": "",
"subId": "alice-sub",
"comment": "from-inbound-json",
},
},
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := model.Inbound{
UserId: 1,
Port: 12345,
Protocol: model.VLESS,
Settings: string(settings),
Tag: "test-inbound",
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
preExisting := &model.ClientRecord{
Email: "alice@example.com",
UUID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
SubID: "alice-sub",
Enable: true,
Comment: "added-via-api",
}
if err := db.Create(preExisting).Error; err != nil {
t.Fatalf("seed client row: %v", err)
}
if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear ClientsTable history: %v", err)
}
if err := seedClientsFromInboundJSON(); err != nil {
t.Fatalf("seedClientsFromInboundJSON should be idempotent against existing rows, got: %v", err)
}
var count int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "alice@example.com").Count(&count).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if count != 1 {
t.Fatalf("alice@example.com should resolve to exactly one row, got %d", count)
}
}
func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
settings, err := json.Marshal(map[string]any{
"clients": []any{
map[string]any{
"id": "00000000-0000-0000-0000-000000000001",
"email": "missing-sub@example.com",
"subId": "",
},
map[string]any{
"id": "00000000-0000-0000-0000-000000000002",
"email": "no-sub-key@example.com",
},
map[string]any{
"id": "00000000-0000-0000-0000-000000000003",
"email": "has-sub@example.com",
"subId": "keep-me-1234",
},
},
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := model.Inbound{
UserId: 1,
Port: 23456,
Protocol: model.VLESS,
Settings: string(settings),
Tag: "subid-fix-inbound",
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := db.Where("seeder_name = ?", "InboundClientSubIdFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := normalizeInboundClientSubId(); err != nil {
t.Fatalf("normalizeInboundClientSubId: %v", err)
}
var reloaded model.Inbound
if err := db.First(&reloaded, inbound.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(reloaded.Settings), &parsed); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
clients, ok := parsed["clients"].([]any)
if !ok || len(clients) != 3 {
t.Fatalf("expected 3 clients, got %v", parsed["clients"])
}
subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`)
for i := 0; i < 2; i++ {
obj := clients[i].(map[string]any)
sub, _ := obj["subId"].(string)
if !subIdPattern.MatchString(sub) {
t.Fatalf("client %d: expected 16-char [0-9a-z] subId, got %q", i, sub)
}
}
preserved := clients[2].(map[string]any)["subId"].(string)
if preserved != "keep-me-1234" {
t.Fatalf("expected existing subId preserved, got %q", preserved)
}
var historyCount int64
if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "InboundClientSubIdFix").Count(&historyCount).Error; err != nil {
t.Fatalf("count seeder history: %v", err)
}
if historyCount != 1 {
t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
}
}
-26
View File
@@ -1,26 +0,0 @@
package database
import "fmt"
// JSONClientsFromInbound returns the FROM clause that yields one row per element
// of inbounds.settings -> clients, with a column named `client.value` whose text
// fields can be read with JSONFieldText("client.value", "<key>").
func JSONClientsFromInbound() string {
if IsPostgres() {
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
}
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
}
// JSONFieldText returns a SQL expression that extracts the textual value of <key>
// from a JSON expression. On both backends the result is the raw (unquoted) string,
// so callers do NOT need to trim surrounding quotes.
func JSONFieldText(expr, key string) string {
if IsPostgres() {
return fmt.Sprintf("(%s ->> '%s')", expr, key)
}
// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
}
-143
View File
@@ -1,143 +0,0 @@
package database
import (
"errors"
"fmt"
"log"
"os"
"path"
"reflect"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// migrationModels is the FK-aware order in which tables are created and copied.
// Parents come before their children so foreign-key constraints stay satisfied
// even when checks are not explicitly disabled.
func migrationModels() []any {
return []any{
&model.User{},
&model.Setting{},
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
&model.Node{},
&model.ApiToken{},
&model.Inbound{},
&xray.ClientTraffic{},
&model.OutboundTraffics{},
&model.InboundClientIps{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.InboundFallback{},
}
}
// MigrateData copies every row from the configured SQLite file at srcPath into
// a fresh PostgreSQL database described by dstDSN. The destination tables are
// (re)created with AutoMigrate before the copy. Source data is left untouched.
func MigrateData(srcPath, dstDSN string) error {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
}
if dstDSN == "" {
return errors.New("destination DSN is required")
}
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
return err
}
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open sqlite source: %w", err)
}
srcSQL, err := src.DB()
if err != nil {
return err
}
defer srcSQL.Close()
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open postgres destination: %w", err)
}
dstSQL, err := dst.DB()
if err != nil {
return err
}
defer dstSQL.Close()
dstSQL.SetConnMaxLifetime(time.Hour)
log.Println("Creating destination schema...")
for _, m := range migrationModels() {
if err := dst.AutoMigrate(m); err != nil {
return fmt.Errorf("AutoMigrate %T: %w", m, err)
}
}
totalRows := 0
for _, m := range migrationModels() {
n, err := copyTable(src, dst, m)
if err != nil {
return fmt.Errorf("copy %T: %w", m, err)
}
totalRows += n
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
}
if err := resetPostgresSequences(dst); err != nil {
log.Printf("warning: failed to reset some postgres sequences: %v", err)
}
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
return nil
}
// copyTable streams every row of `mdl` from src to dst in batches.
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
batchPtr := reflect.New(sliceType)
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
total := 0
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
batch := batchPtr.Elem()
if batch.Len() == 0 {
return nil
}
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
return err
}
total += batch.Len()
return nil
}).Error
return total, err
}
// resetPostgresSequences advances each table's id sequence past MAX(id),
// otherwise the next INSERT-without-id would clash with copied rows.
func resetPostgresSequences(dst *gorm.DB) error {
tables := []string{
"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
}
for _, t := range tables {
// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
_ = dst.Exec(fmt.Sprintf(
`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
t, t, t,
)).Error
}
return nil
}
+34 -665
View File
@@ -1,718 +1,87 @@
// Package model defines the database models and data structures used by the 3x-ui panel.
package model
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/util/json_util"
"github.com/mhsanaei/3x-ui/v3/xray"
"x-ui/util/json_util"
"x-ui/xray"
)
// Protocol represents the protocol type for Xray inbounds.
type Protocol string
// Protocol constants for different Xray inbound protocols.
// Hysteria v2 is not a distinct protocol — it is plain "hysteria"
// with streamSettings.version = 2. The share-link URI scheme
// "hysteria2://" is independent of this and is still emitted by the
// link generator when the stream version is 2.
const (
VMESS Protocol = "vmess"
VMess Protocol = "vmess"
VLESS Protocol = "vless"
Tunnel Protocol = "tunnel"
HTTP Protocol = "http"
Dokodemo Protocol = "Dokodemo-door"
Http Protocol = "http"
Trojan Protocol = "trojan"
Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
)
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
LoginEpoch int64 `json:"-" gorm:"default:0"`
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
LoginSecret string `json:"loginSecret"`
}
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
UserId int `json:"-"` // Associated user ID
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
Remark string `json:"remark" form:"remark"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"`
Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
// Xray configuration fields
// config part
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel"`
Port int `json:"port" form:"port" gorm:"unique"`
Protocol Protocol `json:"protocol" form:"protocol"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
// FallbackParent is populated by the API layer when this inbound is
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
// The frontend uses it to rewrite client-share links so they advertise
// the master's externally reachable endpoint instead of the child's
// loopback listen. Not persisted.
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
}
// FallbackParentInfo carries everything the frontend needs to rewrite a
// child inbound's client link: where to connect (the master's address
// and port) and which path matched on the master's fallbacks array.
// The frontend already has the master inbound in its dbInbounds list,
// so we only ship identifiers + the match path here.
type FallbackParentInfo struct {
MasterId int `json:"masterId"`
Path string `json:"path,omitempty"`
}
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
type OutboundTraffics struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
Up int64 `json:"up" form:"up" gorm:"default:0"`
Down int64 `json:"down" form:"down" gorm:"default:0"`
Total int64 `json:"total" form:"total" gorm:"default:0"`
}
// InboundClientIps stores IP addresses associated with inbound clients for access control.
type InboundClientIps struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
Ips string `json:"ips" form:"ips"`
}
// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
// JSON-text string. Empty or unparseable storage renders as null so API
// consumers don't have to special-case the legacy double-encoded shape.
func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
type alias InboundClientIps
return json.Marshal(struct {
alias
Ips json.RawMessage `json:"ips"`
}{
alias: alias(ic),
Ips: jsonStringFieldToRaw(ic.Ips),
})
}
// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
// JSON-encoded string (legacy shape), normalising back to the JSON-text the
// column stores.
func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
type alias InboundClientIps
aux := struct {
*alias
Ips json.RawMessage `json:"ips"`
}{
alias: (*alias)(ic),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
ic.Ips = jsonStringFieldFromRaw(aux.Ips)
return nil
}
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
type HistoryOfSeeders struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
SeederName string `json:"seederName"`
}
type ApiToken struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
Token string `json:"token" gorm:"not null"`
Enabled bool `json:"enabled" gorm:"default:true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
// objects rather than escaped strings, so API consumers don't need to JSON.parse
// a string inside a string. Empty fields render as null; fields whose stored
// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
func (i Inbound) MarshalJSON() ([]byte, error) {
type alias Inbound
return json.Marshal(struct {
alias
Settings json.RawMessage `json:"settings"`
StreamSettings json.RawMessage `json:"streamSettings"`
Sniffing json.RawMessage `json:"sniffing"`
}{
alias: alias(i),
Settings: jsonStringFieldToRaw(i.Settings),
StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
Sniffing: jsonStringFieldToRaw(i.Sniffing),
})
}
// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
// string (the legacy shape). Either form is normalised back to the JSON-text
// string the DB column stores.
func (i *Inbound) UnmarshalJSON(data []byte) error {
type alias Inbound
aux := struct {
*alias
Settings json.RawMessage `json:"settings"`
StreamSettings json.RawMessage `json:"streamSettings"`
Sniffing json.RawMessage `json:"sniffing"`
}{
alias: (*alias)(i),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
i.Settings = jsonStringFieldFromRaw(aux.Settings)
i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
return nil
}
func jsonStringFieldToRaw(s string) json.RawMessage {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return json.RawMessage("null")
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
b, _ := json.Marshal(s)
return b
}
func jsonStringFieldFromRaw(r json.RawMessage) string {
trimmed := bytes.TrimSpace(r)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err == nil {
return s
}
}
return string(trimmed)
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen
if listen == "" {
listen = "0.0.0.0"
}
listen = fmt.Sprintf("\"%v\"", listen)
protocol := string(i.Protocol)
settings := i.Settings
switch i.Protocol {
case Shadowsocks:
if healed, ok := HealShadowsocksClientMethods(settings); ok {
settings = healed
}
case VMESS:
if stripped, ok := StripVmessClientSecurity(settings); ok {
settings = stripped
}
case VLESS:
if stripped, ok := StripVlessInboundEncryption(settings); ok {
settings = stripped
}
if listen != "" {
listen = fmt.Sprintf("\"%v\"", listen)
}
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
Protocol: protocol,
Settings: json_util.RawMessage(settings),
Protocol: string(i.Protocol),
Settings: json_util.RawMessage(i.Settings),
StreamSettings: json_util.RawMessage(i.StreamSettings),
Tag: i.Tag,
Sniffing: json_util.RawMessage(i.Sniffing),
}
}
func StripVmessClientSecurity(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
clients, ok := parsed["clients"].([]any)
if !ok {
return settings, false
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if _, has := cm["security"]; has {
delete(cm, "security")
clients[i] = cm
changed = true
}
}
if !changed {
return settings, false
}
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
func StripVlessInboundEncryption(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
if _, has := parsed["encryption"]; !has {
return settings, false
}
delete(parsed, "encryption")
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
// HealShadowsocksClientMethods normalises the per-client `method` field
// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
// - Legacy ciphers (aes-*, chacha20-*): every client must carry a
// per-user `method` matching the inbound's top-level method, otherwise
// xray fails with "unsupported cipher method:".
// - Shadowsocks 2022 (2022-blake3-*): xray's multi-user code rejects the
// inbound with "users must have empty method" when a client carries
// one — strip stale entries left over from a switch off a legacy
// cipher.
// Returns the rewritten settings string and true when anything changed.
func HealShadowsocksClientMethods(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
method, _ := parsed["method"].(string)
clients, ok := parsed["clients"].([]any)
if !ok {
return settings, false
}
is2022 := strings.HasPrefix(method, "2022-blake3-")
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if is2022 {
if _, hasKey := cm["method"]; hasKey {
delete(cm, "method")
clients[i] = cm
changed = true
}
continue
}
if method == "" {
continue
}
existing, _ := cm["method"].(string)
if existing == method {
continue
}
cm["method"] = method
clients[i] = cm
changed = true
}
if !changed {
return settings, false
}
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
// Setting stores key-value configuration settings for the 3x-ui panel.
type Setting struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Key string `json:"key" form:"key"`
Value string `json:"value" form:"value"`
}
// Node represents a remote 3x-ui panel registered with the central panel.
// The central panel polls each node's existing /panel/api/server/status
// endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below.
type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
Address string `json:"address" form:"address" validate:"required"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is
// truthful without us having to read LastHeartbeat separately.
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
LastError string `json:"lastError"`
InboundCount int `json:"inboundCount" gorm:"-"`
ClientCount int `json:"clientCount" gorm:"-"`
OnlineCount int `json:"onlineCount" gorm:"-"`
DepletedCount int `json:"depletedCount" gorm:"-"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
type CustomGeoResource struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
Url string `json:"url" gorm:"not null"`
LocalPath string `json:"localPath" gorm:"column:local_path"`
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
LastModified string `json:"lastModified" gorm:"column:last_modified"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
}
type ClientReverse struct {
Tag string `json:"tag"`
}
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}
type ClientRecord struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
SubID string `json:"subId" gorm:"index;column:sub_id"`
UUID string `json:"uuid" gorm:"column:uuid"`
ID string `json:"id"`
Password string `json:"password"`
Auth string `json:"auth"`
Flow string `json:"flow"`
Security string `json:"security"`
Reverse string `json:"reverse" gorm:"column:reverse"`
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
Enable bool `json:"enable" gorm:"default:true"`
TgID int64 `json:"tgId" gorm:"column:tg_id"`
Group string `json:"group" gorm:"column:group_name;default:''"`
Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
func (ClientRecord) TableName() string { return "clients" }
type ClientGroup struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
func (ClientGroup) TableName() string { return "client_groups" }
// MarshalJSON emits the reverse column as a nested JSON object rather than an
// escaped JSON-text string, matching the same convention Inbound uses for its
// JSON-text columns. Empty storage renders as null.
func (r ClientRecord) MarshalJSON() ([]byte, error) {
type alias ClientRecord
return json.Marshal(struct {
alias
Reverse json.RawMessage `json:"reverse"`
}{
alias: alias(r),
Reverse: jsonStringFieldToRaw(r.Reverse),
})
}
// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
// JSON-encoded string (legacy shape).
func (r *ClientRecord) UnmarshalJSON(data []byte) error {
type alias ClientRecord
aux := struct {
*alias
Reverse json.RawMessage `json:"reverse"`
}{
alias: (*alias)(r),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
return nil
}
type ClientInbound struct {
ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"`
InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}
func (ClientInbound) TableName() string { return "client_inbounds" }
type InboundFallback struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
Name string `json:"name"`
Alpn string `json:"alpn"`
Path string `json:"path"`
Dest string `json:"dest"`
Xver int `json:"xver"`
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
}
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
func (c *Client) ToRecord() *ClientRecord {
rec := &ClientRecord{
Email: c.Email,
SubID: c.SubID,
UUID: c.ID,
Password: c.Password,
Auth: c.Auth,
Flow: c.Flow,
Security: c.Security,
LimitIP: c.LimitIP,
TotalGB: c.TotalGB,
ExpiryTime: c.ExpiryTime,
Enable: c.Enable,
TgID: c.TgID,
Group: c.Group,
Comment: c.Comment,
Reset: c.Reset,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.Reverse != nil {
if b, err := json.Marshal(c.Reverse); err == nil {
rec.Reverse = string(b)
}
}
return rec
}
func (r *ClientRecord) ToClient() *Client {
c := &Client{
ID: r.UUID,
Email: r.Email,
SubID: r.SubID,
Password: r.Password,
Auth: r.Auth,
Flow: r.Flow,
Security: r.Security,
LimitIP: r.LimitIP,
TotalGB: r.TotalGB,
ExpiryTime: r.ExpiryTime,
Enable: r.Enable,
TgID: r.TgID,
Group: r.Group,
Comment: r.Comment,
Reset: r.Reset,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.Reverse != "" {
var rev ClientReverse
if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
c.Reverse = &rev
}
}
return c
}
type ClientMergeConflict struct {
Field string
Old any
New any
Kept any
}
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
var conflicts []ClientMergeConflict
keep := func(field string, oldV, newV, kept any) {
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
}
const redacted = "<redacted>"
keepSecret := func(field string) {
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
}
incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
if existing.UUID != incoming.UUID && incoming.UUID != "" {
if incomingNewer || existing.UUID == "" {
existing.UUID = incoming.UUID
}
keepSecret("uuid")
}
if existing.Password != incoming.Password && incoming.Password != "" {
if incomingNewer || existing.Password == "" {
existing.Password = incoming.Password
keepSecret("password")
}
}
if existing.Auth != incoming.Auth && incoming.Auth != "" {
if incomingNewer || existing.Auth == "" {
existing.Auth = incoming.Auth
keepSecret("auth")
}
}
if existing.Flow != incoming.Flow && incoming.Flow != "" {
if incomingNewer || existing.Flow == "" {
keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
existing.Flow = incoming.Flow
}
}
if existing.Security != incoming.Security && incoming.Security != "" {
if incomingNewer || existing.Security == "" {
keep("security", existing.Security, incoming.Security, incoming.Security)
existing.Security = incoming.Security
}
}
if existing.SubID != incoming.SubID && incoming.SubID != "" {
if incomingNewer || existing.SubID == "" {
existing.SubID = incoming.SubID
keepSecret("subId")
}
}
if existing.TotalGB != incoming.TotalGB {
picked := existing.TotalGB
if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
picked = incoming.TotalGB
}
if picked != existing.TotalGB {
keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
existing.TotalGB = picked
}
}
if existing.ExpiryTime != incoming.ExpiryTime {
picked := existing.ExpiryTime
if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
picked = incoming.ExpiryTime
}
if picked != existing.ExpiryTime {
keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
existing.ExpiryTime = picked
}
}
if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
picked := existing.LimitIP
if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
picked = incoming.LimitIP
}
if picked != existing.LimitIP {
keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
existing.LimitIP = picked
}
}
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
if incomingNewer || existing.TgID == 0 {
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
existing.TgID = incoming.TgID
}
}
if existing.Reset != incoming.Reset && incoming.Reset != 0 {
if incomingNewer || existing.Reset == 0 {
keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
existing.Reset = incoming.Reset
}
}
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
if incomingNewer || existing.Reverse == "" {
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
existing.Reverse = incoming.Reverse
}
}
if existing.Comment != incoming.Comment && incoming.Comment != "" {
if incomingNewer || existing.Comment == "" {
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
existing.Comment = incoming.Comment
}
}
if existing.Group != incoming.Group && incoming.Group != "" {
if incomingNewer || existing.Group == "" {
keep("group", existing.Group, incoming.Group, incoming.Group)
existing.Group = incoming.Group
}
}
if existing.Enable != incoming.Enable {
if incoming.Enable {
if !existing.Enable {
keep("enable", existing.Enable, incoming.Enable, true)
existing.Enable = true
}
}
}
if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
existing.CreatedAt = incoming.CreatedAt
}
if incoming.UpdatedAt > existing.UpdatedAt {
existing.UpdatedAt = incoming.UpdatedAt
}
return conflicts
Email string `json:"email"`
LimitIP int `json:"limitIp"`
TotalGB int64 `json:"totalGB" form:"totalGB"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
Enable bool `json:"enable" form:"enable"`
TgID string `json:"tgId" form:"tgId"`
SubID string `json:"subId" form:"subId"`
Reset int `json:"reset" form:"reset"`
}
-190
View File
@@ -1,190 +0,0 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
in := Inbound{
Id: 7,
Protocol: VLESS,
Port: 443,
Settings: `{"clients":[],"decryption":"none"}`,
StreamSettings: `{"network":"tcp"}`,
Sniffing: `{"enabled":true}`,
}
out, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
if _, ok := parsed[field].(map[string]any); !ok {
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
}
}
if strings.Contains(string(out), `"settings":"`) {
t.Errorf("settings should not be emitted as a JSON string: %s", out)
}
}
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
in := Inbound{Id: 1, Protocol: VLESS}
out, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
if parsed[field] != nil {
t.Errorf("expected %s to be null, got %v", field, parsed[field])
}
}
}
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
cases := []struct {
name string
body string
}{
{
name: "nested objects (modern)",
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
},
{
name: "JSON-encoded strings (legacy)",
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var in Inbound
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !strings.Contains(in.Settings, `"decryption":"none"`) {
t.Errorf("Settings not normalised: %q", in.Settings)
}
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
}
if !strings.Contains(in.Sniffing, `"enabled":true`) {
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
}
})
}
}
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
in := Inbound{Id: 1, Settings: "not json at all"}
out, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if !strings.Contains(string(out), `"settings":"not json at all"`) {
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
}
}
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
out, err := json.Marshal(rec)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
obj, ok := parsed["reverse"].(map[string]any)
if !ok {
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
}
if obj["tag"] != "vless-in" {
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
}
}
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
out, err := json.Marshal(rec)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed["reverse"] != nil {
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
}
}
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
cases := []struct {
name string
body string
}{
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var rec ClientRecord
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
t.Errorf("Reverse not normalised: %q", rec.Reverse)
}
})
}
}
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
out, err := json.Marshal(row)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
arr, ok := parsed["ips"].([]any)
if !ok {
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
}
if len(arr) != 1 {
t.Errorf("expected 1 entry, got %d", len(arr))
}
}
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
cases := []struct {
name string
body string
}{
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var row InboundClientIps
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
t.Errorf("Ips not normalised: %q", row.Ips)
}
})
}
}
+8 -26
View File
@@ -1,34 +1,16 @@
---
version: "3"
services:
3xui:
build:
context: .
dockerfile: ./Dockerfile
container_name: 3xui_app
# hostname: yourhostname <- optional
3x-ui:
image: ghcr.io/mhsanaei/3x-ui:latest
container_name: 3x-ui
hostname: yourhostname
volumes:
- $PWD/db/:/etc/x-ui/
- $PWD/cert/:/root/cert/
environment:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
# To use PostgreSQL instead of the default SQLite, run:
# docker compose --profile postgres up -d
# and uncomment the two lines below.
# XUI_DB_TYPE: "postgres"
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
tty: true
ports:
- "2053:2053"
network_mode: host
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: 3xui_postgres
profiles: ["postgres"]
environment:
POSTGRES_USER: xui
POSTGRES_PASSWORD: xui
POSTGRES_DB: xui
volumes:
- $PWD/pgdata/:/var/lib/postgresql/data
restart: unless-stopped
-3
View File
@@ -1,3 +0,0 @@
node_modules/
.vite/
*.log
-201
View File
@@ -1,201 +0,0 @@
# 3x-ui frontend
React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
`index.html` (admin panel SPA, all `/panel/*` routes), `login.html`
(login + 2FA), and `subpage.html` (public subscription viewer). All
three are built into `../web/dist/` and embedded into the Go binary
via `embed.FS`.
State is split between local `useState`, TanStack Query for server
state, and `useTheme` / `useWebSocket` contexts. Form validation,
API parsing, and the xray config model all run through a single
shared Zod schema tree (see [Schemas](#schemas)).
## Dev
```sh
npm install
npm run dev
```
Vite serves on `http://localhost:5173/`. API calls and `/panel/*`
routes proxy to the Go panel at `http://localhost:2053/`, so start
the Go panel first (`go run main.go`) and then Vite. The proxy
auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
`/panel/xray` to the matching Vite-served HTML, so the sidebar's
production-style links work without round-tripping through Go.
## Scripts
| Command | What |
|---|---|
| `npm run dev` | Vite dev server with API + WS proxy to Go |
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` |
| `npm run preview` | Serve the built bundle locally |
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
| `npm run test:watch` | Vitest watch mode |
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
CI runs `typecheck`, `lint`, `test`, and `build` on every PR
(see `../.github/workflows/ci.yml`).
### One-off: scan for deprecated APIs
Run this command to sweep the codebase for usages of APIs marked
with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames,
removed Web APIs, etc.):
```sh
npx eslint --config eslint.deprecated.config.js src
```
It's a type-aware ESLint run against `eslint.deprecated.config.js`
and is not wired into `npm run lint` because typed linting triples
the wall-clock time.
## Production build
```sh
npm run build
```
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
`assets/`). `manualChunks` splits AntD, icons, codemirror, and
react-query into separate vendor bundles to keep the per-page
initial JS small. The Go binary embeds this directory at compile
time and `web/controller/dist.go` serves the per-page HTML.
## Layout
```
frontend/
├── index.html, login.html, subpage.html # 3 Vite entries
├── tsconfig.json
├── eslint.config.js
├── eslint.deprecated.config.js # On-demand type-aware lint config that flags
│ # usages of APIs marked with JSDoc @deprecated
├── vitest.config.ts
├── vite.config.js
├── scripts/
│ └── build-openapi.mjs # endpoints.ts → openapi.json
└── src/
├── entries/ # Per-page bootstrap (createRoot + render)
├── main.tsx # Shared root for the admin SPA (index.html)
├── routes.tsx # react-router routes mounted under /panel/
├── pages/ # One folder per route, page component + helpers
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
│ ├── settings/, api-docs/, sub/
├── layouts/ # AdminLayout (sidebar + header + outlet)
├── components/ # Cross-page React components
├── hooks/ # useClients, useTheme, useWebSocket, …
├── api/ # Axios + CSRF interceptor, TanStack Query bridge,
│ # WebSocket client + queryClient.ts
├── i18n/ # react-i18next init (locales in web/translation/)
├── lib/xray/ # Pure functions: link generation, defaults,
│ # form ⇄ wire adapters, protocol capabilities
├── schemas/ # Zod source-of-truth (see "Schemas" below)
├── generated/ # Code-generated zod + ts types from Go
│ # (DO NOT hand-edit — regenerated by gen:zod)
├── models/ # Thin legacy types still in transit
│ # (DBInbound, Status, AllSetting, reality-targets)
├── styles/ # Shared CSS modules
├── test/ # Vitest specs + golden fixtures
│ ├── *.test.ts
│ ├── __snapshots__/
│ └── golden/fixtures/ # Per-(protocol × network × security) JSON
└── utils/ # HttpUtil, ClipboardManager, SizeFormatter, …
```
## Schemas
`src/schemas/` is the single source of truth for the xray
configuration model. Every API response is parsed through it,
every form field is validated against it, and TypeScript types
are inferred via `z.infer<typeof X>` — never hand-written.
```
schemas/
├── primitives/ # Atomic reusable schemas (port, protocol, sniffing, …)
├── api/ # Backend response shapes (e.g. SlimInboundSchema)
├── forms/ # User-facing form shapes (narrower than api/)
├── protocols/
│ ├── inbound/ # Per-protocol settings (vmess, vless, trojan, …)
│ ├── outbound/
│ ├── stream/ # Network transports (tcp, ws, grpc, xhttp, kcp, …)
│ └── security/ # TLS, Reality, none
├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
└── _envelope.ts # Generic `Msg<T>` envelope wrapper
```
Patterns:
- **Discriminated unions** for polymorphic data — inbound `settings`
is `z.discriminatedUnion('protocol', […])`, same for stream and
security.
- **Three validation layers**, non-overlapping:
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
Query `queryFn` — warn-only in prod, throws in dev
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>`
blocks submit + per-field inline error
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
because a malformed payload here is always a developer bug
- **No `.loose()` or `[key: string]: any`** in production schemas.
`@typescript-eslint/no-explicit-any: error` is enforced.
## Form pattern (Pattern A)
All non-trivial modals use this single pattern:
```tsx
const [form] = Form.useForm<InboundFormValues>();
const onFinish = async () => {
const values = await form.validateFields();
await createInbound.mutateAsync(values);
};
<Form form={form} onFinish={onFinish}>
<Form.Item
name="port"
label="Port"
rules={[antdRule(InboundFormSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
</Form>
```
No `safeParse`-on-submit handlers, no `useRef<any>` for form
references, no inline `z.string().min(1)` in rules. Conditional
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
nested protocol schema.
## Testing
Vitest runs everything under `src/test/`. Schemas have **golden
fixture suites** — one JSON per `(protocol × network × security)`
combination round-tripped through `schema.parse` → link generator
→ snapshot. Regenerate snapshots after intentional changes:
```sh
npx vitest run -u
```
Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
via `import.meta.glob`.
## Adding a new page
Most new routes go inside the admin SPA (`index.html`) via
`routes.tsx` — no new HTML or Vite entry needed.
1. Add the page component under `src/pages/<page>/`.
2. Register it in `src/routes.tsx` under the `/panel/...` tree.
3. If you need a brand-new top-level bundle (login-style standalone
page), add the HTML at `frontend/<page>.html`, an entry at
`src/entries/<page>.tsx`, and register it in `rollupOptions.input`
in `vite.config.js`. Then add the Go controller call to
`serveDistPage(c, "<page>.html")`.
-47
View File
@@ -1,47 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{ ignores: ['node_modules/**', '../web/dist/**'] },
js.configs.recommended,
...tseslint.configs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx}'],
})),
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
// Zod migration goal (Step 7): every production module is held to
// strict no-explicit-any. The two legacy class files at the bottom
// of the rule list keep their existing file-level eslint-disable
// until DBInbound is migrated off Inbound.toInbound() — see the
// migration spec Non-Goals section.
'@typescript-eslint/no-explicit-any': 'error',
'no-empty': ['error', { allowEmptyCatch: true }],
'react-hooks/set-state-in-effect': 'off',
'react-hooks/purity': 'off',
'react-hooks/react-compiler': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
},
},
];
-26
View File
@@ -1,26 +0,0 @@
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{ ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
{
files: ['**/*.{ts,tsx}'],
plugins: {
'@typescript-eslint': tseslint.plugin,
'react-hooks': reactHooks,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-deprecated': 'warn',
},
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
},
];
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>Sign in</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/login.tsx"></script>
</body>
</html>
-7205
View File
File diff suppressed because it is too large Load Diff
-65
View File
@@ -1,65 +0,0 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.2.0",
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "vite",
"build": "npm run gen:api && vite build",
"preview": "vite preview",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-query-devtools": "^5.100.14",
"antd": "^6.4.3",
"axios": "^1.16.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.0",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"vite": "8.0.14",
"vitest": "^4.1.7"
},
"overrides": {
"react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0",
"react-debounce-input": {
"react": "^19.0.0"
}
}
}
File diff suppressed because it is too large Load Diff
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
const SECURITY_SCHEMES = {
bearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
},
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: '3x-ui',
description: 'Session cookie set by POST /login. Browser-only.',
},
};
function ginPathToOpenApi(path) {
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
}
function extractPathParams(openApiPath) {
const params = [];
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
let m;
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
return params;
}
function mapType(t) {
const v = String(t || '').toLowerCase();
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
if (v === 'float' || v === 'double') return 'number';
if (v === 'boolean' || v === 'bool') return 'boolean';
if (v === 'array') return 'array';
if (v === 'object') return 'object';
return 'string';
}
function tryParseJson(raw) {
if (typeof raw !== 'string') return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function paramToOpenApi(p) {
const out = {
name: p.name,
in: p.in,
required: p.in === 'path' ? true : !p.optional,
description: p.desc || '',
schema: { type: mapType(p.type) },
};
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
return out;
}
function buildOperation(ep, tag) {
const op = {
tags: [tag],
summary: ep.summary || '',
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
};
if (ep.description) op.description = ep.description;
if (ep.deprecated) op.deprecated = true;
const params = [];
const bodyParams = [];
for (const p of ep.params || []) {
if (p.in === 'body') {
bodyParams.push(p);
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
params.push(paramToOpenApi(p));
}
}
const openApiPath = ginPathToOpenApi(ep.path);
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
for (const name of extractPathParams(openApiPath)) {
if (declared.has(name)) continue;
params.push({
name,
in: 'path',
required: true,
description: '',
schema: { type: 'string' },
});
}
if (params.length > 0) op.parameters = params;
if (ep.body || bodyParams.length > 0) {
const example = tryParseJson(ep.body);
const properties = {};
const required = [];
for (const bp of bodyParams) {
properties[bp.name] = {
type: mapType(bp.type),
description: bp.desc || '',
};
if (!bp.optional) required.push(bp.name);
}
const schema = bodyParams.length > 0
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
: { type: 'object' };
op.requestBody = {
required: required.length > 0 || bodyParams.length === 0,
content: {
'application/json': {
schema,
...(example !== undefined ? { example } : {}),
},
},
};
}
const responses = {};
const successExample = tryParseJson(ep.response);
responses['200'] = {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: {},
},
},
...(successExample !== undefined ? { example: successExample } : {}),
},
},
};
const errExample = tryParseJson(ep.errorResponse);
if (errExample !== undefined || ep.errorStatus) {
const code = String(ep.errorStatus || 400);
responses[code] = {
description: 'Error response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
},
},
...(errExample !== undefined ? { example: errExample } : {}),
},
},
};
}
op.responses = responses;
return op;
}
function buildSpec() {
const paths = {};
for (const section of sections) {
const tag = section.title;
for (const ep of section.endpoints) {
const openApiPath = ginPathToOpenApi(ep.path);
if (!paths[openApiPath]) paths[openApiPath] = {};
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
}
}
const tags = sections.map((s) => ({
name: s.title,
description: s.description || '',
}));
return {
openapi: '3.0.3',
info: {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,
paths,
};
}
const spec = buildSpec();
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
const pathCount = Object.keys(spec.paths).length;
let opCount = 0;
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
console.log(`[openapi] wrote ${outPath}`);
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
void pathToFileURL;
-16
View File
@@ -1,16 +0,0 @@
import type { ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '@/queryClient';
export function QueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
)}
</QueryClientProvider>
);
}
-123
View File
@@ -1,123 +0,0 @@
import axios from 'axios';
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
const CSRF_TOKEN_PATH = '/csrf-token';
let csrfToken: string | null = null;
let csrfFetchPromise: Promise<string | null> | null = null;
let sessionExpired = false;
type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean };
function readMetaToken(): string | null {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
}
async function fetchCsrfToken(): Promise<string | null> {
try {
const basePath = window.X_UI_BASE_PATH;
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
: CSRF_TOKEN_PATH);
const res = await fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return null;
const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
return json?.success && typeof json.obj === 'string' ? json.obj : null;
} catch {
return null;
}
}
async function ensureCsrfToken(): Promise<string | null> {
if (csrfToken) return csrfToken;
const meta = readMetaToken();
if (meta) {
csrfToken = meta;
return csrfToken;
}
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
const fetched = await csrfFetchPromise;
csrfFetchPromise = null;
if (fetched) csrfToken = fetched;
return csrfToken;
}
export function setupAxios(): void {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
if (!basePath) {
const metaTag = document.querySelector('meta[name="base-path"]');
basePath = metaTag ? metaTag.getAttribute('content') : null;
}
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}
csrfToken = readMetaToken();
axios.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const method = (config.method || 'get').toUpperCase();
if (!SAFE_METHODS.has(method)) {
const token = await ensureCsrfToken();
if (token) config.headers.set('X-CSRF-Token', token);
}
if (config.data instanceof FormData) {
config.headers.set('Content-Type', 'multipart/form-data');
} else {
const declaredType = String(config.headers.get('Content-Type') || config.headers.get('content-type') || '');
if (declaredType.toLowerCase().startsWith('application/json')) {
if (config.data !== undefined && typeof config.data !== 'string') {
config.data = JSON.stringify(config.data);
}
} else {
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
}
}
return config;
},
(error: unknown) => Promise.reject(error),
);
axios.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const status = error.response?.status;
if (status === 401) {
if (!sessionExpired) {
sessionExpired = true;
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
return new Promise(() => {});
}
const cfg = error.config as CsrfAwareConfig | undefined;
if (status === 403 && cfg && !cfg.__csrfRetried) {
csrfToken = null;
cfg.__csrfRetried = true;
const token = await ensureCsrfToken();
if (token) {
cfg.headers.set('X-CSRF-Token', token);
const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || '');
if (typeof cfg.data === 'string') {
if (declaredType.toLowerCase().startsWith('application/json')) {
try { cfg.data = JSON.parse(cfg.data); } catch {}
} else {
cfg.data = qs.parse(cfg.data);
}
}
return axios(cfg);
}
}
return Promise.reject(error);
},
);
}
-9
View File
@@ -1,9 +0,0 @@
let lastLocalInvalidateAt = 0;
export function markLocalInvalidate(): void {
lastLocalInvalidateAt = Date.now();
}
export function isRecentLocalInvalidate(windowMs = 1500): boolean {
return Date.now() - lastLocalInvalidateAt < windowMs;
}
@@ -1,69 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
return validated.obj;
}
export function useAllSettings() {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
const [extraSpinning, setExtraSpinning] = useState(false);
const query = useQuery({
queryKey: keys.settings.all(),
queryFn: fetchAllSetting,
staleTime: Infinity,
});
const server = useMemo(() => new AllSetting(query.data), [query.data]);
useEffect(() => {
if (query.data !== undefined) {
setDraft(new AllSetting(query.data));
}
}, [query.data]);
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setDraft((prev) => {
const next = new AllSetting(prev);
Object.assign(next, patch);
return next;
});
}, []);
const saveMut = useMutation({
mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
const body = AllSettingSchema.partial().safeParse(next);
if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues);
}
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
},
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},
});
const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
return {
allSetting: draft,
updateSetting,
fetched: query.data !== undefined,
spinning: extraSpinning || saveMut.isPending,
setSpinning: setExtraSpinning,
saveDisabled,
saveAll,
};
}
@@ -1,58 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
export type { ProbeResult };
export function useNodeMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
const createMut = useMutation({
mutationFn: (payload: Partial<NodeRecord>) =>
HttpUtil.post('/panel/api/nodes/add', payload),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const removeMut = useMutation({
mutationFn: (id: number) =>
HttpUtil.post(`/panel/api/nodes/del/${id}`),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const setEnableMut = useMutation({
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const probeMut = useMutation({
mutationFn: async (id: number): Promise<Msg<ProbeResult>> => {
const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
},
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
return {
create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
probe: (id: number) => probeMut.mutateAsync(id),
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
},
};
}
-81
View File
@@ -1,81 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { NodeListSchema } from '@/schemas/node';
import type { NodeRecord } from '@/schemas/node';
import { keys } from '@/api/queryKeys';
export type { NodeRecord };
export interface NodeTotals {
total: number;
online: number;
offline: number;
avgLatency: number;
inbounds: number;
clients: number;
onlineClients: number;
depleted: number;
}
async function fetchNodes(): Promise<NodeRecord[]> {
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
const validated = parseMsg(msg, NodeListSchema, 'nodes/list');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useNodesQuery() {
const query = useQuery({
queryKey: keys.nodes.list(),
queryFn: fetchNodes,
});
const nodes = useMemo(() => query.data ?? [], [query.data]);
const totals = useMemo<NodeTotals>(() => {
let online = 0;
let offline = 0;
let latencySum = 0;
let latencyCount = 0;
let inbounds = 0;
let clients = 0;
let onlineClients = 0;
let depleted = 0;
for (const n of nodes) {
inbounds += n.inboundCount || 0;
clients += n.clientCount || 0;
onlineClients += n.onlineCount || 0;
depleted += n.depletedCount || 0;
if (!n.enable) continue;
if (n.status === 'online') {
online += 1;
if (n.latencyMs && n.latencyMs > 0) {
latencySum += n.latencyMs;
latencyCount += 1;
}
} else if (n.status === 'offline') {
offline += 1;
}
}
return {
total: nodes.length,
online,
offline,
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
inbounds,
clients,
onlineClients,
depleted,
};
}, [nodes]);
return {
nodes,
totals,
loading: query.isFetching,
fetched: query.data !== undefined,
};
}
@@ -1,36 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { Status } from '@/models/status';
import { StatusSchema } from '@/schemas/status';
import { keys } from '@/api/queryKeys';
const POLL_INTERVAL_MS = 2000;
async function fetchStatus(): Promise<Status> {
const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status');
const validated = parseMsg(msg, StatusSchema, 'server/status');
return new Status(validated.obj);
}
export function useStatusQuery() {
const query = useQuery({
queryKey: keys.server.status(),
queryFn: fetchStatus,
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: false,
staleTime: 0,
});
const status = useMemo(() => query.data ?? new Status(), [query.data]);
const refresh = async () => { await query.refetch(); };
return {
status,
fetched: query.data !== undefined,
refresh,
};
}
-32
View File
@@ -1,32 +0,0 @@
export const keys = {
server: {
status: () => ['server', 'status'] as const,
},
nodes: {
root: () => ['nodes'] as const,
list: () => ['nodes', 'list'] as const,
},
settings: {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const,
},
inbounds: {
root: () => ['inbounds'] as const,
slim: () => ['inbounds', 'slim'] as const,
options: () => ['inbounds', 'options'] as const,
},
clients: {
root: () => ['clients'] as const,
list: (params: unknown) => ['clients', 'list', params] as const,
all: () => ['clients', 'all'] as const,
onlines: () => ['clients', 'onlines'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const,
},
xray: {
root: () => ['xray'] as const,
config: () => ['xray', 'config'] as const,
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
},
} as const;
-192
View File
@@ -1,192 +0,0 @@
type WebSocketListener = (...args: unknown[]) => void;
interface WebSocketMessage {
type: string;
payload?: unknown;
time?: unknown;
}
export class WebSocketClient {
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
static #BASE_RECONNECT_MS = 1000;
static #MAX_RECONNECT_MS = 30_000;
static #SLOW_RETRY_MS = 60_000;
basePath: string;
maxReconnectAttempts: number;
reconnectAttempts: number;
isConnected: boolean;
private ws: WebSocket | null;
private shouldReconnect: boolean;
private reconnectTimer: ReturnType<typeof setTimeout> | null;
private listeners: Map<string, Set<WebSocketListener>>;
constructor(basePath = '') {
this.basePath = basePath;
this.maxReconnectAttempts = 10;
this.reconnectAttempts = 0;
this.isConnected = false;
this.ws = null;
this.shouldReconnect = true;
this.reconnectTimer = null;
this.listeners = new Map();
}
connect(): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.shouldReconnect = true;
this.#cancelReconnect();
this.#openSocket();
}
disconnect(): void {
this.shouldReconnect = false;
this.#cancelReconnect();
this.reconnectAttempts = 0;
if (this.ws) {
try { this.ws.close(1000, 'client disconnect'); } catch {}
this.ws = null;
}
this.isConnected = false;
}
on(event: string, callback: WebSocketListener): void {
if (typeof callback !== 'function') return;
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
}
off(event: string, callback: WebSocketListener): void {
const set = this.listeners.get(event);
if (!set) return;
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
}
send(data: unknown): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
#openSocket(): void {
const url = this.#buildUrl();
let socket: WebSocket;
try {
socket = new WebSocket(url);
} catch (err) {
console.error('WebSocket: failed to construct connection', err);
this.#emit('error', err);
this.#scheduleReconnect();
return;
}
this.ws = socket;
socket.addEventListener('open', () => {
if (this.ws !== socket) return;
this.isConnected = true;
this.reconnectAttempts = 0;
this.#emit('connected');
});
socket.addEventListener('message', (event) => {
if (this.ws !== socket) return;
this.#onMessage(event);
});
socket.addEventListener('error', (event) => {
if (this.ws !== socket) return;
this.#emit('error', event);
});
socket.addEventListener('close', () => {
if (this.ws !== socket) return;
this.isConnected = false;
this.ws = null;
this.#emit('disconnected');
if (this.shouldReconnect) this.#scheduleReconnect();
});
}
#buildUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let basePath = this.basePath || '/';
if (!basePath.startsWith('/')) basePath = '/' + basePath;
if (!basePath.endsWith('/')) basePath += '/';
return `${protocol}//${window.location.host}${basePath}ws`;
}
#onMessage(event: MessageEvent): void {
const data = event.data;
if (typeof data === 'string') {
const byteLen = new Blob([data]).size;
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
try { this.ws?.close(1009, 'message too big'); } catch {}
return;
}
}
let message: unknown;
try {
message = JSON.parse(typeof data === 'string' ? data : '');
} catch (err) {
console.error('WebSocket: invalid JSON message', err);
return;
}
if (!message || typeof message !== 'object' || typeof (message as { type?: unknown }).type !== 'string') {
console.error('WebSocket: malformed message envelope');
return;
}
const msg = message as WebSocketMessage;
this.#emit(msg.type, msg.payload, msg.time);
this.#emit('message', msg);
}
#emit(event: string, ...args: unknown[]): void {
const set = this.listeners.get(event);
if (!set) return;
for (const callback of set) {
try {
callback(...args);
} catch (err) {
console.error(`WebSocket: handler for "${event}" threw`, err);
}
}
}
#scheduleReconnect(): void {
if (!this.shouldReconnect) return;
this.#cancelReconnect();
let base: number;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts += 1;
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
} else {
base = WebSocketClient.#SLOW_RETRY_MS;
}
const delay = base * (0.75 + Math.random() * 0.5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.shouldReconnect) return;
this.#openSocket();
}, delay);
}
#cancelReconnect(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
-79
View File
@@ -1,79 +0,0 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { WebSocketClient } from '@/api/websocket';
import { keys } from '@/api/queryKeys';
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
type Handler = (payload: unknown) => void;
interface SharedClient {
connect(): void;
on(event: string, fn: Handler): void;
off(event: string, fn: Handler): void;
}
let sharedClient: SharedClient | null = null;
function getSharedClient(): SharedClient {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath) as SharedClient;
return sharedClient;
}
let invalidateTimer: number | null = null;
export function useWebSocketBridge() {
const queryClient = useQueryClient();
useEffect(() => {
const client = getSharedClient();
const onInvalidate: Handler = (payload) => {
const p = payload as { type?: string } | undefined;
if (!p || (p.type !== 'inbounds' && p.type !== 'clients')) return;
if (invalidateTimer != null) clearTimeout(invalidateTimer);
invalidateTimer = window.setTimeout(() => {
invalidateTimer = null;
if (isRecentLocalInvalidate()) return;
if (p.type === 'inbounds') {
queryClient.invalidateQueries({ queryKey: ['inbounds'] });
} else {
queryClient.invalidateQueries({ queryKey: ['clients'] });
}
}, 200);
};
const onOutbounds: Handler = (payload) => {
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
};
const onNodes: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.nodes.list(), payload);
};
const onInbounds: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.inbounds.slim(), payload);
};
client.on('invalidate', onInvalidate);
client.on('outbounds', onOutbounds);
client.on('nodes', onNodes);
client.on('inbounds', onInbounds);
client.connect();
return () => {
client.off('invalidate', onInvalidate);
client.off('outbounds', onOutbounds);
client.off('nodes', onNodes);
client.off('inbounds', onInbounds);
if (invalidateTimer != null) {
clearTimeout(invalidateTimer);
invalidateTimer = null;
}
};
}, [queryClient]);
}
-283
View File
@@ -1,283 +0,0 @@
.ant-sidebar > .ant-layout-sider {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
font-size: 18px;
letter-spacing: 0.5px;
color: var(--ant-color-text);
}
.sider-brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px 14px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
}
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
}
.brand-block {
display: inline-flex;
align-items: center;
min-width: 0;
line-height: 1.1;
}
.sider-brand-collapsed .brand-block {
flex: 0 0 auto;
}
.brand-actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.sidebar-donate {
background: transparent;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--ant-color-text-secondary);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-donate:hover,
.sidebar-donate:focus-visible {
background-color: rgba(236, 72, 153, 0.12);
color: #ec4899;
transform: scale(1.08);
outline: none;
}
.sidebar-donate .anticon {
font-size: 16px;
}
.sidebar-theme-cycle {
background: transparent;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--ant-color-text-secondary);
padding: 0;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-theme-cycle:hover,
.sidebar-theme-cycle:focus-visible {
background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
color: var(--ant-color-primary);
transform: scale(1.08);
outline: none;
}
.sidebar-theme-cycle .anticon {
font-size: 16px;
}
.drawer-header-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.drawer-handle {
position: fixed;
top: 12px;
left: 12px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.drawer-close {
background: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
color: var(--ant-color-text-secondary);
}
.drawer-close:hover,
.drawer-close:focus-visible {
background: var(--ant-color-fill-tertiary);
}
.drawer-menu .ant-menu-item {
height: 48px;
line-height: 48px;
margin: 0;
border-radius: 0;
}
.drawer-menu .ant-menu-item .anticon {
font-size: 16px;
}
.drawer-utility {
margin-top: auto;
border-top: 1px solid var(--ant-color-border-secondary);
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
}
.sider-nav {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.sider-utility {
flex: 0 0 auto;
border-top: 1px solid var(--ant-color-border-secondary);
}
.sider-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
}
.sider-version {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
padding: 8px 16px;
color: var(--ant-color-text-secondary);
font-size: 13px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
text-decoration: none;
transition: color 0.2s;
}
.sider-version .anticon {
font-size: 16px;
}
.sider-version:hover,
.sider-version:focus-visible {
color: var(--ant-color-primary);
outline: none;
}
.sider-version.is-collapsed {
justify-content: center;
padding: 8px 0;
}
.drawer-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
display: none;
}
.ant-sidebar > .ant-layout-sider {
flex: 0 0 0 !important;
max-width: 0 !important;
min-width: 0 !important;
width: 0 !important;
}
}
body.dark .ant-drawer-content,
body.dark .ant-drawer-body {
background-color: #15161a;
}
html[data-theme="ultra-dark"] body.dark .ant-drawer-content,
html[data-theme="ultra-dark"] body.dark .ant-drawer-body {
background-color: #050507;
}
body.dark .ant-drawer-body .drawer-menu,
body.dark .ant-drawer-body .drawer-menu.ant-menu-dark,
body.dark .ant-drawer-body .drawer-menu .ant-menu-item,
body.dark .ant-drawer-body .drawer-menu .ant-menu-sub,
body.dark .ant-drawer-body .drawer-menu .ant-menu-item-group-list {
background-color: transparent;
}
.sider-nav .ant-menu-item-selected,
.sider-utility .ant-menu-item-selected,
.drawer-menu .ant-menu-item-selected {
background-color: color-mix(in srgb, var(--ant-color-primary) 20%, transparent) !important;
color: var(--ant-color-primary) !important;
}
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent) !important;
color: var(--ant-color-primary) !important;
}
-301
View File
@@ -1,301 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ClusterOutlined,
CloseOutlined,
DashboardOutlined,
GithubOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MenuOutlined,
MoonFilled,
MoonOutlined,
SettingOutlined,
SunOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
inbound: ImportOutlined,
team: TeamOutlined,
groups: TagsOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
};
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-donate"
aria-label={ariaLabel}
title={ariaLabel}
>
<HeartOutlined />
</a>
);
}
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = `v${version}`;
return (
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
aria-label={`GitHub ${label}`}
title={label}
>
<GithubOutlined />
{!collapsed && <span className="sider-version-text">{label}</span>}
</a>
);
}
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
id: string;
isDark: boolean;
isUltra: boolean;
onCycle: () => void;
ariaLabel: string;
}) {
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
return (
<button
id={id}
type="button"
className="sidebar-theme-cycle"
aria-label={ariaLabel}
title={ariaLabel}
onClick={onCycle}
>
{icon}
</button>
);
}
export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname } = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [drawerOpen, setDrawerOpen] = useState(false);
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
], [t]);
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
const selectedKey = pathname === '' ? '/' : pathname;
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
return {
key: tab.key,
icon: <Icon />,
label: tab.title,
};
}),
[]);
const openLink = useCallback(async (key: string) => {
if (key === LOGOUT_KEY) {
await HttpUtil.post('/logout');
window.location.href = window.X_UI_BASE_PATH || '/';
return;
}
navigate(key);
}, [navigate]);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
openLink(String(key));
}, [openLink]);
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
setCollapsed(isCollapsed);
}
}, []);
const cycleTheme = useCallback((id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}, [isDark, isUltra, toggleTheme, toggleUltra]);
return (
<div className="ant-sidebar">
<Layout.Sider
theme={currentTheme}
collapsible
collapsed={collapsed}
breakpoint="md"
onCollapse={onSiderCollapse}
>
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
</div>
{!collapsed && (
<div className="brand-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle')}
ariaLabel={t('menu.theme')}
/>
</div>
)}
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="sider-nav"
items={toMenuItems(navItems)}
onClick={onMenuClick}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="sider-utility"
items={toMenuItems(utilItems)}
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} />
</div>
</Layout.Sider>
<Drawer
placement="left"
closable={false}
open={drawerOpen}
rootClassName={currentTheme}
size="min(82vw, 320px)"
styles={{
wrapper: { padding: 0 },
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
header: { display: 'none' },
}}
onClose={() => setDrawerOpen(false)}
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">3X-UI</span>
</div>
<div className="drawer-header-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle-drawer"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle-drawer')}
ariaLabel={t('menu.theme')}
/>
<button
className="drawer-close"
type="button"
aria-label={t('close')}
onClick={() => setDrawerOpen(false)}
>
<CloseOutlined />
</button>
</div>
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="drawer-menu drawer-nav"
items={toMenuItems(navItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="drawer-menu drawer-utility"
items={toMenuItems(utilItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
/>
<div className="drawer-footer">
<VersionBadge version={panelVersion} />
</div>
</Drawer>
{!drawerOpen && (
<button
className="drawer-handle"
type="button"
aria-label={t('menu.dashboard')}
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />
</button>
)}
</div>
);
}
@@ -1,35 +0,0 @@
.jdp-wrap {
width: 100%;
}
.jdp-wrap > * {
width: 100%;
}
.jdp-wrap input {
direction: ltr;
text-align: left;
unicode-bidi: plaintext;
}
.jdp-dark .jdp-wrap input,
.jdp-dark input {
color: rgba(255, 255, 255, 0.88) !important;
background-color: #23252b !important;
}
.jdp-ultra .jdp-wrap input,
.jdp-ultra input {
color: rgba(255, 255, 255, 0.88) !important;
background-color: #101013 !important;
}
.jdp-dark input::placeholder,
.jdp-ultra input::placeholder {
color: rgba(255, 255, 255, 0.30) !important;
}
.jdp-disabled {
pointer-events: none;
opacity: 0.6;
}
@@ -1,98 +0,0 @@
import { useMemo } from 'react';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { PersianDateTimePicker } from 'persian-calendar-suite';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useTheme } from '@/hooks/useTheme';
import './DateTimePicker.css';
interface DateTimePickerProps {
value: Dayjs | null;
onChange: (next: Dayjs | null) => void;
showTime?: boolean;
format?: string;
placeholder?: string;
disabled?: boolean;
}
const LIGHT_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#ffffff',
borderColor: '#d9d9d9',
hoverColor: 'rgba(22, 119, 255, 0.10)',
selectedTextColor: '#ffffff',
textColor: 'rgba(0, 0, 0, 0.88)',
};
const DARK_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#23252b',
borderColor: 'rgba(255, 255, 255, 0.12)',
hoverColor: 'rgba(22, 119, 255, 0.18)',
selectedTextColor: '#ffffff',
textColor: 'rgba(255, 255, 255, 0.88)',
};
const ULTRA_DARK_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#101013',
borderColor: 'rgba(255, 255, 255, 0.08)',
hoverColor: 'rgba(22, 119, 255, 0.16)',
selectedTextColor: '#ffffff',
textColor: 'rgba(255, 255, 255, 0.88)',
};
export default function DateTimePicker({
value,
onChange,
showTime = true,
format = 'YYYY-MM-DD HH:mm:ss',
placeholder = '',
disabled = false,
}: DateTimePickerProps) {
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const persianTheme = useMemo(() => {
if (isUltra) return ULTRA_DARK_THEME;
if (isDark) return DARK_THEME;
return LIGHT_THEME;
}, [isDark, isUltra]);
if (datepicker === 'jalalian') {
return (
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<PersianDateTimePicker
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
if (next == null || next === '') {
onChange(null);
return;
}
const ms = typeof next === 'number' ? next : Number(next);
if (Number.isFinite(ms)) onChange(dayjs(ms));
}}
showTime={showTime}
outputFormat="timestamp"
persianNumbers
rtlCalendar
theme={persianTheme}
/>
</div>
);
}
return (
<DatePicker
value={value}
onChange={(next) => onChange(next || null)}
showTime={showTime ? { format: 'HH:mm:ss' } : false}
format={format}
placeholder={placeholder}
disabled={disabled}
style={{ width: '100%' }}
/>
);
}
-766
View File
@@ -1,766 +0,0 @@
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives';
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
// paths under `name`; the parent modal owns the Form instance.
//
// Naming convention inside Form.List: AntD prefixes Form.Item `name`
// with the Form.List's own `name`. So Form.Items inside the render
// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
// Form.Lists also use relative names. Using absolute paths here would
// double up the prefix and silently route reads/writes to the wrong
// storage path.
export interface FinalMaskFormProps {
name: NamePath;
network: string;
protocol: string;
form: FormInstance;
}
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
function asPath(name: NamePath): (string | number)[] {
return Array.isArray(name) ? [...name] : [name];
}
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'fragment':
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
case 'sudoku':
return {
password: '', ascii: '', customTable: '', customTables: '',
paddingMin: 0, paddingMax: 0,
};
case 'header-custom':
return { clients: [], servers: [] };
default:
return {};
}
}
function defaultUdpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'salamander':
case 'mkcp-aes128gcm':
return { password: '' };
case 'header-dns':
return { domain: '' };
case 'xdns':
return { domains: [] };
case 'xicmp':
return { ip: '0.0.0.0', id: 0 };
case 'header-custom':
return { client: [], server: [] };
case 'noise':
return { reset: 0, noise: [] };
default:
return {};
}
}
function defaultClientServerItem(): Record<string, unknown> {
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function defaultUdpClientServerItem(): Record<string, unknown> {
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function defaultNoiseItem(): Record<string, unknown> {
return {
rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20',
};
}
function defaultQuicParams(): Record<string, unknown> {
return {
congestion: 'bbr',
debug: false,
maxIdleTimeout: 30,
keepAlivePeriod: 10,
disablePathMTUDiscovery: false,
maxIncomingStreams: 1024,
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnectionReceiveWindow: 20971520,
maxConnectionReceiveWindow: 20971520,
};
}
function defaultUdpHop(): Record<string, unknown> {
return { ports: '20000-50000', interval: '5-10' };
}
export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
const base = asPath(name);
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
const showTcp = TCP_NETWORKS.includes(network);
const showUdp = isHysteria || network === 'kcp';
const showQuic = isHysteria || network === 'xhttp';
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
const hasQuicParams = quicParams != null;
if (!showTcp && !showUdp && !showQuic) return null;
return (
<>
{showTcp && <TcpMasksList base={base} form={form} />}
{showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
{showQuic && (
<>
<Form.Item label="QUIC Params">
<Switch
checked={hasQuicParams}
onChange={(v) => {
form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined);
}}
/>
</Form.Item>
{hasQuicParams && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
</>
)}
</>
);
}
function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
return (
<Form.List name={[...base, 'tcp']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="TCP Masks">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
/>
</Form.Item>
{fields.map((field, mIdx) => (
<TcpMaskItem
key={field.key}
fieldName={field.name}
displayIndex={mIdx + 1}
form={form}
listPath={[...base, 'tcp']}
onRemove={() => remove(field.name)}
/>
))}
</>
)}
</Form.List>
);
}
function TcpMaskItem({
fieldName, displayIndex, form, listPath, onRemove,
}: {
fieldName: number;
displayIndex: number;
form: FormInstance;
listPath: (string | number)[];
onRemove: () => void;
}) {
// Absolute path for setFieldValue side effects (resetting settings on
// type change). All Form.Item `name=` use RELATIVE paths within the
// outer Form.List context.
const absolutePath = [...listPath, fieldName];
return (
<div>
<Divider style={{ margin: 0 }}>
TCP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
}
options={[
{ value: 'fragment', label: 'Fragment' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'sudoku', label: 'Sudoku' },
]}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => {
const a = getDeep(prev, [...absolutePath, 'type']);
const b = getDeep(curr, [...absolutePath, 'type']);
return a !== b;
}}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'fragment') {
return (
<>
<Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
<Select
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
/>
</Form.Item>
<Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
<Input />
</Form.Item>
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
<Input />
</Form.Item>
<Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
<Input />
</Form.Item>
</>
);
}
if (type === 'sudoku') {
return (
<>
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
<InputNumber min={0} />
</Form.Item>
</>
);
}
if (type === 'header-custom') {
return (
<HeaderCustomGroups
tcpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
return null;
}}
</Form.Item>
</div>
);
}
// Walks a deep object path safely. Used inside shouldUpdate which gets
// the whole form values blob; we need to compare a deep field across
// prev/curr without crashing on missing intermediates.
function getDeep(obj: unknown, path: (string | number)[]): unknown {
let cur: unknown = obj;
for (const key of path) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string | number, unknown>)[key];
}
return cur;
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
tcpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return (
<>
{(['clients', 'servers'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
{(groups, { add: addGroup, remove: removeGroup }) => (
<>
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => addGroup([defaultClientServerItem()])}
/>
</Form.Item>
{groups.map((group, gi) => (
<div key={group.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
<DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
</Divider>
<Form.List name={[group.name]}>
{(items, { add: addItem, remove: removeItem }) => (
<>
<Form.Item label="Items">
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => addItem(defaultClientServerItem())}
/>
</Form.Item>
{items.map((item) => (
<ItemEditor
key={item.key}
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
delayMode="number"
onRemove={() => removeItem(item.name)}
/>
))}
</>
)}
</Form.List>
</div>
))}
</>
)}
</Form.List>
))}
</>
);
}
function UdpMasksList({
base, form, isHysteria, network,
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) {
return (
<Form.List name={[...base, 'udp']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="UDP Masks">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => {
const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
add({ type: def, settings: defaultUdpMaskSettings(def) });
}}
/>
</Form.Item>
{fields.map((field, mIdx) => (
<UdpMaskItem
key={field.key}
fieldName={field.name}
displayIndex={mIdx + 1}
form={form}
listPath={[...base, 'udp']}
isHysteria={isHysteria}
network={network}
onRemove={() => remove(field.name)}
/>
))}
</>
)}
</Form.List>
);
}
function UdpMaskItem({
fieldName, displayIndex, form, listPath, isHysteria, network, onRemove,
}: {
fieldName: number;
displayIndex: number;
form: FormInstance;
listPath: (string | number)[];
isHysteria: boolean;
network: string;
onRemove: () => void;
}) {
const absolutePath = [...listPath, fieldName];
const onTypeChange = (v: string) => {
form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
if (network === 'kcp') {
const kcpMtuPath = [...listPath.slice(0, -1), 'kcpSettings', 'mtu'];
form.setFieldValue(kcpMtuPath, v === 'xdns' ? 900 : 1350);
}
};
const options = isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
: [
{ value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
{ value: 'header-dns', label: 'Header DNS' },
{ value: 'header-dtls', label: 'Header DTLS 1.2' },
{ value: 'header-srtp', label: 'Header SRTP' },
{ value: 'header-utp', label: 'Header uTP' },
{ value: 'header-wechat', label: 'Header WeChat Video' },
{ value: 'header-wireguard', label: 'Header WireGuard' },
{ value: 'mkcp-original', label: 'mKCP Original' },
{ value: 'xdns', label: 'xDNS' },
{ value: 'xicmp', label: 'xICMP' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' },
];
return (
<div>
<Divider style={{ margin: 0 }}>
UDP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select onChange={onTypeChange} options={options} />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'mkcp-aes128gcm' || type === 'salamander') {
return (
<Form.Item label="Password">
<Space.Compact block>
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
);
}
if (type === 'header-dns') {
return (
<Form.Item label="Domain" name={[fieldName, 'settings', 'domain']}>
<Input placeholder="e.g., www.example.com" />
</Form.Item>
);
}
if (type === 'xdns') {
return (
<Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
);
}
if (type === 'xicmp') {
return (
<>
<Form.Item label="IP" name={[fieldName, 'settings', 'ip']}>
<Input placeholder="0.0.0.0" />
</Form.Item>
<Form.Item label="ID" name={[fieldName, 'settings', 'id']}>
<InputNumber min={0} />
</Form.Item>
</>
);
}
if (type === 'header-custom') {
return (
<UdpHeaderCustom
udpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
if (type === 'noise') {
return (
<NoiseItems
udpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
return null;
}}
</Form.Item>
</div>
);
}
function UdpHeaderCustom({
udpFieldName, form, absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return (
<>
{(['client', 'server'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
{(items, { add, remove }) => (
<>
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => add(defaultUdpClientServerItem())}
/>
</Form.Item>
{items.map((item, ci) => (
<div key={item.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
</Divider>
<ItemEditor
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
onRemove={() => remove(item.name)}
/>
</div>
))}
</>
)}
</Form.List>
))}
</>
);
}
function NoiseItems({
udpFieldName, form, absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return (
<>
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
<InputNumber min={0} />
</Form.Item>
<Form.List name={[udpFieldName, 'settings', 'noise']}>
{(items, { add, remove }) => (
<>
<Form.Item label="Noise">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => add(defaultNoiseItem())}
/>
</Form.Item>
{items.map((item, ni) => (
<div key={item.key}>
<Divider style={{ margin: 0 }}>
Noise {ni + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
</Divider>
<ItemEditor
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, 'noise', item.name]}
delayMode="string"
onRemove={() => remove(item.name)}
/>
</div>
))}
</>
)}
</Form.List>
</>
);
}
function ItemEditor({
fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove,
}: {
fieldName: number;
form: FormInstance;
absoluteItemPath: (string | number)[];
delayMode?: 'number' | 'string';
onRemove?: () => void;
}) {
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
};
return (
<>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
{ value: 'str', label: 'String' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
]}
/>
</Form.Item>
{delayMode === 'number' && (
<Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
<InputNumber min={0} />
</Form.Item>
)}
{delayMode === 'string' && (
<Form.Item label="Delay" name={[fieldName, 'delay']}>
<Input placeholder="10-20" />
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
if (type === 'array') {
return (
<>
<Form.Item label="Rand" name={[fieldName, 'rand']}>
{delayMode === 'string' ? (
<Input placeholder="0 or 1-8192" />
) : (
<InputNumber min={0} />
)}
</Form.Item>
<Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
<Input placeholder="0-255" />
</Form.Item>
</>
);
}
if (type === 'base64') {
return (
<Form.Item label="Packet">
<Space.Compact block>
<Form.Item name={[fieldName, 'packet']} noStyle>
<Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
/>
</Space.Compact>
</Form.Item>
);
}
return (
<Form.Item label="Packet" name={[fieldName, 'packet']}>
<Input placeholder="binary data" />
</Form.Item>
);
}}
</Form.Item>
</>
);
}
function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as Record<string, unknown> | undefined;
const hasUdpHop = udpHop != null;
return (
<>
<Form.Item label="Congestion" name={[...base, 'congestion']}>
<Select
options={[
{ value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' },
{ value: 'brutal', label: 'Brutal' },
{ value: 'force-brutal', label: 'Force Brutal' },
]}
/>
</Form.Item>
{congestion === 'bbr' && (
<Form.Item label="BBR Profile" name={[...base, 'bbrProfile']}>
<Select
allowClear
placeholder="standard"
options={[
{ value: 'conservative', label: 'Conservative' },
{ value: 'standard', label: 'Standard' },
{ value: 'aggressive', label: 'Aggressive' },
]}
/>
</Form.Item>
)}
<Form.Item label="Debug" name={[...base, 'debug']} valuePropName="checked">
<Switch />
</Form.Item>
{(congestion === 'brutal' || congestion === 'force-brutal') && (
<>
<Form.Item label="Brutal Up" name={[...base, 'brutalUp']}>
<Input placeholder="e.g. 60 mbps" />
</Form.Item>
<Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
<Input placeholder="e.g. 100 mbps" />
</Form.Item>
</>
)}
<Form.Item label="UDP Hop">
<Switch
checked={hasUdpHop}
onChange={(v) => {
form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
}}
/>
</Form.Item>
{hasUdpHop && (
<>
<Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
<Input placeholder="e.g. 20000-50000" />
</Form.Item>
<Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
<Input placeholder="e.g. 5-10" />
</Form.Item>
</>
)}
<Form.Item label="Max Idle Timeout (s)" name={[...base, 'maxIdleTimeout']}>
<InputNumber min={4} max={120} />
</Form.Item>
<Form.Item label="Keep Alive Period (s)" name={[...base, 'keepAlivePeriod']}>
<InputNumber min={2} max={60} />
</Form.Item>
<Form.Item label="Disable Path MTU Dis" name={[...base, 'disablePathMTUDiscovery']} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Max Incoming Streams" name={[...base, 'maxIncomingStreams']}>
<InputNumber min={8} placeholder="1024 = default" />
</Form.Item>
<Form.Item label="Init Stream Window" name={[...base, 'initStreamReceiveWindow']}>
<InputNumber min={16384} placeholder="8388608 = default" />
</Form.Item>
<Form.Item label="Max Stream Window" name={[...base, 'maxStreamReceiveWindow']}>
<InputNumber min={16384} placeholder="8388608 = default" />
</Form.Item>
<Form.Item label="Init Conn Window" name={[...base, 'initConnectionReceiveWindow']}>
<InputNumber min={16384} placeholder="20971520 = default" />
</Form.Item>
<Form.Item label="Max Conn Window" name={[...base, 'maxConnectionReceiveWindow']}>
<InputNumber min={16384} placeholder="20971520 = default" />
</Form.Item>
</>
);
}
-141
View File
@@ -1,141 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Input, Space } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
// Reusable header-map editor. Handles the two wire shapes Xray uses for
// HTTP-style header maps:
//
// v1: { 'Content-Type': 'application/json', 'X-Custom': 'value' }
// Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
// name.
//
// v2: { 'Accept': ['text/html', 'application/json'],
// 'X-Forwarded': ['1.2.3.4'] }
// Used by TCP HTTP camouflage request/response. Each header can
// repeat (RFC 7230 §3.2.2).
//
// Internal state is always the flat list-of-rows shape regardless of
// mode. Conversion to/from the wire shape happens at the value/onChange
// boundary so consumers can bind straight to a Form.Item without any
// extra transforms on their side.
export type HeaderMapMode = 'v1' | 'v2';
export type HeaderMapValue =
| Record<string, string>
| Record<string, string[]>
| undefined;
interface HeaderRow {
name: string;
value: string;
}
interface HeaderMapEditorProps {
mode: HeaderMapMode;
value?: HeaderMapValue;
onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
}
function mapToRows(value: HeaderMapValue): HeaderRow[] {
if (!value || typeof value !== 'object') return [];
const out: HeaderRow[] = [];
for (const [name, raw] of Object.entries(value)) {
if (Array.isArray(raw)) {
for (const v of raw) {
out.push({ name, value: typeof v === 'string' ? v : String(v) });
}
} else if (typeof raw === 'string') {
out.push({ name, value: raw });
}
}
return out;
}
function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
if (mode === 'v1') {
const map: Record<string, string> = {};
for (const r of rows) {
if (!r.name) continue;
map[r.name] = r.value ?? '';
}
return map;
}
const map: Record<string, string[]> = {};
for (const r of rows) {
if (!r.name) continue;
const list = map[r.name] ?? [];
list.push(r.value ?? '');
map[r.name] = list;
}
return map;
}
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
// Local state holds rows including blanks. Without it, addRow() would
// append a {name:'', value:''} that rowsToMap immediately filters out
// before reaching the form, so the new row would never reach UI. The
// form-bound map only sees rows with non-empty names; blank rows live
// here until the user fills them in.
const [rows, setRows] = useState<HeaderRow[]>(() => mapToRows(value));
const lastEmittedRef = useRef<string>(JSON.stringify(rowsToMap(rows, mode)));
// Re-sync local rows when the form value changes from outside (modal
// re-open with edit data, JSON tab edits, etc.) but not when it's our
// own emission echoing back.
useEffect(() => {
const incoming = JSON.stringify(value ?? {});
if (incoming === lastEmittedRef.current) return;
setRows(mapToRows(value));
lastEmittedRef.current = incoming;
}, [value]);
function commit(next: HeaderRow[]) {
setRows(next);
const map = rowsToMap(next, mode);
lastEmittedRef.current = JSON.stringify(map);
onChange?.(map);
}
function setRow(index: number, patch: Partial<HeaderRow>) {
const next = rows.slice();
next[index] = { ...next[index], ...patch };
commit(next);
}
function addRow() {
commit([...rows, { name: '', value: '' }]);
}
function removeRow(index: number) {
const next = rows.slice();
next.splice(index, 1);
commit(next);
}
return (
<>
{rows.map((row, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={row.name}
placeholder="Name"
onChange={(e) => setRow(idx, { name: e.target.value })}
/>
<Input
value={row.value}
placeholder="Value"
onChange={(e) => setRow(idx, { value: e.target.value })}
/>
<Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
</Space.Compact>
))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
Add
</Button>
</>
);
}
-19
View File
@@ -1,19 +0,0 @@
interface InfinityIconProps {
width?: number | string;
height?: number | string;
}
export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
return (
<svg
width={width}
height={height}
viewBox="0 0 640 512"
fill="currentColor"
aria-hidden="true"
style={{ verticalAlign: '-1px', display: 'inline-block' }}
>
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
</svg>
);
}
-33
View File
@@ -1,33 +0,0 @@
.input-addon {
display: inline-flex;
align-items: center;
padding: 0 11px;
height: 32px;
font-size: 14px;
line-height: 30px;
background-color: var(--ant-color-fill-tertiary);
border: 1px solid var(--ant-color-border);
border-radius: 6px;
position: relative;
z-index: 1;
color: var(--ant-color-text);
white-space: nowrap;
}
.ant-space-compact > .input-addon:not(:first-child) {
margin-inline-start: -1px;
}
.ant-space-compact > .input-addon:first-child {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.ant-space-compact > .input-addon:last-child {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
border-radius: 0;
}
-21
View File
@@ -1,21 +0,0 @@
import type { CSSProperties, ReactNode } from 'react';
import './InputAddon.css';
interface InputAddonProps {
children: ReactNode;
className?: string;
style?: CSSProperties;
onClick?: () => void;
}
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
return (
<span
className={`input-addon ${className}`.trim()}
style={style}
onClick={onClick}
>
{children}
</span>
);
}
-16
View File
@@ -1,16 +0,0 @@
.json-editor-host {
border: 1px solid var(--ant-color-border);
border-radius: 6px;
overflow: hidden;
background: var(--ant-color-bg-container);
}
.json-editor-host .cm-editor,
.json-editor-host .cm-editor.cm-focused {
outline: none;
}
.json-editor-host:focus-within {
border-color: var(--ant-color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
}
-179
View File
@@ -1,179 +0,0 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { lintGutter, linter } from '@codemirror/lint';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { syntaxHighlighting } from '@codemirror/language';
import { keymap } from '@codemirror/view';
import { indentWithTab } from '@codemirror/commands';
import { useTheme } from '@/hooks/useTheme';
import './JsonEditor.css';
export interface JsonEditorProps {
value: string;
onChange?: (next: string) => void;
minHeight?: string;
maxHeight?: string;
readOnly?: boolean;
}
export interface JsonEditorHandle {
focus: () => void;
}
interface DarkPalette {
bg: string;
panelBg: string;
activeBg: string;
border: string;
selection: string;
}
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
return EditorView.theme(
{
'&': { color: '#dcdcdc', backgroundColor: bg },
'.cm-content': { caretColor: '#dcdcdc' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
'.cm-gutters': {
backgroundColor: bg,
borderRight: `1px solid ${border}`,
color: '#6a6a6a',
},
'.cm-activeLine': { backgroundColor: activeBg },
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{ backgroundColor: selection },
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
'.cm-tooltip': {
backgroundColor: panelBg,
border: `1px solid ${border}`,
color: '#dcdcdc',
},
},
{ dark: true },
);
}
const darkTheme = buildDarkTheme({
bg: '#1e1e1e',
panelBg: '#2d2d30',
activeBg: '#252526',
border: '#3a3a3c',
selection: '#3a3a3c',
});
const ultraDarkTheme = buildDarkTheme({
bg: '#0a0a0a',
panelBg: '#141414',
activeBg: '#141414',
border: '#1f1f1f',
selection: '#2a2a2a',
});
function themeExtension(isDark: boolean, isUltra: boolean) {
if (!isDark) return [];
const chrome = isUltra ? ultraDarkTheme : darkTheme;
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
}
const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
{ value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartmentRef = useRef<Compartment>(new Compartment());
const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
const onChangeRef = useRef(onChange);
const valueRef = useRef(value);
const { isDark, isUltra } = useTheme();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useImperativeHandle(ref, () => ({
focus: () => viewRef.current?.focus(),
}));
useEffect(() => {
if (!hostRef.current) return;
const updateListener = EditorView.updateListener.of((u) => {
if (!u.docChanged) return;
const next = u.state.doc.toString();
if (next === valueRef.current) return;
valueRef.current = next;
onChangeRef.current?.(next);
});
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: value,
extensions: [
basicSetup,
keymap.of([indentWithTab]),
json(),
linter(jsonParseLinter()),
lintGutter(),
EditorView.lineWrapping,
updateListener,
themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '12px',
minHeight,
maxHeight,
},
}),
],
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (value === current) return;
valueRef.current = value;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
}, [value]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
});
}, [isDark, isUltra]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
});
}, [readOnly]);
return <div ref={hostRef} className="json-editor-host" />;
});
export default JsonEditor;
-20
View File
@@ -1,20 +0,0 @@
import { Suspense, useEffect, useState, type ReactNode } from 'react';
interface LazyMountProps {
when: boolean;
fallback?: ReactNode;
children: ReactNode;
}
// Mounts children only after `when` first becomes true and keeps them mounted
// thereafter, so React.lazy modals get loaded on demand but their close
// animations still play out. Pair with `lazy(() => import(...))` modal imports
// on heavy list pages to keep the initial bundle small.
export default function LazyMount({ when, fallback = null, children }: LazyMountProps) {
const [mounted, setMounted] = useState(when);
useEffect(() => {
if (when && !mounted) setMounted(true);
}, [when, mounted]);
if (!mounted) return null;
return <Suspense fallback={fallback}>{children}</Suspense>;
}
-84
View File
@@ -1,84 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Input, Modal } from 'antd';
import type { InputRef } from 'antd';
import { useTranslation } from 'react-i18next';
interface PromptModalProps {
open: boolean;
onClose: () => void;
title: string;
okText?: string;
type?: 'input' | 'textarea';
initialValue?: string;
loading?: boolean;
onConfirm: (value: string) => void;
}
export default function PromptModal({
open,
onClose,
title,
okText,
type = 'input',
initialValue = '',
loading = false,
onConfirm,
}: PromptModalProps) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const inputRef = useRef<InputRef | null>(null);
useEffect(() => {
if (open) {
setValue(initialValue);
setTimeout(() => {
if (type === 'textarea') textareaRef.current?.focus();
else inputRef.current?.focus();
}, 50);
}
}, [open, initialValue, type]);
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (type !== 'textarea' && e.key === 'Enter') {
e.preventDefault();
onConfirm(value);
return;
}
if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault();
onConfirm(value);
}
}
return (
<Modal
open={open}
title={title}
okText={okText ?? t('confirm')}
cancelText={t('cancel')}
mask={{ closable: false }}
confirmLoading={loading}
onOk={() => onConfirm(value)}
onCancel={onClose}
destroyOnHidden
>
{type === 'textarea' ? (
<Input.TextArea
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
value={value}
onChange={(e) => setValue(e.target.value)}
autoSize={{ minRows: 10, maxRows: 20 }}
onKeyDown={onKeydown}
/>
) : (
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeydown}
/>
)}
</Modal>
);
}
@@ -1,28 +0,0 @@
.setting-list-item {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.setting-list-item:last-child {
border-bottom: 0;
}
.setting-list-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-list-title {
font-size: 14px;
color: var(--ant-color-text);
font-weight: 500;
}
.setting-list-description {
font-size: 14px;
color: var(--ant-color-text-tertiary);
line-height: 1.5715;
}
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react';
import { Col, Row } from 'antd';
import './SettingListItem.css';
interface SettingListItemProps {
paddings?: 'small' | 'default';
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
control?: ReactNode;
}
export default function SettingListItem({
paddings = 'default',
title,
description,
children,
control,
}: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px';
return (
<div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<div className="setting-list-meta">
{title && <div className="setting-list-title">{title}</div>}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col>
<Col xs={24} lg={12}>
{control ?? children}
</Col>
</Row>
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
.sparkline-svg {
display: block;
width: 100%;
}
.sparkline-container {
position: relative;
width: 100%;
}
.sparkline-extrema {
position: absolute;
top: 2px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 2px 8px;
background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
pointer-events: none;
z-index: 1;
}
.sparkline-extrema .extrema-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
-270
View File
@@ -1,270 +0,0 @@
import { useId, useMemo } from 'react';
import {
Area,
AreaChart,
CartesianGrid,
ReferenceDot,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import './Sparkline.css';
export interface SparklineReferenceLine {
y: number;
label?: string;
color?: string;
dash?: string;
}
export interface SparklineExtrema {
show?: boolean;
formatter?: (v: number) => string;
minColor?: string;
maxColor?: string;
}
const DEFAULT_MIN_COLOR = '#52c41a';
const DEFAULT_MAX_COLOR = '#fa541c';
interface SparklineProps {
data: number[];
labels?: (string | number)[];
height?: number;
stroke?: string;
strokeWidth?: number;
maxPoints?: number;
showGrid?: boolean;
fillOpacity?: number;
showMarker?: boolean;
markerRadius?: number;
showAxes?: boolean;
yTickStep?: number;
tickCountX?: number;
showTooltip?: boolean;
valueMin?: number;
valueMax?: number | null;
yFormatter?: (v: number) => string;
tooltipFormatter?: ((v: number) => string) | null;
tooltipLabelFormatter?: ((label: string) => string) | null;
referenceLines?: SparklineReferenceLine[];
extrema?: SparklineExtrema;
}
interface ChartPoint {
index: number;
value: number;
label: string;
}
export default function Sparkline({
data,
labels = [],
height = 80,
stroke = '#008771',
strokeWidth = 2,
maxPoints = 120,
showGrid = true,
fillOpacity = 0.22,
showMarker = true,
markerRadius = 3,
showAxes = false,
yTickStep = 25,
tickCountX = 4,
showTooltip = false,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
tooltipFormatter = null,
tooltipLabelFormatter = null,
referenceLines,
extrema,
}: SparklineProps) {
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
const gradId = `spkGrad-${safeId}`;
const points = useMemo<ChartPoint[]>(() => {
const n = Math.min(data.length, maxPoints);
if (n === 0) return [];
const sliceStart = data.length - n;
const labelStart = Math.max(0, labels.length - n);
return data.slice(sliceStart).map((value, i) => ({
index: i,
value: Number(value) || 0,
label: String(labels[labelStart + i] ?? i + 1),
}));
}, [data, labels, maxPoints]);
const yDomain = useMemo<[number, number]>(() => {
if (valueMax != null) return [valueMin, valueMax];
let max = valueMin;
for (const p of points) {
if (Number.isFinite(p.value) && p.value > max) max = p.value;
}
if (max <= valueMin) max = valueMin + 1;
return [valueMin, max * 1.1];
}, [points, valueMin, valueMax]);
const yTicks = useMemo(() => {
if (!showAxes) return undefined;
const [min, max] = yDomain;
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
const out: number[] = [];
for (let v = min; v <= max; v += yTickStep) out.push(v);
return out;
}
const n = 5;
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
const xTickIndexes = useMemo(() => {
if (!showAxes || points.length === 0) return undefined;
const m = Math.max(2, tickCountX);
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
}, [showAxes, tickCountX, points.length]);
const fmtTooltip = tooltipFormatter ?? yFormatter;
const extremaPoints = useMemo(() => {
if (!extrema?.show || points.length < 2) return null;
let minIdx = 0;
let maxIdx = 0;
for (let i = 1; i < points.length; i++) {
if (points[i].value < points[minIdx].value) minIdx = i;
if (points[i].value > points[maxIdx].value) maxIdx = i;
}
if (minIdx === maxIdx) return null;
return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
}, [points, extrema?.show]);
const fmtExtrema = extrema?.formatter ?? yFormatter;
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
return (
<div className="sparkline-container">
{extremaPoints && (
<div className="sparkline-extrema" aria-hidden="true">
<span className="extrema-item" style={{ color: maxColor }}>
{fmtExtrema(extremaPoints.max.value)}
</span>
<span className="extrema-item" style={{ color: minColor }}>
{fmtExtrema(extremaPoints.min.value)}
</span>
</div>
)}
<ResponsiveContainer width="100%" height={height} className="sparkline-svg">
<AreaChart
data={points}
margin={{
top: showAxes ? 14 : 6,
right: showAxes ? 12 : 6,
bottom: showAxes ? 26 : 4,
left: 4,
}}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
</linearGradient>
</defs>
{showGrid && (
<CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
)}
<XAxis
dataKey="label"
hide={!showAxes}
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
axisLine={false}
tickLine={false}
tickMargin={14}
interval={0}
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
/>
<YAxis
domain={yDomain}
hide={!showAxes}
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
axisLine={false}
tickLine={false}
tickMargin={8}
tickFormatter={yFormatter}
ticks={yTicks}
width={56}
/>
{showTooltip && (
<Tooltip
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
contentStyle={{
background: 'var(--ant-color-bg-elevated)',
border: '1px solid var(--ant-color-border-secondary)',
borderRadius: 6,
fontSize: 12,
padding: '6px 10px',
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
}}
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
separator=""
/>
)}
{referenceLines?.map((rl, idx) => (
<ReferenceLine
key={`ref-${idx}-${rl.y}`}
y={rl.y}
stroke={rl.color || stroke}
strokeDasharray={rl.dash || '5 4'}
strokeWidth={1.4}
label={rl.label ? {
value: rl.label,
position: 'insideTopRight',
fill: rl.color || stroke,
fontSize: 10,
fontWeight: 600,
} : undefined}
ifOverflow="extendDomain"
/>
))}
{extremaPoints && (
<>
<ReferenceDot
x={extremaPoints.max.label}
y={extremaPoints.max.value}
r={4.5}
fill={maxColor}
stroke="var(--ant-color-bg-elevated)"
strokeWidth={2}
ifOverflow="extendDomain"
/>
<ReferenceDot
x={extremaPoints.min.label}
y={extremaPoints.min.value}
r={4.5}
fill={minColor}
stroke="var(--ant-color-bg-elevated)"
strokeWidth={2}
ifOverflow="extendDomain"
/>
</>
)}
<Area
type="monotone"
dataKey="value"
stroke={stroke}
strokeWidth={strokeWidth}
fill={`url(#${gradId})`}
dot={false}
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
-61
View File
@@ -1,61 +0,0 @@
import { Button, Input, Modal, message } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { ClipboardManager, FileManager } from '@/utils';
interface TextModalProps {
open: boolean;
onClose: () => void;
title: string;
content: string;
fileName?: string;
}
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
async function copy() {
const ok = await ClipboardManager.copyText(content || '');
if (ok) {
messageApi.success(t('copied'));
onClose();
}
}
function download() {
if (!fileName) return;
FileManager.downloadTextFile(content, fileName);
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
onCancel={onClose}
destroyOnHidden
footer={(
<>
{fileName && (
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
)}
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>{t('copy')}</Button>
</>
)}
>
<Input.TextArea
value={content}
readOnly
autoSize={{ minRows: 10, maxRows: 20 }}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
overflowY: 'auto',
}}
/>
</Modal>
</>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import LoginPage from '@/pages/login/LoginPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<QueryProvider>
<LoginPage />
</QueryProvider>
</ThemeProvider>,
);
}
});
-26
View File
@@ -1,26 +0,0 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import SubPage from '@/pages/sub/SubPage';
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<QueryProvider>
<SubPage />
</QueryProvider>
</ThemeProvider>,
);
}
});
-88
View File
@@ -1,88 +0,0 @@
/// <reference types="vite/client" />
interface SubPageData {
sId?: string;
enabled?: boolean;
download?: string;
upload?: string;
total?: string;
used?: string;
remained?: string;
totalByte?: string | number;
expire?: string | number;
lastOnline?: string | number;
subUrl?: string;
subJsonUrl?: string;
subClashUrl?: string;
subTitle?: string;
links?: string[];
emails?: string[];
datepicker?: 'gregorian' | 'jalalian';
downloadByte?: string | number;
uploadByte?: string | number;
usedByte?: string | number;
}
interface Window {
X_UI_BASE_PATH?: string;
X_UI_CUR_VER?: string;
__SUB_PAGE_DATA__?: SubPageData;
}
declare module 'qs' {
interface StringifyOptions {
arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma';
encode?: boolean;
encoder?: (str: unknown, defaultEncoder: (s: unknown) => string, charset: string, type: 'key' | 'value') => string;
allowDots?: boolean;
skipNulls?: boolean;
addQueryPrefix?: boolean;
}
interface ParseOptions {
depth?: number;
arrayLimit?: number;
allowDots?: boolean;
parseArrays?: boolean;
ignoreQueryPrefix?: boolean;
}
export function stringify(obj: unknown, options?: StringifyOptions): string;
export function parse(str: string, options?: ParseOptions): Record<string, unknown>;
const qs: { stringify: typeof stringify; parse: typeof parse };
export default qs;
}
declare module 'persian-calendar-suite' {
import type { ComponentType, ReactNode } from 'react';
type DateInput = string | number | null;
type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
interface PersianDateTimePickerProps {
value?: DateInput;
onChange?: (value: number | string | null) => void;
defaultValue?: string | number | 'now' | null;
showTime?: boolean;
minuteStep?: number;
outputFormat?: OutputFormat;
showFooter?: boolean;
theme?: Record<string, unknown>;
disabledHours?: number[];
minDate?: string | Date | null;
maxDate?: string | Date | null;
enabledDates?: string[] | null;
disabledDates?: string[] | null;
disabledWeekDays?: number[];
persianNumbers?: boolean;
rtlCalendar?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
children?: ReactNode;
}
export const PersianDateTimePicker: ComponentType<PersianDateTimePickerProps>;
export const PersianCalendar: ComponentType<Record<string, unknown>>;
export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
export const PersianTimePicker: ComponentType<Record<string, unknown>>;
export const PersianTimeline: ComponentType<Record<string, unknown>>;
}
-359
View File
@@ -1,359 +0,0 @@
// Code generated by tools/openapigen. DO NOT EDIT.
export type Protocol = string;
export interface AllSetting {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
pageSize: number;
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
subClashPath: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subJsonEnable: boolean;
subJsonFragment: string;
subJsonMux: string;
subJsonNoises: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subTitle: string;
subURI: string;
subUpdates: number;
tgBotAPIServer: string;
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgLang: string;
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
webBasePath: string;
webCertFile: string;
webDomain: string;
webKeyFile: string;
webListen: string;
webPort: number;
}
export interface AllSettingView {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
pageSize: number;
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
subClashPath: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subJsonEnable: boolean;
subJsonFragment: string;
subJsonMux: string;
subJsonNoises: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subTitle: string;
subURI: string;
subUpdates: number;
tgBotAPIServer: string;
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgLang: string;
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
webBasePath: string;
webCertFile: string;
webDomain: string;
webKeyFile: string;
webListen: string;
webPort: number;
}
export interface ApiToken {
createdAt: number;
enabled: boolean;
id: number;
name: string;
token: string;
}
export interface Client {
auth?: string;
comment: string;
created_at?: number;
email: string;
enable: boolean;
expiryTime: number;
flow?: string;
id?: string;
limitIp: number;
password?: string;
reset: number;
reverse?: ClientReverse | null;
security: string;
subId: string;
tgId: number;
totalGB: number;
updated_at?: number;
}
export interface ClientInbound {
clientId: number;
createdAt: number;
flowOverride: string;
inboundId: number;
}
export interface ClientRecord {
auth: string;
comment: string;
createdAt: number;
email: string;
enable: boolean;
expiryTime: number;
flow: string;
id: number;
limitIp: number;
password: string;
reset: number;
reverse: unknown;
security: string;
subId: string;
tgId: number;
totalGB: number;
updatedAt: number;
uuid: string;
}
export interface ClientReverse {
tag: string;
}
export interface ClientTraffic {
down: number;
email: string;
enable: boolean;
expiryTime: number;
id: number;
inboundId: number;
lastOnline: number;
reset: number;
subId: string;
total: number;
up: number;
uuid: string;
}
export interface CustomGeoResource {
alias: string;
createdAt: number;
id: number;
lastModified: string;
lastUpdatedAt: number;
localPath: string;
type: string;
updatedAt: number;
url: string;
}
export interface FallbackParentInfo {
masterId: number;
path?: string;
}
export interface HistoryOfSeeders {
id: number;
seederName: string;
}
export interface Inbound {
clientStats: ClientTraffic[];
down: number;
enable: boolean;
expiryTime: number;
fallbackParent?: FallbackParentInfo | null;
id: number;
lastTrafficResetTime: number;
listen: string;
nodeId?: number | null;
port: number;
protocol: Protocol;
remark: string;
settings: unknown;
sniffing: unknown;
streamSettings: unknown;
tag: string;
total: number;
trafficReset: string;
up: number;
}
export interface InboundClientIps {
clientEmail: string;
id: number;
ips: unknown;
}
export interface InboundFallback {
alpn: string;
childId: number;
id: number;
masterId: number;
name: string;
path: string;
sortOrder: number;
xver: number;
}
export interface Msg {
msg: string;
obj: unknown;
success: boolean;
}
export interface Node {
address: string;
allowPrivateAddress: boolean;
apiToken: string;
basePath: string;
clientCount: number;
cpuPct: number;
createdAt: number;
depletedCount: number;
enable: boolean;
id: number;
inboundCount: number;
lastError: string;
lastHeartbeat: number;
latencyMs: number;
memPct: number;
name: string;
onlineCount: number;
panelVersion: string;
port: number;
remark: string;
scheme: string;
status: string;
updatedAt: number;
uptimeSecs: number;
xrayVersion: string;
}
export interface OutboundTraffics {
down: number;
id: number;
tag: string;
total: number;
up: number;
}
export interface Setting {
id: number;
key: string;
value: string;
}
export interface User {
id: number;
password: string;
username: string;
}
-380
View File
@@ -1,380 +0,0 @@
// Code generated by tools/openapigen. DO NOT EDIT.
import { z } from 'zod';
export const ProtocolSchema = z.string();
export type Protocol = z.infer<typeof ProtocolSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
pageSize: z.number().int().min(1).max(1000),
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(0).max(525600),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
subClashPath: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFragment: z.string(),
subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
tgBotAPIServer: z.string(),
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
webBasePath: z.string(),
webCertFile: z.string(),
webDomain: z.string(),
webKeyFile: z.string(),
webListen: z.string(),
webPort: z.number().int().min(1).max(65535),
});
export type AllSetting = z.infer<typeof AllSettingSchema>;
export const AllSettingViewSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
pageSize: z.number().int().min(1).max(1000),
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(0).max(525600),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
subClashPath: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFragment: z.string(),
subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
tgBotAPIServer: z.string(),
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
webBasePath: z.string(),
webCertFile: z.string(),
webDomain: z.string(),
webKeyFile: z.string(),
webListen: z.string(),
webPort: z.number().int().min(1).max(65535),
});
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
export const ApiTokenSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
name: z.string(),
token: z.string(),
});
export type ApiToken = z.infer<typeof ApiTokenSchema>;
export const ClientSchema = z.object({
auth: z.string().optional(),
comment: z.string(),
created_at: z.number().int().optional(),
email: z.string(),
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string().optional(),
id: z.string().optional(),
limitIp: z.number().int(),
password: z.string().optional(),
reset: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
security: z.string(),
subId: z.string(),
tgId: z.number().int(),
totalGB: z.number().int(),
updated_at: z.number().int().optional(),
});
export type Client = z.infer<typeof ClientSchema>;
export const ClientInboundSchema = z.object({
clientId: z.number().int(),
createdAt: z.number().int(),
flowOverride: z.string(),
inboundId: z.number().int(),
});
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
export const ClientRecordSchema = z.object({
auth: z.string(),
comment: z.string(),
createdAt: z.number().int(),
email: z.string(),
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string(),
id: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
reset: z.number().int(),
reverse: z.unknown(),
security: z.string(),
subId: z.string(),
tgId: z.number().int(),
totalGB: z.number().int(),
updatedAt: z.number().int(),
uuid: z.string(),
});
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export const ClientReverseSchema = z.object({
tag: z.string(),
});
export type ClientReverse = z.infer<typeof ClientReverseSchema>;
export const ClientTrafficSchema = z.object({
down: z.number().int(),
email: z.string(),
enable: z.boolean(),
expiryTime: z.number().int(),
id: z.number().int(),
inboundId: z.number().int(),
lastOnline: z.number().int(),
reset: z.number().int(),
subId: z.string(),
total: z.number().int(),
up: z.number().int(),
uuid: z.string(),
});
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
export const CustomGeoResourceSchema = z.object({
alias: z.string(),
createdAt: z.number().int(),
id: z.number().int(),
lastModified: z.string(),
lastUpdatedAt: z.number().int(),
localPath: z.string(),
type: z.string(),
updatedAt: z.number().int(),
url: z.string(),
});
export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
export const FallbackParentInfoSchema = z.object({
masterId: z.number().int(),
path: z.string().optional(),
});
export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
export const HistoryOfSeedersSchema = z.object({
id: z.number().int(),
seederName: z.string(),
});
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
export const InboundSchema = z.object({
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
down: z.number().int(),
enable: z.boolean(),
expiryTime: z.number().int(),
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
id: z.number().int(),
lastTrafficResetTime: z.number().int(),
listen: z.string(),
nodeId: z.number().int().nullable().optional(),
port: z.number().int().min(1).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'hysteria2', 'http', 'mixed', 'tunnel']),
remark: z.string(),
settings: z.unknown(),
sniffing: z.unknown(),
streamSettings: z.unknown(),
tag: z.string(),
total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
up: z.number().int(),
});
export type Inbound = z.infer<typeof InboundSchema>;
export const InboundClientIpsSchema = z.object({
clientEmail: z.string(),
id: z.number().int(),
ips: z.unknown(),
});
export type InboundClientIps = z.infer<typeof InboundClientIpsSchema>;
export const InboundFallbackSchema = z.object({
alpn: z.string(),
childId: z.number().int(),
id: z.number().int(),
masterId: z.number().int(),
name: z.string(),
path: z.string(),
sortOrder: z.number().int(),
xver: z.number().int(),
});
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const MsgSchema = z.object({
msg: z.string(),
obj: z.unknown(),
success: z.boolean(),
});
export type Msg = z.infer<typeof MsgSchema>;
export const NodeSchema = z.object({
address: z.string(),
allowPrivateAddress: z.boolean(),
apiToken: z.string(),
basePath: z.string(),
clientCount: z.number().int(),
cpuPct: z.number(),
createdAt: z.number().int(),
depletedCount: z.number().int(),
enable: z.boolean(),
id: z.number().int(),
inboundCount: z.number().int(),
lastError: z.string(),
lastHeartbeat: z.number().int(),
latencyMs: z.number().int(),
memPct: z.number(),
name: z.string(),
onlineCount: z.number().int(),
panelVersion: z.string(),
port: z.number().int().min(1).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
status: z.string(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
xrayVersion: z.string(),
});
export type Node = z.infer<typeof NodeSchema>;
export const OutboundTrafficsSchema = z.object({
down: z.number().int(),
id: z.number().int(),
tag: z.string(),
total: z.number().int(),
up: z.number().int(),
});
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
export const SettingSchema = z.object({
id: z.number().int(),
key: z.string(),
value: z.string(),
});
export type Setting = z.infer<typeof SettingSchema>;
export const UserSchema = z.object({
id: z.number().int(),
password: z.string(),
username: z.string(),
});
export type User = z.infer<typeof UserSchema>;
-502
View File
@@ -1,502 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import { markLocalInvalidate } from '@/api/invalidationTracker';
import {
ClientHydrateSchema,
ClientPageResponseSchema,
InboundOptionsSchema,
OnlinesSchema,
BulkAdjustResultSchema,
BulkAttachResultSchema,
BulkCreateResultSchema,
BulkDeleteResultSchema,
BulkDetachResultSchema,
DelDepletedResultSchema,
type ClientHydrate,
type ClientRecord,
type ClientTraffic,
type ClientsSummary,
type ClientPageResponse,
type InboundOption,
type BulkAdjustResult,
type BulkAttachResult,
type BulkCreateResult,
type BulkDeleteResult,
type BulkDetachResult,
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
}
export interface ClientQueryParams {
page: number;
pageSize: number;
search?: string;
// CSV strings — frontend joins arrays on ',', backend splits the same way.
filter?: string;
protocol?: string;
inbound?: string;
sort?: string;
order?: 'ascend' | 'descend';
expiryFrom?: number;
expiryTo?: number;
usageFrom?: number;
usageTo?: number;
autoRenew?: 'on' | 'off' | '';
hasTgId?: 'yes' | 'no' | '';
hasComment?: 'yes' | 'no' | '';
group?: string;
}
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
};
function buildQS(p: ClientQueryParams): string {
const sp = new URLSearchParams();
sp.set('page', String(p.page || 1));
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound) sp.set('inbound', p.inbound);
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
if (p.hasComment) sp.set('hasComment', p.hasComment);
if (p.group) sp.set('group', p.group);
return sp.toString();
}
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
if (!validated.obj) throw new Error('Empty clients response');
return validated.obj;
}
async function fetchInboundOptions(): Promise<InboundOption[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
return Array.isArray(validated.obj) ? validated.obj : [];
}
async function fetchDefaults(): Promise<Record<string, unknown>> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj || {};
}
export function useClients() {
const queryClient = useQueryClient();
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// setQuery shallow-compares so callers can pass a fresh object every render
// (the common React pattern) without triggering a re-fetch when nothing
// actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.inbound ?? '') === (next.inbound ?? '')
&& (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '')
&& (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
&& (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
&& (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
&& (prev.usageTo ?? 0) === (next.usageTo ?? 0)
&& (prev.autoRenew ?? '') === (next.autoRenew ?? '')
&& (prev.hasTgId ?? '') === (next.hasTgId ?? '')
&& (prev.hasComment ?? '') === (next.hasComment ?? '')
&& (prev.group ?? '') === (next.group ?? '')
) return prev;
return next;
});
}, []);
const listQuery = useQuery({
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
staleTime: Infinity,
placeholderData: keepPreviousData,
});
const inboundOptionsQuery = useQuery({
queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions,
staleTime: Infinity,
});
const defaultsQuery = useQuery({
queryKey: keys.settings.defaults(),
queryFn: fetchDefaults,
staleTime: Infinity,
});
const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(),
queryFn: async () => {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
},
staleTime: Infinity,
});
const clients = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const filtered = listQuery.data?.filtered ?? 0;
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
const allGroups = listQuery.data?.groups ?? [];
const fetched = listQuery.data !== undefined;
const loading = listQuery.isFetching;
const inbounds = inboundOptionsQuery.data ?? [];
const onlines = onlinesQuery.data ?? [];
const defaults = defaultsQuery.data ?? {};
const subSettings: SubSettings = useMemo(() => ({
enable: !!defaults.subEnable,
subURI: (defaults.subURI as string) || '',
subJsonURI: (defaults.subJsonURI as string) || '',
subJsonEnable: !!defaults.subJsonEnable,
subClashURI: (defaults.subClashURI as string) || '',
subClashEnable: !!defaults.subClashEnable,
}), [
defaults.subEnable,
defaults.subURI,
defaults.subJsonURI,
defaults.subJsonEnable,
defaults.subClashURI,
defaults.subClashEnable,
]);
const ipLimitEnable = !!defaults.ipLimitEnable;
const tgBotEnable = !!defaults.tgBotEnable;
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
const pageSize = (defaults.pageSize as number) ?? 0;
// Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
// mutate inbound rows server-side too — adding a client appends to
// settings.clients on each attached inbound, the slim list's per-inbound
// client count is derived from that. Invalidate both buckets so the
// Inbounds page and any open edit modal pick up the new shape without
// a manual reload.
const invalidateAll = useCallback(
() => {
markLocalInvalidate();
return Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
]);
},
[queryClient],
);
const refresh = useCallback(async () => {
await invalidateAll();
}, [invalidateAll]);
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
if (!email) return null;
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
if (!msg?.success || !msg.obj) return null;
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
return validated.obj;
}, []);
const createMut = useMutation({
mutationFn: (payload: unknown) =>
HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAddToGroupMut = useMutation({
mutationFn: (body: { emails: string[]; group: string }) =>
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkRemoveFromGroupMut = useMutation({
mutationFn: (body: { emails: string[] }) =>
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const removeMut = useMutation({
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
const url = keepTraffic
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
return HttpUtil.post(url);
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkDeleteMut = useMutation({
mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkCreateMut = useMutation({
mutationFn: async (payloads: unknown[]): Promise<Msg<BulkCreateResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAdjustMut = useMutation({
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
},
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),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAttachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const detachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkDetachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const resetTrafficMut = useMutation({
mutationFn: (email: string) =>
HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const resetAllTrafficsMut = useMutation({
mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const delDepletedMut = useMutation({
mutationFn: async () => {
const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
const update = useCallback((email: string, client: unknown) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return updateMut.mutateAsync({ email, client });
}, [updateMut]);
const remove = useCallback((email: string, keepTraffic = false) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return removeMut.mutateAsync({ email, keepTraffic });
}, [removeMut]);
const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
}, [bulkDeleteMut]);
const bulkCreate = useCallback((payloads: unknown[]) => {
if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
return bulkCreateMut.mutateAsync(payloads);
}, [bulkCreateMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
}, [bulkAdjustMut]);
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
}, [bulkAddToGroupMut]);
const bulkRemoveFromGroup = useCallback((emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkRemoveFromGroupMut.mutateAsync({ emails });
}, [bulkRemoveFromGroupMut]);
const attach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
return bulkAttachMut.mutateAsync({ emails, inboundIds });
}, [bulkAttachMut]);
const detach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return detachMut.mutateAsync({ email, inboundIds });
}, [detachMut]);
const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
return bulkDetachMut.mutateAsync({ emails, inboundIds });
}, [bulkDetachMut]);
const resetTraffic = useCallback((client: ClientRecord) => {
if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
return resetTrafficMut.mutateAsync(client.email);
}, [resetTrafficMut]);
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
if (!client?.email) return null;
const payload = {
email: client.email,
subId: client.subId,
id: client.uuid,
password: client.password,
auth: client.auth,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
limitIp: client.limitIp || 0,
comment: client.comment || '',
enable: !!enable,
};
return update(client.email, payload);
}, [update]);
// WS-driven in-place merges. Page wires these via useWebSocket; the bridge
// covers coarse 'invalidate' and 'inbounds' events centrally.
const queryRef = useRef(query);
queryRef.current = query;
const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[] };
if (Array.isArray(p.onlineClients)) {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
}
}, [queryClient]);
const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: (ClientTraffic & { email?: string })[] };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
if (!prev) return prev;
let touched = false;
const next = prev.items.slice();
for (let i = 0; i < next.length; i++) {
const row = next[i];
const upd = byEmail.get(row?.email);
if (!upd) continue;
const merged: ClientTraffic = { ...(row.traffic || {}) };
if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
next[i] = { ...row, traffic: merged };
touched = true;
}
if (!touched) return prev;
return { ...prev, items: next };
});
}, [queryClient]);
useEffect(() => {
queryRef.current = query;
}, [query]);
return {
clients,
total,
filtered,
summary,
allGroups,
hydrate,
query,
setQuery,
inbounds,
onlines,
loading,
fetched,
subSettings,
ipLimitEnable,
tgBotEnable,
expireDiff,
trafficDiff,
pageSize,
refresh,
create,
bulkCreate,
update,
remove,
bulkDelete,
bulkAdjust,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
bulkAttach,
detach,
bulkDetach,
resetTraffic,
resetAllTraffics,
delDepleted,
setEnable,
applyTrafficEvent,
applyClientStatsEvent,
};
}
-57
View File
@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
type Calendar = 'gregorian' | 'jalalian';
let cachedValue: Calendar = 'gregorian';
let fetched = false;
let pending: Promise<void> | null = null;
const listeners = new Set<(value: Calendar) => void>();
function notify(value: Calendar) {
listeners.forEach((fn) => fn(value));
}
async function loadOnce(): Promise<void> {
if (fetched) return;
if (pending) {
await pending;
return;
}
pending = (async () => {
try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (msg?.success) {
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
cachedValue = validated.obj?.datepicker || 'gregorian';
notify(cachedValue);
}
} finally {
fetched = true;
pending = null;
}
})();
await pending;
}
export function setDatepicker(value: Calendar) {
fetched = true;
cachedValue = value || 'gregorian';
notify(cachedValue);
}
export function useDatepicker() {
const [datepicker, setLocal] = useState<Calendar>(cachedValue);
useEffect(() => {
listeners.add(setLocal);
loadOnce();
return () => {
listeners.delete(setLocal);
};
}, []);
return { datepicker };
}
-15
View File
@@ -1,15 +0,0 @@
import { useEffect, useState } from 'react';
const MOBILE_BREAKPOINT_PX = 768;
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [breakpoint]);
return { isMobile };
}
-26
View File
@@ -1,26 +0,0 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard',
'/inbounds': 'menu.inbounds',
'/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/api-docs': 'menu.apiDocs',
};
export function usePageTitle() {
const { pathname } = useLocation();
const { t } = useTranslation();
useEffect(() => {
const key = TITLE_KEYS[pathname];
const title = key ? t(key) : '3X-UI';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
}, [pathname, t]);
}
-153
View File
@@ -1,153 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd';
const STORAGE_DARK = 'dark-mode';
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
function readBool(key: string, fallback: boolean): boolean {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return raw === 'true';
}
function applyDom(isDark: boolean, isUltra: boolean) {
document.body.setAttribute('class', isDark ? 'dark' : 'light');
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const msg = document.getElementById('message');
if (msg) msg.className = isDark ? 'dark' : 'light';
}
// module load so the document is in the right theme before React mounts.
const initialDark = readBool(STORAGE_DARK, true);
const initialUltra = readBool(STORAGE_ULTRA, false);
applyDom(initialDark, initialUltra);
const DARK_TOKENS = {
colorBgBase: '#1a1b1f',
colorBgLayout: '#1a1b1f',
colorBgContainer: '#23252b',
colorBgElevated: '#2d2f37',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
colorBgLayout: '#000',
colorBgContainer: '#101013',
colorBgElevated: '#1a1a1e',
};
const DARK_LAYOUT_TOKENS = {
bodyBg: '#1a1b1f',
headerBg: '#15161a',
headerColor: '#ffffff',
footerBg: '#1a1b1f',
siderBg: '#15161a',
triggerBg: '#23252b',
triggerColor: '#ffffff',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
bodyBg: '#000',
headerBg: '#050507',
headerColor: '#ffffff',
footerBg: '#000',
siderBg: '#050507',
triggerBg: '#1a1a1e',
triggerColor: '#ffffff',
};
const DARK_MENU_TOKENS = {
darkItemBg: '#15161a',
darkSubMenuItemBg: '#1a1b1f',
darkPopupBg: '#23252b',
};
const ULTRA_DARK_MENU_TOKENS = {
darkItemBg: '#050507',
darkSubMenuItemBg: '#000',
darkPopupBg: '#101013',
};
const DARK_CARD_TOKENS = {
colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
};
const ULTRA_DARK_CARD_TOKENS = {
colorBorderSecondary: 'rgba(255, 255, 255, 0.04)',
};
const STATISTIC_TOKENS = {
contentFontSize: 17,
titleFontSize: 11,
};
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) {
return {
algorithm: antdTheme.defaultAlgorithm,
components: {
Statistic: STATISTIC_TOKENS,
},
};
}
return {
algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS,
Statistic: STATISTIC_TOKENS,
},
};
}
export function pauseAnimationsUntilLeave(elementId: string): void {
document.documentElement.setAttribute('data-theme-animations', 'off');
const el = document.getElementById(elementId);
if (!el) return;
const restore = () => {
document.documentElement.removeAttribute('data-theme-animations');
el.removeEventListener('mouseleave', restore);
el.removeEventListener('touchend', restore);
};
el.addEventListener('mouseleave', restore);
el.addEventListener('touchend', restore);
}
interface ThemeContextValue {
isDark: boolean;
isUltra: boolean;
toggleTheme: () => void;
toggleUltra: () => void;
antdThemeConfig: ThemeConfig;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
}, [isDark, isUltra]);
const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
const value = useMemo<ThemeContextValue>(
() => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
[isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}
-32
View File
@@ -1,32 +0,0 @@
import { useEffect } from 'react';
import { WebSocketClient } from '@/api/websocket';
type Handler = (payload: unknown) => void;
interface SharedClient {
connect(): void;
on(event: string, fn: Handler): void;
off(event: string, fn: Handler): void;
}
let sharedClient: SharedClient | null = null;
function getSharedClient(): SharedClient {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath) as SharedClient;
return sharedClient;
}
export function useWebSocket(handlers: Record<string, Handler>) {
useEffect(() => {
const client = getSharedClient();
const entries = Object.entries(handlers);
for (const [event, fn] of entries) client.on(event, fn);
client.connect();
return () => {
for (const [event, fn] of entries) client.off(event, fn);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
-382
View File
@@ -1,382 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { HttpUtil, Msg, PromiseUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
OutboundTestResultSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
export type { OutboundTrafficRow, OutboundTestResult };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
result?: OutboundTestResult | null;
mode?: string;
}
export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
export interface UseXraySettingResult {
fetched: boolean;
spinning: boolean;
saveDisabled: boolean;
fetchError: string;
xraySetting: string;
setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
restartResult: string;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
testOutbound: (
index: number,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
restartXray: () => Promise<void>;
}
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
let parsed: unknown;
try {
parsed = JSON.parse(msg.obj);
} catch (e) {
const err = e as Error;
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
}
const result = XrayConfigPayloadSchema.safeParse(parsed);
if (!result.success) {
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
return parsed as XrayConfigPayload;
}
return result.data;
}
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useXraySetting(): UseXraySettingResult {
const queryClient = useQueryClient();
const configQuery = useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
});
const trafficQuery = useQuery({
queryKey: keys.xray.outboundsTraffic(),
queryFn: fetchOutboundsTraffic,
staleTime: Infinity,
});
const [saveDisabled, setSaveDisabled] = useState(true);
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [restartResult, setRestartResult] = useState('');
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
const oldOutboundTestUrlRef = useRef('');
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
templateSettingsRef.current = templateSettings;
// Seed local editor state from the config query. Runs on first fetch and
// every time the query refetches (e.g. after a successful save).
useEffect(() => {
if (!configQuery.data) return;
const obj = configQuery.data;
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncingRef.current = true;
setXraySettingState(pretty);
setTemplateSettingsState(obj.xraySetting);
oldXraySettingRef.current = pretty;
syncingRef.current = false;
setInboundTags(obj.inboundTags || []);
setClientReverseTags(obj.clientReverseTags || []);
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
setOutboundTestUrlState(nextUrl);
oldOutboundTestUrlRef.current = nextUrl;
setSaveDisabled(true);
}, [configQuery.data]);
const fetched = configQuery.data !== undefined || configQuery.isError;
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
const setXraySetting = useCallback((next: string) => {
setXraySettingState(next);
if (syncingRef.current) return;
try {
const parsed = JSON.parse(next);
syncingRef.current = true;
setTemplateSettingsState(parsed);
syncingRef.current = false;
} catch {
/* ignore — wait for user to finish */
}
}, []);
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
setTemplateSettingsState((prev) => {
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
if (next == null) return next;
if (!syncingRef.current) {
try {
syncingRef.current = true;
setXraySettingState(JSON.stringify(next, null, 2));
} finally {
syncingRef.current = false;
}
}
return next;
});
}, []);
const setOutboundTestUrl = useCallback((v: string) => {
setOutboundTestUrlState(v);
}, []);
const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]);
const fetchOutboundsTrafficCb = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
}, [queryClient]);
const saveMut = useMutation({
mutationFn: async () =>
HttpUtil.post('/panel/xray/update', {
xraySetting: xraySettingRef.current,
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
}),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
const resetTrafficMut = useMutation({
mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
});
const restartMut = useMutation({
mutationFn: async () => {
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
if (!msg?.success) return msg;
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult');
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
if (validated?.success) setRestartResult(validated.obj || '');
return msg;
},
});
const resetDefaultMut = useMutation({
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
},
onSuccess: (msg) => {
if (msg?.success && msg.obj) {
const cloned = JSON.parse(JSON.stringify(msg.obj));
setTemplateSettings(cloned);
}
},
});
const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]);
const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null;
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: true, result: null, mode },
}));
try {
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode,
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: false, result: msg.obj },
}));
return msg.obj;
}
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode },
},
}));
} catch (e) {
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: String(e), mode },
},
}));
}
return null;
},
[],
);
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
const list = templateSettingsRef.current?.outbounds || [];
if (list.length === 0 || testingAll) return;
setTestingAll(true);
try {
const concurrency = mode === 'tcp' ? 8 : 1;
const queue = list
.map((ob, i) => ({ index: i, outbound: ob }))
.filter(({ outbound }) => {
const tag = outbound?.tag;
const proto = outbound?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
return true;
});
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
}
}
const workers = Array.from(
{ length: Math.min(concurrency, queue.length) },
() => worker(),
);
await Promise.all(workers);
} finally {
setTestingAll(false);
}
}, [testingAll, testOutbound]);
useEffect(() => {
const timer = window.setInterval(() => {
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
setSaveDisabled(!(dirtyXray || dirtyUrl));
}, DIRTY_POLL_MS);
return () => window.clearInterval(timer);
}, []);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
return useMemo(
() => ({
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
}),
[
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
],
);
}
-43
View File
@@ -1,43 +0,0 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils';
import enUS from '../../../web/translation/en-US.json';
const FALLBACK = 'en-US';
const lazyModules = import.meta.glob([
'../../../web/translation/*.json',
'!../../../web/translation/en-US.json',
]);
function moduleKeyFor(code: string): string {
return `../../../web/translation/${code}.json`;
}
let active: string = LanguageManager.getLanguage();
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK;
}
export async function readyI18n() {
await i18next.use(initReactI18next).init({
lng: active,
fallbackLng: FALLBACK,
resources: { [FALLBACK]: { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
returnNull: false,
});
if (active !== FALLBACK) {
const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record<string, unknown> }>) | undefined;
if (loader) {
const mod = await loader();
const messages = (mod.default ?? mod) as Record<string, unknown>;
i18next.addResourceBundle(active, 'translation', messages, true, true);
await i18next.changeLanguage(active);
}
}
return i18next;
}
export { i18next as i18n };
-10
View File
@@ -1,10 +0,0 @@
import { Outlet } from 'react-router-dom';
import { useWebSocketBridge } from '@/api/websocketBridge';
import { usePageTitle } from '@/hooks/usePageTitle';
export default function PanelLayout() {
useWebSocketBridge();
usePageTitle();
return <Outlet />;
}
-78
View File
@@ -1,78 +0,0 @@
// Pure helpers for header-shape conversion between the panel's internal
// HeaderEntry[] form and Xray's V2-style header map. Extracted from
// XrayCommonClass.toHeaders / .toV2Headers so callers can stop relying on
// the class hierarchy. Behavior is byte-equivalent to the legacy methods —
// the shadow tests in src/test/headers.test.ts pin that.
export interface HeaderEntry {
name: string;
value: string;
}
export type V2HeaderMap = Record<string, string | string[]>;
// Expand a V2-style header map into the panel's flat HeaderEntry[]. A
// header whose value is an array yields one entry per item, preserving
// order; a string value yields a single entry. Non-object inputs (null,
// undefined, primitives) yield [].
export function toHeaders(v2Headers: unknown): HeaderEntry[] {
const out: HeaderEntry[] = [];
if (!v2Headers || typeof v2Headers !== 'object') return out;
const map = v2Headers as Record<string, unknown>;
for (const key of Object.keys(map)) {
const values = map[key];
if (typeof values === 'string') {
out.push({ name: key, value: values });
} else if (Array.isArray(values)) {
for (const v of values) {
if (typeof v === 'string') out.push({ name: key, value: v });
}
}
}
return out;
}
// Case-insensitive lookup against a wire-shape header map. The legacy
// `Inbound.getHeader(obj, name)` iterated `obj.headers` as a HeaderEntry[];
// this version reads the Record map our Zod schemas store. For repeated
// header names (string[] in TCP/WS-style maps) the first value wins —
// matches the legacy iteration order. Returns '' when missing, mirroring
// the legacy fallback so link-generator call sites stay simple.
export function getHeaderValue(
headers: Readonly<Record<string, string | string[]>> | undefined | null,
name: string,
): string {
if (!headers || typeof headers !== 'object') return '';
const lower = name.toLowerCase();
for (const key of Object.keys(headers)) {
if (key.toLowerCase() !== lower) continue;
const value = headers[key];
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value[0] ?? '';
}
return '';
}
// Collapse a HeaderEntry[] back into a V2-style header map. When `arr` is
// true (the default — matches Xray's TCP/WS/HTTP request/response shape),
// duplicate header names accumulate into a string[]. When false (used for
// WS/HTTPUpgrade/xHTTP top-level headers, sockopt portMap, etc.), the
// last value wins. Entries with empty name or value are skipped — same as
// the legacy ObjectUtil.isEmpty() filter.
export function toV2Headers(headers: HeaderEntry[], arr: boolean = true): V2HeaderMap {
const out: V2HeaderMap = {};
for (const { name, value } of headers) {
if (name == null || name === '' || value == null || value === '') continue;
if (!(name in out)) {
out[name] = arr ? [value] : value;
continue;
}
const existing = out[name];
if (arr && Array.isArray(existing)) {
existing.push(value);
} else {
out[name] = value;
}
}
return out;
}
-280
View File
@@ -1,280 +0,0 @@
import { RandomUtil, Wireguard } from '@/utils';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
// Plain-object factories for protocol clients. Each returns a Zod-parsable
// object matching the wire shape. Random fields (id, password, auth,
// email, subId) call RandomUtil at invocation time — pass them in
// `overrides` for deterministic tests or for forms that pre-seed values.
//
// These replace the legacy `new Inbound.<Settings>.<Client>()` constructors
// and the Inbound.ClientBase machinery. Callers no longer carry the
// XrayCommonClass dependency once the swap lands.
interface ClientBaseSeed {
email?: string;
subId?: string;
limitIp?: number;
totalGB?: number;
expiryTime?: number;
enable?: boolean;
tgId?: number;
comment?: string;
reset?: number;
}
interface ClientBase {
email: string;
limitIp: number;
totalGB: number;
expiryTime: number;
enable: boolean;
tgId: number;
subId: string;
comment: string;
reset: number;
}
function clientBase(seed: ClientBaseSeed = {}): ClientBase {
return {
email: seed.email ?? RandomUtil.randomLowerAndNum(10),
limitIp: seed.limitIp ?? 0,
totalGB: seed.totalGB ?? 0,
expiryTime: seed.expiryTime ?? 0,
enable: seed.enable ?? true,
tgId: seed.tgId ?? 0,
subId: seed.subId ?? RandomUtil.randomLowerAndNum(16),
comment: seed.comment ?? '',
reset: seed.reset ?? 0,
};
}
export interface VlessClientSeed extends ClientBaseSeed {
id?: string;
flow?: VlessClient['flow'];
}
export function createDefaultVlessClient(seed: VlessClientSeed = {}): VlessClient {
return {
id: seed.id ?? RandomUtil.randomUUID(),
flow: seed.flow ?? '',
...clientBase(seed),
};
}
export interface VmessClientSeed extends ClientBaseSeed {
id?: string;
security?: VmessClient['security'];
}
export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClient {
return {
id: seed.id ?? RandomUtil.randomUUID(),
security: seed.security ?? 'auto',
...clientBase(seed),
};
}
export interface TrojanClientSeed extends ClientBaseSeed {
password?: string;
}
export function createDefaultTrojanClient(seed: TrojanClientSeed = {}): TrojanClient {
return {
password: seed.password ?? RandomUtil.randomSeq(10),
...clientBase(seed),
};
}
export interface ShadowsocksClientSeed extends ClientBaseSeed {
method?: string;
password?: string;
ssMethod?: string;
}
// Shadowsocks clients ship with an empty `method` on single-user inbounds
// (the parent inbound's method is authoritative); only 2022-blake3 multi-
// user inbounds use the per-client method. Callers pass `ssMethod` to seed
// a method-specific password length when creating a multi-user client.
export function createDefaultShadowsocksClient(seed: ShadowsocksClientSeed = {}): ShadowsocksClient {
const method = seed.method ?? '';
const password = seed.password ?? RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
return {
method,
password,
...clientBase(seed),
};
}
export interface HysteriaClientSeed extends ClientBaseSeed {
auth?: string;
}
export function createDefaultHysteriaClient(seed: HysteriaClientSeed = {}): HysteriaClient {
return {
auth: seed.auth ?? RandomUtil.randomSeq(10),
...clientBase(seed),
};
}
// Inbound-settings factories. Each returns a Zod-parsable wire-shape with
// schema defaults already applied — no class instance, no XrayCommonClass.
// Callers (form modals via Step 4, InboundsPage clone via Step 5) call
// these instead of the legacy `Inbound.Settings.getSettings(protocol)`.
export function createDefaultVlessInboundSettings(): VlessInboundSettings {
return {
clients: [],
decryption: 'none',
encryption: 'none',
fallbacks: [],
};
}
export function createDefaultVmessInboundSettings(): VmessInboundSettings {
return { clients: [] };
}
export function createDefaultTrojanInboundSettings(): TrojanInboundSettings {
return { clients: [], fallbacks: [] };
}
export interface ShadowsocksInboundSeed {
method?: ShadowsocksInboundSettings['method'];
password?: string;
network?: ShadowsocksInboundSettings['network'];
ivCheck?: boolean;
}
export function createDefaultShadowsocksInboundSettings(
seed: ShadowsocksInboundSeed = {},
): ShadowsocksInboundSettings {
const method = seed.method ?? '2022-blake3-aes-256-gcm';
return {
method,
password: seed.password ?? RandomUtil.randomShadowsocksPassword(method),
network: seed.network ?? 'tcp',
clients: [],
ivCheck: seed.ivCheck ?? false,
};
}
// Hysteria v1 defaults still emit `version: 2` to match the legacy panel
// constructor — the field discriminates v1 vs v2 inside the same settings
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
export interface HysteriaInboundSeed {
version?: number;
}
export function createDefaultHysteriaInboundSettings(
seed: HysteriaInboundSeed = {},
): HysteriaInboundSettings {
return {
version: seed.version ?? 2,
clients: [],
};
}
export function createDefaultHttpInboundSettings(): HttpInboundSettings {
return {
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
allowTransparent: false,
};
}
export function createDefaultMixedInboundSettings(): MixedInboundSettings {
return {
auth: 'password',
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
udp: false,
ip: '127.0.0.1',
};
}
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
return {
portMap: {},
allowedNetwork: 'tcp,udp',
followRedirect: false,
};
}
export function createDefaultTunInboundSettings(): TunInboundSettings {
return {
name: 'xray0',
mtu: 1500,
gateway: [],
dns: [],
userLevel: 0,
autoSystemRoutingTable: [],
autoOutboundsInterface: 'auto',
};
}
export interface WireguardInboundSeed {
mtu?: number;
secretKey?: string;
noKernelTun?: boolean;
peerPrivateKey?: string;
}
export function createDefaultWireguardInboundSettings(
seed: WireguardInboundSeed = {},
): WireguardInboundSettings {
const peerKp = seed.peerPrivateKey
? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
: Wireguard.generateKeypair();
return {
mtu: seed.mtu ?? 1420,
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
peers: [{
privateKey: peerKp.privateKey,
publicKey: peerKp.publicKey,
allowedIPs: ['10.0.0.2/32'],
keepAlive: 0,
}],
noKernelTun: seed.noKernelTun ?? false,
};
}
// Protocol-aware dispatch over every inbound-settings factory. Mirrors
// the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but
// returns a plain Zod-parsable object instead of a class instance.
// Callers swapping off the class hierarchy use this in place of
// `getSettings(p)` + `.toJson()`.
export type AnyInboundSettings =
| VlessInboundSettings
| VmessInboundSettings
| TrojanInboundSettings
| ShadowsocksInboundSettings
| HysteriaInboundSettings
| HttpInboundSettings
| MixedInboundSettings
| TunInboundSettings
| TunnelInboundSettings
| WireguardInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
case 'vless': return createDefaultVlessInboundSettings();
case 'vmess': return createDefaultVmessInboundSettings();
case 'trojan': return createDefaultTrojanInboundSettings();
case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
case 'hysteria': return createDefaultHysteriaInboundSettings();
case 'http': return createDefaultHttpInboundSettings();
case 'mixed': return createDefaultMixedInboundSettings();
case 'tunnel': return createDefaultTunnelInboundSettings();
case 'tun': return createDefaultTunInboundSettings();
case 'wireguard': return createDefaultWireguardInboundSettings();
default: return null;
}
}
@@ -1,306 +0,0 @@
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
HysteriaClientSchema,
ShadowsocksClientSchema,
TrojanClientSchema,
VlessClientSchema,
VmessClientSchema,
} from '@/schemas/protocols/inbound';
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod';
// Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside
// InboundFormModal. No dependency on the legacy Inbound/DBInbound
// classes — the modal hands the raw row in, takes typed values out, and
// on submit calls formValuesToWirePayload() to get a payload ready to
// POST to /panel/api/inbounds/add or /update/:id.
export interface RawInboundRow {
port?: number;
listen?: string;
protocol?: string;
tag?: string;
settings?: unknown;
streamSettings?: unknown;
sniffing?: unknown;
up?: number;
down?: number;
total?: number;
remark?: string;
enable?: boolean;
expiryTime?: number;
trafficReset?: string;
lastTrafficResetTime?: number;
nodeId?: number | null;
clientStats?: unknown;
}
// The wire payload — settings/streamSettings/sniffing arrive as JSON
// strings, mirroring what the Go endpoints expect (xray-core wants the
// nested config slices as strings to round-trip through its loader).
export interface WireInboundPayload {
up: number;
down: number;
total: number;
remark: string;
enable: boolean;
expiryTime: number;
trafficReset: TrafficReset;
lastTrafficResetTime: number;
listen: string;
port: number;
protocol: string;
settings: string;
streamSettings: string;
sniffing: string;
tag: string;
clientStats?: unknown;
nodeId?: number;
}
function coerceJsonObject(value: unknown): Record<string, unknown> {
if (value == null) return {};
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== 'string') return {};
const trimmed = value.trim();
if (trimmed === '') return {};
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
function coerceTrafficReset(v: unknown): TrafficReset {
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
? (v as TrafficReset)
: 'never';
}
// Network values that map to a required `${network}Settings` key in
// NetworkSettingsSchema. Older saved inbounds may be missing the per-
// network sub-object (the legacy panel sometimes emitted streamSettings
// without it, and an earlier panel-side prune wrongly stripped empty
// `tcpSettings: {}` out of the wire payload). Reseat an empty object
// here so InboundFormSchema.safeParse doesn't blow up at edit time.
const NETWORK_SETTINGS_KEY: Record<string, string> = {
tcp: 'tcpSettings',
kcp: 'kcpSettings',
ws: 'wsSettings',
grpc: 'grpcSettings',
httpupgrade: 'httpupgradeSettings',
xhttp: 'xhttpSettings',
hysteria: 'hysteriaSettings',
};
function healStreamNetworkKey(stream: Record<string, unknown>): void {
const network = typeof stream.network === 'string' ? stream.network : '';
const key = NETWORK_SETTINGS_KEY[network];
if (!key) return;
if (stream[key] == null || typeof stream[key] !== 'object') {
stream[key] = {};
}
}
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
return Array.isArray(tls?.certificates) ? tls.certificates as Record<string, unknown>[] : [];
}
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
for (const c of tlsCerts(stream)) {
if (typeof c.useFile === 'boolean') continue;
const hasFile = !!c.certificateFile || !!c.keyFile;
const hasInline =
(Array.isArray(c.certificate) && c.certificate.length > 0) ||
(Array.isArray(c.key) && c.key.length > 0);
c.useFile = hasFile || !hasInline;
}
}
function stripTlsCertUseFile(stream: Record<string, unknown>): void {
for (const c of tlsCerts(stream)) delete c.useFile;
}
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
const rawStream = coerceJsonObject(row.streamSettings);
const streamSettings = Object.keys(rawStream).length > 0
? (rawStream as StreamSettings)
: undefined;
if (streamSettings) {
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
}
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
return {
remark: row.remark ?? '',
enable: row.enable ?? true,
port: row.port ?? 0,
listen: row.listen ?? '',
tag: row.tag ?? '',
expiryTime: row.expiryTime ?? 0,
sniffing,
streamSettings,
up: row.up ?? 0,
down: row.down ?? 0,
total: row.total ?? 0,
trafficReset: coerceTrafficReset(row.trafficReset),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
protocol,
settings,
} as InboundFormValues;
}
// Recursively strip undefined leaves from the wire payload. Empty arrays
// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
// shells like `tcpSettings: {}` so xray-core picks up its built-in
// defaults, and stripping them led the FE to lose required-but-empty
// arrays (vless clients, wireguard peers, etc.) which the Go side then
// serialized back as `null`. Primitive values (including 0, false, '')
// are kept verbatim.
export function pruneEmpty(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(pruneEmpty);
}
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const p = pruneEmpty(v);
if (p === undefined) continue;
out[k] = p;
}
return out;
}
return value;
}
// Per-protocol client field whitelist — the Zod schemas in
// schemas/protocols/inbound/<proto>.ts define which keys a given
// protocol's clients accept on the wire. When a global client is created
// the panel may persist cross-protocol fields on the same row (`auth` for
// hysteria, `password` for trojan, `security` for vmess, etc.); rendering
// those inside a vless inbound's settings.clients is confusing and rides
// dead weight in the wire payload. Parsing through the protocol's schema
// gives us the canonical projection.
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
switch (protocol) {
case 'vless': return VlessClientSchema;
case 'vmess': return VmessClientSchema;
case 'trojan': return TrojanClientSchema;
case 'shadowsocks': return ShadowsocksClientSchema;
case 'hysteria': return HysteriaClientSchema;
default: return null;
}
}
export function normalizeClients(protocol: string, clients: unknown): unknown {
const schema = clientSchemaForProtocol(protocol);
if (!schema || !Array.isArray(clients)) return clients;
return clients.map((c) => {
const parsed = schema.safeParse(c);
return parsed.success ? parsed.data : c;
});
}
// Sniffing normalizer matching the legacy Sniffing.toJson(): when
// disabled the payload is the bare `{ enabled: false }` regardless of
// what the form holds; when enabled, only non-default fields ride.
export function normalizeSniffing(s: Sniffing | undefined): Record<string, unknown> {
if (!s || !s.enabled) return { enabled: false };
const out: Record<string, unknown> = {
enabled: true,
destOverride: s.destOverride,
};
if (s.metadataOnly) out.metadataOnly = true;
if (s.routeOnly) out.routeOnly = true;
if (s.ipsExcluded?.length) out.ipsExcluded = s.ipsExcluded;
if (s.domainsExcluded?.length) out.domainsExcluded = s.domainsExcluded;
return out;
}
// Drops cosmetic empty-array keys that legacy XrayCommonClass.toJson()
// explicitly skipped (fallbacks/finalmask). Mutates the pruned settings
// objects in place; called AFTER pruneEmpty so we can lean on the
// already-shallow shape.
export function dropLegacyOptionalEmpties(
settings: Record<string, unknown>,
stream: Record<string, unknown> | undefined,
): void {
// VLESS/Trojan emit `fallbacks` only when non-empty.
const fb = settings.fallbacks;
if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
if (stream) {
// StreamSettings emits `finalmask` only when at least one transport
// mask exists (legacy `hasFinalMask`). Drop the whole block when all
// sub-fields are empty; otherwise drop only the empty sub-arrays so
// the wire payload doesn't carry a stray `"tcp": []` next to a
// populated UDP mask list (and vice versa).
const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
if (fm && typeof fm === 'object') {
const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
const hasQuic = fm.quicParams != null;
if (!hasTcp && !hasUdp && !hasQuic) {
delete stream.finalmask;
} else {
if (!hasTcp) delete fm.tcp;
if (!hasUdp) delete fm.udp;
}
}
// Hysteria's per-client auth lives in settings.clients[*].auth; the
// streamSettings.hysteriaSettings.auth slot is a holdover from older
// hysteria builds and serves no purpose on the inbound side, so an
// empty value shouldn't ride along in the JSON payload.
const hs = stream.hysteriaSettings as { auth?: string } | undefined;
if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
delete hs.auth;
}
}
}
export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
}
const streamPruned = values.streamSettings
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
: undefined;
if (streamPruned) stripTlsCertUseFile(streamPruned);
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
const payload: WireInboundPayload = {
up: values.up,
down: values.down,
total: values.total,
remark: values.remark,
enable: values.enable,
expiryTime: values.expiryTime,
trafficReset: values.trafficReset,
lastTrafficResetTime: values.lastTrafficResetTime,
listen: values.listen,
port: values.port,
protocol: values.protocol,
settings: JSON.stringify(settingsPruned),
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
tag: values.tag,
};
if (values.nodeId != null) payload.nodeId = values.nodeId;
return payload;
}
-55
View File
@@ -1,55 +0,0 @@
import type { Inbound } from '@/schemas/api/inbound';
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import { coerceInboundJsonField } from '@/models/dbinbound';
import { fillStreamDefaults } from './stream-defaults';
export interface DbInboundLike {
port: number;
listen: string;
protocol: string;
settings: unknown;
streamSettings: unknown;
sniffing: unknown;
tag?: string;
remark?: string;
enable?: boolean;
expiryTime?: number;
up?: number;
down?: number;
total?: number;
}
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
if (parsed.success) {
const tagged = parsed.data as { settings: Record<string, unknown> };
return { ...tagged.settings };
}
return settings;
}
export function inboundFromDb(raw: DbInboundLike): Inbound {
const rawSettings = coerceInboundJsonField(raw.settings);
const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings);
const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings);
const sniffing = coerceInboundJsonField(raw.sniffing);
const streamSettings = Object.keys(streamSettingsRaw).length === 0
? streamSettingsRaw
: fillStreamDefaults(streamSettingsRaw);
return {
protocol: raw.protocol,
port: raw.port,
listen: raw.listen ?? '',
tag: raw.tag ?? '',
remark: raw.remark ?? '',
enable: raw.enable ?? true,
expiryTime: raw.expiryTime ?? 0,
up: raw.up ?? 0,
down: raw.down ?? 0,
total: raw.total ?? 0,
settings,
streamSettings,
sniffing,
} as unknown as Inbound;
}
-946
View File
@@ -1,946 +0,0 @@
import { Base64, Wireguard } from '@/utils';
import type { Inbound } from '@/schemas/api/inbound';
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
import type {
WireguardInboundPeer,
WireguardInboundSettings,
} from '@/schemas/protocols/inbound/wireguard';
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
// support shareable links). The helpers below were previously static
// methods on the Inbound class; extracting them removes the
// XrayCommonClass dependency and lets these run against Zod-parsed data
// directly.
type ForceTls = 'same' | 'tls' | 'none';
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
return getHeaderValue(xhttp?.headers, 'host');
}
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
// reading the share link wouldn't honor them. Mirrors the legacy
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
if (!xhttp) return null;
const extra: Record<string, unknown> = {};
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
extra.xPaddingBytes = xhttp.xPaddingBytes;
}
if (xhttp.xPaddingObfsMode === true) {
extra.xPaddingObfsMode = true;
for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
const v = xhttp[k];
if (typeof v === 'string' && v.length > 0) extra[k] = v;
}
}
const stringFields = [
'uplinkHTTPMethod',
'sessionPlacement',
'sessionKey',
'seqPlacement',
'seqKey',
'uplinkDataPlacement',
'uplinkDataKey',
'scMaxEachPostBytes',
] as const;
for (const k of stringFields) {
const v = xhttp[k];
if (typeof v === 'string' && v.length > 0) extra[k] = v;
}
// Headers on the wire are a record; emit them as a map upstream's
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
if (xhttp.headers && Object.keys(xhttp.headers).length > 0) {
const headersMap: Record<string, string> = {};
for (const [name, value] of Object.entries(xhttp.headers)) {
if (name.toLowerCase() === 'host') continue;
headersMap[name] = value;
}
if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
}
return Object.keys(extra).length > 0 ? extra : null;
}
function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
if (!xhttp) return;
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
obj.x_padding_bytes = xhttp.xPaddingBytes;
}
const extra = buildXhttpExtra(xhttp);
if (!extra) return;
for (const [k, v] of Object.entries(extra)) obj[k] = v;
}
// Recursively checks whether a finalmask payload has any non-empty
// content. Empty arrays / empty objects / empty strings all return false;
// any truthy primitive returns true. Used to decide whether the link
// should carry an `fm` blob at all.
function hasShareableFinalMaskValue(value: unknown): boolean {
if (value == null) return false;
if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue);
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>).some(hasShareableFinalMaskValue);
}
if (typeof value === 'string') return value.length > 0;
return true;
}
function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
if (!finalmask) return '';
return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : '';
}
function applyFinalMaskToObj(
finalmask: FinalMaskStreamSettings | undefined,
obj: Record<string, unknown>,
): void {
const payload = serializeFinalMask(finalmask);
if (payload.length > 0) obj.fm = payload;
}
function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
if (Array.isArray(value)) return value.filter(Boolean).join(',');
return '';
}
function applyExternalProxyTLSObj(
externalProxy: ExternalProxyEntry | null | undefined,
obj: Record<string, unknown>,
security: string,
): void {
if (!externalProxy || security !== 'tls') return;
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
if (sni && sni.length > 0) obj.sni = sni;
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
const alpn = externalProxyAlpn(externalProxy.alpn);
if (alpn.length > 0) obj.alpn = alpn;
}
export interface GenVmessLinkInput {
inbound: Inbound;
address: string;
port?: number;
forceTls?: ForceTls;
remark?: string;
clientId: string;
security?: VmessSecurity;
externalProxy?: ExternalProxyEntry | null;
}
// VMess share link: `vmess://` followed by base64-encoded JSON. The JSON
// schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound
// is not vmess so dispatcher code can fall through cleanly.
export function genVmessLink(input: GenVmessLinkInput): string {
const {
inbound,
address,
port = inbound.port,
forceTls = 'same',
remark = '',
clientId,
security,
externalProxy = null,
} = input;
if (inbound.protocol !== 'vmess') return '';
const stream = inbound.streamSettings;
if (!stream) return '';
const tls = forceTls === 'same' ? stream.security : forceTls;
const obj: Record<string, unknown> = {
v: '2',
ps: remark,
add: address,
port,
id: clientId,
scy: security,
net: stream.network,
tls,
};
if (stream.network === 'tcp') {
const tcp = stream.tcpSettings;
const header = tcp.header;
if (header) {
obj.type = header.type;
if (header.type === 'http') {
const request = header.request;
if (request) {
obj.path = request.path.join(',');
const host =
getHeaderValue(header.response?.headers, 'host')
|| getHeaderValue(request.headers, 'host');
if (host) obj.host = host;
}
}
} else {
obj.type = 'none';
}
} else if (stream.network === 'kcp') {
const kcp = stream.kcpSettings;
obj.mtu = kcp.mtu;
obj.tti = kcp.tti;
} else if (stream.network === 'ws') {
const ws = stream.wsSettings;
obj.path = ws.path;
obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host');
} else if (stream.network === 'grpc') {
const grpc = stream.grpcSettings;
obj.path = grpc.serviceName;
obj.authority = grpc.authority;
if (grpc.multiMode) obj.type = 'multi';
} else if (stream.network === 'httpupgrade') {
const hu = stream.httpupgradeSettings;
obj.path = hu.path;
obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host');
} else if (stream.network === 'xhttp') {
const xhttp = stream.xhttpSettings;
obj.path = xhttp.path;
obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
obj.type = xhttp.mode;
applyXhttpExtraToObj(xhttp, obj);
}
applyFinalMaskToObj(stream.finalmask, obj);
if (tls === 'tls' && stream.security === 'tls') {
const tlsSettings = stream.tlsSettings;
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
}
}
applyExternalProxyTLSObj(externalProxy, obj, tls);
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
}
// Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
// legacy applyXhttpExtraToParams / applyFinalMaskToParams /
// applyExternalProxyTLSParams but write to a URLSearchParams instance
// directly. Number values get coerced via .toString() on set — same as
// what URLSearchParams does internally so the resulting URL bytes match.
function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
if (!xhttp) return;
params.set('path', xhttp.path);
const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
params.set('host', host);
params.set('mode', xhttp.mode);
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
params.set('x_padding_bytes', xhttp.xPaddingBytes);
}
const extra = buildXhttpExtra(xhttp);
if (extra) params.set('extra', JSON.stringify(extra));
}
function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
const payload = serializeFinalMask(finalmask);
if (payload.length > 0) params.set('fm', payload);
}
function applyExternalProxyTLSParams(
externalProxy: ExternalProxyEntry | null | undefined,
params: URLSearchParams,
security: string,
): void {
if (!externalProxy || security !== 'tls') return;
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
if (sni && sni.length > 0) params.set('sni', sni);
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
const alpn = externalProxyAlpn(externalProxy.alpn);
if (alpn.length > 0) params.set('alpn', alpn);
}
export interface GenVlessLinkInput {
inbound: Inbound;
address: string;
port?: number;
forceTls?: ForceTls;
remark?: string;
clientId: string;
flow?: VlessClient['flow'];
externalProxy?: ExternalProxyEntry | null;
}
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
// query carries network type, encryption, network-specific knobs, and
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
// pbk/sid/spx). Returns '' if the inbound isn't vless.
export function genVlessLink(input: GenVlessLinkInput): string {
const {
inbound,
address,
port = inbound.port,
forceTls = 'same',
remark = '',
clientId,
flow = '',
externalProxy = null,
} = input;
if (inbound.protocol !== 'vless') return '';
const stream = inbound.streamSettings;
if (!stream) return '';
const security = forceTls === 'same' ? stream.security : forceTls;
const params = new URLSearchParams();
params.set('type', stream.network);
params.set('encryption', inbound.settings.encryption);
if (stream.network === 'tcp') {
const tcp = stream.tcpSettings;
if (tcp.header?.type === 'http') {
const request = tcp.header.request;
if (request) {
params.set('path', request.path.join(','));
const host =
getHeaderValue(tcp.header.response?.headers, 'host')
|| getHeaderValue(request.headers, 'host');
if (host) params.set('host', host);
params.set('headerType', 'http');
}
}
} else if (stream.network === 'kcp') {
const kcp = stream.kcpSettings;
params.set('mtu', String(kcp.mtu));
params.set('tti', String(kcp.tti));
} else if (stream.network === 'ws') {
const ws = stream.wsSettings;
params.set('path', ws.path);
params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
} else if (stream.network === 'grpc') {
const grpc = stream.grpcSettings;
params.set('serviceName', grpc.serviceName);
params.set('authority', grpc.authority);
if (grpc.multiMode) params.set('mode', 'multi');
} else if (stream.network === 'httpupgrade') {
const hu = stream.httpupgradeSettings;
params.set('path', hu.path);
params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
} else if (stream.network === 'xhttp') {
applyXhttpExtraToParams(stream.xhttpSettings, params);
}
applyFinalMaskToParams(stream.finalmask, params);
if (security === 'tls') {
params.set('security', 'tls');
if (stream.security === 'tls') {
const tls = stream.tlsSettings;
params.set('fp', tls.settings.fingerprint);
params.set('alpn', tls.alpn.join(','));
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
params.set('security', 'reality');
if (stream.security === 'reality') {
const reality = stream.realitySettings;
params.set('pbk', reality.settings.publicKey);
params.set('fp', reality.settings.fingerprint);
const sni =
reality.settings.serverName ||
reality.serverNames?.[0] ||
reality.target?.split(':')[0];
if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
} else {
params.set('security', 'none');
}
const url = new URL(`vless://${clientId}@${address}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
}
// Shared network-branch writer used by trojan + shadowsocks links.
// VLESS and VMess don't call this because they have minor per-protocol
// quirks inline (vmess maps `multi` differently into obj.type; vless sets
// encryption=none up-front).
function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
if (stream.network === 'tcp') {
const tcp = stream.tcpSettings;
if (tcp.header?.type === 'http') {
const request = tcp.header.request;
if (request) {
params.set('path', request.path.join(','));
const host =
getHeaderValue(tcp.header.response?.headers, 'host')
|| getHeaderValue(request.headers, 'host');
if (host) params.set('host', host);
params.set('headerType', 'http');
}
}
} else if (stream.network === 'kcp') {
const kcp = stream.kcpSettings;
params.set('mtu', String(kcp.mtu));
params.set('tti', String(kcp.tti));
} else if (stream.network === 'ws') {
const ws = stream.wsSettings;
params.set('path', ws.path);
params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
} else if (stream.network === 'grpc') {
const grpc = stream.grpcSettings;
params.set('serviceName', grpc.serviceName);
params.set('authority', grpc.authority);
if (grpc.multiMode) params.set('mode', 'multi');
} else if (stream.network === 'httpupgrade') {
const hu = stream.httpupgradeSettings;
params.set('path', hu.path);
params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
} else if (stream.network === 'xhttp') {
applyXhttpExtraToParams(stream.xhttpSettings, params);
}
}
function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
if (stream.security !== 'tls') return;
const tls = stream.tlsSettings;
params.set('fp', tls.settings.fingerprint);
params.set('alpn', tls.alpn.join(','));
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
}
// Reality query-string writer shared by VLESS and Trojan. Preserves the
// legacy SNI-omission quirk (see genVlessLink for the full story).
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
if (stream.security !== 'reality') return;
const reality = stream.realitySettings;
params.set('pbk', reality.settings.publicKey);
params.set('fp', reality.settings.fingerprint);
const sni =
reality.settings.serverName ||
reality.serverNames?.[0] ||
reality.target?.split(':')[0];
if (sni && sni.length > 0) params.set('sni', sni);
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
}
export interface GenTrojanLinkInput {
inbound: Inbound;
address: string;
port?: number;
forceTls?: ForceTls;
remark?: string;
clientPassword: string;
externalProxy?: ExternalProxyEntry | null;
}
// Trojan share link: trojan://<password>@<host>:<port>?<query>#<remark>.
// Same query-string shape as VLESS minus the `encryption` and `flow`
// fields. Returns '' if the inbound isn't trojan.
export function genTrojanLink(input: GenTrojanLinkInput): string {
const {
inbound,
address,
port = inbound.port,
forceTls = 'same',
remark = '',
clientPassword,
externalProxy = null,
} = input;
if (inbound.protocol !== 'trojan') return '';
const stream = inbound.streamSettings;
if (!stream) return '';
const security = forceTls === 'same' ? stream.security : forceTls;
const params = new URLSearchParams();
params.set('type', stream.network);
writeNetworkParams(stream, params);
applyFinalMaskToParams(stream.finalmask, params);
if (security === 'tls') {
params.set('security', 'tls');
writeTlsParams(stream, params);
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
params.set('security', 'reality');
writeRealityParams(stream, params);
} else {
params.set('security', 'none');
}
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
}
export interface GenShadowsocksLinkInput {
inbound: Inbound;
address: string;
port?: number;
forceTls?: ForceTls;
remark?: string;
clientPassword?: string;
externalProxy?: ExternalProxyEntry | null;
}
// Shadowsocks 2022 share link. The userinfo portion is base64(method:pw)
// for single-user and base64(method:settingsPw:clientPw) for multi-user
// 2022-blake3. Legacy SS (non-2022) leaves the password out of the
// userinfo entirely — matches the legacy class's password-array logic.
// Note: legacy `isSSMultiUser` returns true for everything except
// 2022-blake3-chacha20-poly1305 (a curious classification, but we
// preserve it for byte-stable parity).
export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
const {
inbound,
address,
port = inbound.port,
forceTls = 'same',
remark = '',
clientPassword = '',
externalProxy = null,
} = input;
if (inbound.protocol !== 'shadowsocks') return '';
const stream = inbound.streamSettings;
if (!stream) return '';
const settings = inbound.settings;
const security = forceTls === 'same' ? stream.security : forceTls;
const params = new URLSearchParams();
params.set('type', stream.network);
writeNetworkParams(stream, params);
applyFinalMaskToParams(stream.finalmask, params);
if (security === 'tls') {
params.set('security', 'tls');
writeTlsParams(stream, params);
applyExternalProxyTLSParams(externalProxy, params, security);
}
const isSS2022 = settings.method.substring(0, 4) === '2022';
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
const passwords: string[] = [];
if (isSS2022) passwords.push(settings.password);
if (isSSMultiUser) passwords.push(clientPassword);
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
const url = new URL(`ss://${userinfo}@${address}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
}
export interface GenHysteriaLinkInput {
inbound: Inbound;
address: string;
port?: number;
remark?: string;
clientAuth: string;
}
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
// password from finalmask.udp[type=salamander] when present; the broader
// finalmask payload still rides under `fm` like the other links.
//
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
// which isn't a field on TlsStreamSettings.Settings — the guard is always
// false. We omit the `insecure` param here to stay byte-stable.
export function genHysteriaLink(input: GenHysteriaLinkInput): string {
const {
inbound,
address,
port = inbound.port,
remark = '',
clientAuth,
} = input;
if (inbound.protocol !== 'hysteria') return '';
const stream = inbound.streamSettings;
if (!stream || stream.security !== 'tls') return '';
const settings = inbound.settings;
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
const params = new URLSearchParams();
params.set('security', 'tls');
const tls = stream.tlsSettings;
if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
const udpMasks = stream.finalmask?.udp;
if (Array.isArray(udpMasks)) {
const salamander = udpMasks.find((m) => m?.type === 'salamander');
const obfsPassword = salamander?.settings?.password;
if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
params.set('obfs', 'salamander');
params.set('obfs-password', obfsPassword);
}
}
applyFinalMaskToParams(stream.finalmask, params);
const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
}
export interface GenWireguardLinkInput {
settings: WireguardInboundSettings;
address: string;
port: number;
remark?: string;
peerIndex: number;
}
// Wireguard share link: wireguard://<peerPrivKey>@<host>:<port>
// ?publickey=<serverPub>&address=<peerAllowedIP>&mtu=<mtu>#<remark>
// pubKey is derived from the server's secretKey via Wireguard.generateKeypair
// at call time (Zod's schema stores secretKey only — pubKey isn't on the
// wire). Returns '' when the peer index is out of bounds.
export function genWireguardLink(input: GenWireguardLinkInput): string {
const { settings, address, port, remark = '', peerIndex } = input;
const peer = settings.peers[peerIndex];
if (!peer) return '';
const url = new URL(`wireguard://${address}:${port}`);
url.username = peer.privateKey ?? '';
const pubKey = settings.secretKey.length > 0
? Wireguard.generateKeypair(settings.secretKey).publicKey
: '';
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
url.searchParams.set('address', peer.allowedIPs[0]);
}
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
url.searchParams.set('mtu', String(settings.mtu));
}
url.hash = encodeURIComponent(remark);
return url.toString();
}
// Plain-text WireGuard client config (.conf format). Mirrors the legacy
// getWireguardTxt — same DNS defaults (1.1.1.1, 1.0.0.1), MTU optional,
// presharedKey + keepAlive only emitted when present on the peer. The
// final newline structure follows the legacy: no newline after Endpoint,
// optional preSharedKey appended with leading \n, keepAlive appended
// with leading \n AND trailing \n.
export function genWireguardConfig(input: GenWireguardLinkInput): string {
const { settings, address, port, remark = '', peerIndex } = input;
const peer = settings.peers[peerIndex];
if (!peer) return '';
const pubKey = settings.secretKey.length > 0
? Wireguard.generateKeypair(settings.secretKey).publicKey
: '';
let txt = `[Interface]\n`;
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
txt += `MTU = ${settings.mtu}\n`;
}
txt += `\n# ${remark}\n`;
txt += `[Peer]\n`;
txt += `PublicKey = ${pubKey}\n`;
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
txt += `Endpoint = ${address}:${port}`;
if (peer.preSharedKey && peer.preSharedKey.length > 0) {
txt += `\nPresharedKey = ${peer.preSharedKey}`;
}
if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
}
return txt;
}
export type { WireguardInboundPeer };
// Orchestrators.
// resolveAddr picks the host that goes into share/sub links. Order:
// 1. hostOverride (caller supplies node address for node-managed inbounds)
// 2. inbound's bind listen (when explicit, not 0.0.0.0)
// 3. fallbackHostname (caller-supplied — typically window.location.hostname
// in the browser; tests pass a fixed value)
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
if (hostOverride.length > 0) return hostOverride;
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen;
return fallbackHostname;
}
// Returns the client array for protocols that have one. SS returns its
// clients only in 2022-blake3 multi-user mode (matches the legacy
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
// clients, and any protocol without a clients array.
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string };
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
switch (inbound.protocol) {
case 'vmess':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'vless':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'trojan':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'hysteria':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'shadowsocks': {
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
}
default:
return null;
}
}
export interface GenLinkInput {
inbound: Inbound;
address: string;
port?: number;
forceTls?: ForceTls;
remark?: string;
client: ClientShape;
externalProxy?: ExternalProxyEntry | null;
}
// Per-protocol dispatcher matching the legacy `genLink` switch. Returns
// '' for protocols that don't have client-based share links (wireguard
// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
// don't have share URLs).
export function genLink(input: GenLinkInput): string {
const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
switch (inbound.protocol) {
case 'vmess':
return genVmessLink({
inbound, address, port, forceTls, remark,
clientId: client.id ?? '',
security: client.security,
externalProxy,
});
case 'vless':
return genVlessLink({
inbound, address, port, forceTls, remark,
clientId: client.id ?? '',
flow: client.flow,
externalProxy,
});
case 'shadowsocks': {
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
return genShadowsocksLink({
inbound, address, port, forceTls, remark,
clientPassword: isMultiUser ? (client.password ?? '') : '',
externalProxy,
});
}
case 'trojan':
return genTrojanLink({
inbound, address, port, forceTls, remark,
clientPassword: client.password ?? '',
externalProxy,
});
case 'hysteria':
return genHysteriaLink({
inbound, address, port, remark,
clientAuth: client.auth ?? '',
});
default:
return '';
}
}
export interface GenAllLinksEntry {
remark: string;
link: string;
}
export interface GenAllLinksInput {
inbound: Inbound;
remark?: string;
remarkModel?: string;
client: ClientShape;
hostOverride?: string;
fallbackHostname: string;
}
// Fans out a single client's link per externalProxy entry, or just one
// link when there are no external proxies. remarkModel is a 4-char
// string: first char is the separator, remaining chars pick which
// pieces to compose into the per-link remark — 'i' = inbound remark,
// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
// (dash-separated, inbound + email + proxy).
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
const {
inbound,
remark = '',
remarkModel = '-io',
client,
hostOverride = '',
fallbackHostname,
} = input;
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const port = inbound.port;
const separationChar = remarkModel.charAt(0);
const orderChars = remarkModel.slice(1);
const email = client.email ?? '';
const composeRemark = (proxyRemark: string): string => {
const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
return orderChars.split('')
.map((c) => orders[c] ?? '')
.filter((x) => x.length > 0)
.join(separationChar);
};
const externals = inbound.streamSettings?.externalProxy;
if (!externals || externals.length === 0) {
const r = composeRemark('');
return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
}
return externals.map((ep) => {
const r = composeRemark(ep.remark);
return {
remark: r,
link: genLink({
inbound,
address: ep.dest,
port: ep.port,
forceTls: ep.forceTls,
remark: r,
client,
externalProxy: ep,
}),
};
});
}
export interface GenInboundLinksInput {
inbound: Inbound;
remark?: string;
remarkModel?: string;
hostOverride?: string;
fallbackHostname: string;
}
// Top-level entrypoint that produces the full \r\n-joined block a user
// pastes into a client. Iterates per-client for protocols with clients,
// falls back to a single SS link for single-user 2022-blake3-chacha20,
// and emits per-peer .conf blocks for wireguard. Returns '' for the
// other clientless protocols (http, mixed, tunnel).
export function genInboundLinks(input: GenInboundLinksInput): string {
const {
inbound,
remark = '',
remarkModel = '-io',
hostOverride = '',
fallbackHostname,
} = input;
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const clients = getInboundClients(inbound);
if (clients) {
const links: string[] = [];
for (const client of clients) {
const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
for (const e of entries) links.push(e.link);
}
return links.join('\r\n');
}
if (inbound.protocol === 'shadowsocks') {
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
}
if (inbound.protocol === 'wireguard') {
return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
}
return '';
}
// Per-peer wireguard fanout. Each peer gets its own link (or .conf
// block) with an index-suffixed remark, joined by \r\n. Matches the
// legacy genWireguardLinks / genWireguardConfigs exactly.
export interface GenWireguardFanoutInput {
inbound: Inbound;
remark?: string;
remarkModel?: string;
hostOverride?: string;
fallbackHostname: string;
}
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'wireguard') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = remarkModel.charAt(0);
return inbound.settings.peers
.map((_p, i) => genWireguardLink({
settings: inbound.settings as WireguardInboundSettings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}`,
peerIndex: i,
}))
.join('\r\n');
}
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'wireguard') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = remarkModel.charAt(0);
return inbound.settings.peers
.map((_p, i) => genWireguardConfig({
settings: inbound.settings as WireguardInboundSettings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}`,
peerIndex: i,
}))
.join('\r\n');
}
-167
View File
@@ -1,167 +0,0 @@
import { RandomUtil, Wireguard } from '@/utils';
import type { BlackholeOutboundSettings } from '@/schemas/protocols/outbound/blackhole';
import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
import type { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
import type { SocksOutboundSettings } from '@/schemas/protocols/outbound/socks';
import type { TrojanOutboundSettings } from '@/schemas/protocols/outbound/trojan';
import type { VlessOutboundSettings } from '@/schemas/protocols/outbound/vless';
import type { VmessOutboundSettings } from '@/schemas/protocols/outbound/vmess';
import type { WireguardOutboundSettings } from '@/schemas/protocols/outbound/wireguard';
// Plain-object factories mirroring `new Outbound.<X>Settings()` from the
// legacy class hierarchy, then `.toJson()`. The output matches the wire
// shape — the same starting state the OutboundFormModal's `ob.settings`
// holds the first time the user picks a protocol.
//
// Required-by-schema fields the legacy class leaves undefined (address,
// port, user-supplied ids/passwords) become empty stubs here. Zod will
// reject the default output until the user fills them in via the form;
// this is intentional and matches the legacy "scaffold object" behavior.
export function createDefaultFreedomOutboundSettings(): FreedomOutboundSettings {
return {};
}
export function createDefaultBlackholeOutboundSettings(): BlackholeOutboundSettings {
return {};
}
export function createDefaultLoopbackOutboundSettings(): LoopbackOutboundSettings {
return { inboundTag: '' };
}
export function createDefaultDNSOutboundSettings(): DNSOutboundSettings {
return {
rewriteNetwork: '',
rewriteAddress: '',
rewritePort: 53,
userLevel: 0,
rules: [],
};
}
export function createDefaultVmessOutboundSettings(): VmessOutboundSettings {
return {
vnext: [{
address: '',
port: 443,
users: [{ id: '', security: 'auto' }],
}],
};
}
export function createDefaultVlessOutboundSettings(): VlessOutboundSettings {
return {
address: '',
port: 443,
id: '',
flow: '',
encryption: 'none',
};
}
export function createDefaultTrojanOutboundSettings(): TrojanOutboundSettings {
return {
servers: [{ address: '', port: 443, password: '' }],
};
}
// Why: legacy constructor leaves method undefined; the form's Select
// snaps to the first option when the user opens it. We pick the same
// modern default the inbound shadowsocks factory uses
// (2022-blake3-aes-128-gcm) so the OutboundFormModal renders a coherent
// initial state instead of an empty Select.
export function createDefaultShadowsocksOutboundSettings(): ShadowsocksOutboundSettings {
return {
servers: [{
address: '',
port: 443,
password: '',
method: '2022-blake3-aes-128-gcm',
}],
};
}
export function createDefaultSocksOutboundSettings(): SocksOutboundSettings {
return {
servers: [{ address: '', port: 1080, users: [] }],
};
}
export function createDefaultHttpOutboundSettings(): HttpOutboundSettings {
return {
servers: [{ address: '', port: 8080, users: [] }],
};
}
interface WireguardOutboundSeed {
secretKey?: string;
}
export function createDefaultWireguardOutboundSettings(
seed: WireguardOutboundSeed = {},
): WireguardOutboundSettings {
const secretKey = seed.secretKey ?? Wireguard.generateKeypair().privateKey;
return {
mtu: 1420,
secretKey,
address: [],
workers: 2,
peers: [{
publicKey: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
}],
noKernelTun: false,
};
}
export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSettings {
return { address: '', port: 443, version: 2 };
}
export type AnyOutboundSettings =
| BlackholeOutboundSettings
| DNSOutboundSettings
| FreedomOutboundSettings
| HttpOutboundSettings
| HysteriaOutboundSettings
| LoopbackOutboundSettings
| ShadowsocksOutboundSettings
| SocksOutboundSettings
| TrojanOutboundSettings
| VlessOutboundSettings
| VmessOutboundSettings
| WireguardOutboundSettings;
// Protocol-aware dispatch. Mirrors the legacy
// `Outbound.Settings.getSettings(protocol)` switch. Note: the inbound
// dispatcher returns `null` for unknown protocols and so does this one,
// keeping the contract identical so callers can stay protocol-agnostic.
//
// The `RandomUtil` reference is held to silence unused-import warnings
// when no per-call randomization happens at the dispatcher level —
// individual factories may pull from it via their own seeds.
export function createDefaultOutboundSettings(protocol: string): AnyOutboundSettings | null {
void RandomUtil;
switch (protocol) {
case 'freedom': return createDefaultFreedomOutboundSettings();
case 'blackhole': return createDefaultBlackholeOutboundSettings();
case 'dns': return createDefaultDNSOutboundSettings();
case 'vmess': return createDefaultVmessOutboundSettings();
case 'vless': return createDefaultVlessOutboundSettings();
case 'trojan': return createDefaultTrojanOutboundSettings();
case 'shadowsocks': return createDefaultShadowsocksOutboundSettings();
case 'socks': return createDefaultSocksOutboundSettings();
case 'http': return createDefaultHttpOutboundSettings();
case 'wireguard': return createDefaultWireguardOutboundSettings();
case 'hysteria': return createDefaultHysteriaOutboundSettings();
case 'loopback': return createDefaultLoopbackOutboundSettings();
default: return null;
}
}
@@ -1,619 +0,0 @@
import { Wireguard } from '@/utils';
import type {
DnsOutboundFormSettings,
DnsRuleForm,
FreedomFinalRuleForm,
FreedomOutboundFormSettings,
HysteriaOutboundFormSettings,
LoopbackOutboundFormSettings,
MuxForm,
OutboundFormSettings,
OutboundFormValues,
OutboundStreamFormValues,
ReverseSniffingForm,
ShadowsocksOutboundFormSettings,
TrojanOutboundFormSettings,
VlessOutboundFormSettings,
VmessOutboundFormSettings,
WireguardOutboundFormPeer,
WireguardOutboundFormSettings,
} from '@/schemas/forms/outbound-form';
type Raw = Record<string, unknown>;
function asObject(value: unknown): Raw {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
return fallback;
}
function asBool(value: unknown): boolean {
return value === true;
}
function asPort(value: unknown, fallback: number): number {
const n = asNumber(value, fallback);
if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
return n;
}
const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
};
function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
const r = asObject(raw);
const dest = asArray(r.destOverride).map((x) => asString(x));
return {
enabled: asBool(r.enabled),
destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: asBool(r.metadataOnly),
routeOnly: asBool(r.routeOnly),
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
};
}
function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
const vnext = asArray(raw.vnext);
const v = asObject(vnext[0]);
const u = asObject(asArray(v.users)[0]);
return {
address: asString(v.address),
port: asPort(v.port, 443),
id: asString(u.id),
security: ((): VmessOutboundFormSettings['security'] => {
const s = asString(u.security);
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
})(),
};
}
function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
let address = asString(raw.address);
let port = asPort(raw.port, 443);
let id = asString(raw.id);
let flow = asString(raw.flow);
let encryption = asString(raw.encryption, 'none');
const vnext = asArray(raw.vnext);
if (vnext.length > 0) {
const v = asObject(vnext[0]);
const u = asObject(asArray(v.users)[0]);
address = asString(v.address);
port = asPort(v.port, 443);
id = asString(u.id);
flow = asString(u.flow);
encryption = asString(u.encryption, 'none');
}
const reverse = asObject(raw.reverse);
const reverseTag = asString(reverse.tag);
const reverseSniffing = reverseTag
? reverseSniffingFromWire(reverse.sniffing)
: REVERSE_SNIFFING_DEFAULT;
const savedSeed = asArray(raw.testseed);
const testseed = savedSeed.length === 4
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
? (savedSeed as number[])
: [900, 500, 900, 256];
return {
address,
port,
id,
flow,
encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
reverseTag,
reverseSniffing,
testpre: asNumber(raw.testpre, 0),
testseed,
};
}
function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
const s = asObject(asArray(raw.servers)[0]);
return {
address: asString(s.address),
port: asPort(s.port, 443),
password: asString(s.password),
};
}
function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
const s = asObject(asArray(raw.servers)[0]);
return {
address: asString(s.address),
port: asPort(s.port, 443),
password: asString(s.password),
method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
uot: asBool(s.uot),
UoTVersion: asNumber(s.UoTVersion, 1),
};
}
interface SimpleAuthFormSettings {
address: string;
port: number;
user: string;
pass: string;
}
function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
const s = asObject(asArray(raw.servers)[0]);
const u = asObject(asArray(s.users)[0]);
return {
address: asString(s.address),
port: asPort(s.port, defaultPort),
user: asString(u.user),
pass: asString(u.pass),
};
}
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
const secretKey = asString(raw.secretKey);
const pubKey = secretKey.length > 0
? Wireguard.generateKeypair(secretKey).publicKey
: '';
const addressArr = asArray(raw.address).map((x) =>
typeof x === 'number' ? String(x) : asString(x),
);
const reservedArr = asArray(raw.reserved).map((x) =>
typeof x === 'number' ? String(x) : asString(x),
);
const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
const pp = asObject(p);
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
return {
publicKey: asString(pp.publicKey),
psk: asString(pp.preSharedKey),
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
endpoint: asString(pp.endpoint),
keepAlive: asNumber(pp.keepAlive, 0),
};
});
return {
mtu: asNumber(raw.mtu, 1420),
secretKey,
pubKey,
address: addressArr.join(','),
workers: asNumber(raw.workers, 2),
domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
const s = asString(raw.domainStrategy);
return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
})(),
reserved: reservedArr.join(','),
peers,
noKernelTun: asBool(raw.noKernelTun),
};
}
function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
return {
address: asString(raw.address),
port: asPort(raw.port, 443),
version: 2,
};
}
function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
const fragment = asObject(raw.fragment);
const noises = asArray(raw.noises).map((n) => {
const nn = asObject(n);
return {
type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
packet: asString(nn.packet, '10-20'),
delay: asString(nn.delay, '10-16'),
applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
};
});
const finalRulesRaw = asArray(raw.finalRules);
const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
const rr = asObject(r);
const network = Array.isArray(rr.network)
? rr.network.map((x) => asString(x)).join(',')
: asString(rr.network);
return {
action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
network,
port: asString(rr.port),
ip: asArray(rr.ip).map((x) => asString(x)),
blockDelay: asString(rr.blockDelay),
};
});
// Legacy ipsBlocked → finalRule(block) backfill
if (finalRules.length === 0) {
const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
if (ipsBlocked.length > 0) {
finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
}
}
// Wire fragment is either missing or a populated object. Mirror the
// legacy behavior: when the wire omits fragment, leave all four fields
// empty so the modal's "Fragment" Switch starts off. When present,
// surface whatever the wire holds verbatim.
const wireHasFragment = raw.fragment != null
&& typeof raw.fragment === 'object'
&& Object.keys(fragment).length > 0;
return {
domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
const allowed = [
'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
];
const s = asString(raw.domainStrategy);
return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
})(),
redirect: asString(raw.redirect),
fragment: wireHasFragment
? {
packets: asString(fragment.packets, '1-3'),
length: asString(fragment.length),
interval: asString(fragment.interval),
maxSplit: asString(fragment.maxSplit),
}
: { packets: '', length: '', interval: '', maxSplit: '' },
noises,
finalRules,
};
}
function blackholeFromWire(raw: Raw) {
const response = asObject(raw.response);
const t = asString(response.type);
return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
}
function dnsRuleFromWire(raw: unknown): DnsRuleForm {
const r = asObject(raw);
const qtype = Array.isArray(r.qtype)
? r.qtype.map((x) => String(x)).join(',')
: typeof r.qtype === 'number'
? String(r.qtype)
: asString(r.qtype);
const domain = Array.isArray(r.domain)
? r.domain.map((x) => asString(x)).join(',')
: asString(r.domain);
const action = asString(r.action, 'direct');
const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action)
? action
: 'direct';
return { action: validAction as DnsRuleForm['action'], qtype, domain };
}
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
const rules = asArray(raw.rules).map(dnsRuleFromWire);
return {
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
const s = asString(raw.rewriteNetwork ?? raw.network);
return (s === 'udp' || s === 'tcp') ? s : '';
})(),
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
userLevel: asNumber(raw.userLevel, 0),
rules,
};
}
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
return { inboundTag: asString(raw.inboundTag) };
}
function muxFromWire(raw: unknown): MuxForm {
const m = asObject(raw);
return {
enabled: asBool(m.enabled),
concurrency: asNumber(m.concurrency, 8),
xudpConcurrency: asNumber(m.xudpConcurrency, 16),
xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
const s = asString(m.xudpProxyUDP443, 'reject');
return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
})(),
};
}
export interface RawOutboundRow {
tag?: string;
protocol?: string;
sendThrough?: string;
settings?: unknown;
streamSettings?: unknown;
mux?: unknown;
}
export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
const protocol = asString(raw.protocol, 'vless');
const settings = asObject(raw.settings);
const tag = asString(raw.tag);
const sendThrough = asString(raw.sendThrough);
const mux = muxFromWire(raw.mux);
const hasStream = raw.streamSettings
&& typeof raw.streamSettings === 'object'
&& Object.keys(raw.streamSettings as Raw).length > 0;
const streamSettings = hasStream
? (raw.streamSettings as unknown as OutboundStreamFormValues)
: undefined;
let typed: OutboundFormSettings;
switch (protocol) {
case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
case 'http': typed = { protocol: 'http', settings: simpleAuthFromWire(settings, 8080) }; break;
case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
case 'blackhole': typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) }; break;
case 'dns': typed = { protocol: 'dns', settings: dnsFromWire(settings) }; break;
case 'loopback': typed = { protocol: 'loopback', settings: loopbackFromWire(settings) }; break;
default: typed = { protocol: 'vless', settings: vlessFromWire(settings) };
}
return {
...typed,
tag,
sendThrough,
mux,
streamSettings,
};
}
// --- Form values -> wire payload --------------------------------------
function vmessToWire(s: VmessOutboundFormSettings) {
return {
vnext: [{
address: s.address,
port: s.port,
users: [{ id: s.id, security: s.security }],
}],
};
}
function reverseSniffingToWire(s: ReverseSniffingForm) {
return {
enabled: s.enabled,
destOverride: s.destOverride,
metadataOnly: s.metadataOnly,
routeOnly: s.routeOnly,
ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
};
}
function vlessToWire(s: VlessOutboundFormSettings) {
const result: Raw = {
address: s.address,
port: s.port,
id: s.id,
flow: s.flow,
encryption: s.encryption || 'none',
};
if (s.reverseTag) {
const sn = reverseSniffingToWire(s.reverseSniffing);
const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
result.reverse = {
tag: s.reverseTag,
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
};
}
if (s.flow === 'xtls-rprx-vision') {
if (s.testpre > 0) result.testpre = s.testpre;
if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
result.testseed = s.testseed;
}
}
return result;
}
function trojanToWire(s: TrojanOutboundFormSettings) {
return { servers: [{ address: s.address, port: s.port, password: s.password }] };
}
function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
return {
servers: [{
address: s.address,
port: s.port,
password: s.password,
method: s.method,
uot: s.uot,
UoTVersion: s.UoTVersion,
}],
};
}
function simpleAuthToWire(s: SimpleAuthFormSettings) {
return {
servers: [{
address: s.address,
port: s.port,
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
}],
};
}
function wireguardToWire(s: WireguardOutboundFormSettings) {
return {
mtu: s.mtu || undefined,
secretKey: s.secretKey,
address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
workers: s.workers || undefined,
domainStrategy: s.domainStrategy || undefined,
reserved: s.reserved
? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
: undefined,
peers: s.peers.map((p) => ({
publicKey: p.publicKey,
preSharedKey: p.psk.length > 0 ? p.psk : undefined,
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
endpoint: p.endpoint,
keepAlive: p.keepAlive || undefined,
})),
noKernelTun: s.noKernelTun,
};
}
function hysteriaToWire(s: HysteriaOutboundFormSettings) {
return { address: s.address, port: s.port, version: s.version };
}
function freedomToWire(s: FreedomOutboundFormSettings) {
// Legacy semantics: emit fragment only when the user actually populated
// at least one of the four sub-fields. Defaults like packets='1-3' alone
// are not enough — the modal's Fragment Switch sets all four together.
const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
return {
domainStrategy: s.domainStrategy || undefined,
redirect: s.redirect || undefined,
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
noises: s.noises.length > 0 ? s.noises : undefined,
finalRules: s.finalRules.length > 0
? s.finalRules.map((r) => ({
action: r.action,
network: r.network || undefined,
port: r.port || undefined,
ip: r.ip.length > 0 ? r.ip : undefined,
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
}))
: undefined,
};
}
function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
return { response: s.type ? { type: s.type } : undefined };
}
function dnsRuleToWire(r: DnsRuleForm) {
const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action)
? r.action
: 'direct';
const result: Raw = { action };
const qtype = r.qtype.trim();
if (qtype) {
result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype;
}
const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
if (domains.length > 0) result.domain = domains;
return result;
}
function dnsToWire(s: DnsOutboundFormSettings) {
const result: Raw = {};
if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
if (s.rewritePort) result.rewritePort = s.rewritePort;
if (s.userLevel) result.userLevel = s.userLevel;
if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
return result;
}
function loopbackToWire(s: LoopbackOutboundFormSettings) {
return { inboundTag: s.inboundTag || undefined };
}
// canEnableMux mirrors the legacy Outbound.canEnableMux().
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
function dropEmptyStrings(obj: Raw): Raw {
const out: Raw = {};
for (const [k, v] of Object.entries(obj)) {
if (v === '') continue;
out[k] = v;
}
return out;
}
function stripUiOnlyStreamFields(stream: unknown): Raw {
const next = { ...(stream as Raw) };
const xh = next.xhttpSettings;
if (xh && typeof xh === 'object') {
const cleaned = { ...(xh as Raw) };
delete cleaned.enableXmux;
next.xhttpSettings = dropEmptyStrings(cleaned);
}
return next;
}
function muxAllowed(values: OutboundFormValues): boolean {
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
const flow = values.protocol === 'vless'
? (values.settings as VlessOutboundFormSettings).flow
: '';
if (flow) return false;
const network = values.streamSettings && 'network' in values.streamSettings
? values.streamSettings.network
: undefined;
if (network === 'xhttp') return false;
return true;
}
export type WireOutboundPayload = Raw;
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
let settings: Raw;
switch (values.protocol) {
case 'vmess': settings = vmessToWire(values.settings); break;
case 'vless': settings = vlessToWire(values.settings); break;
case 'trojan': settings = trojanToWire(values.settings); break;
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
case 'socks': settings = simpleAuthToWire(values.settings); break;
case 'http': settings = simpleAuthToWire(values.settings); break;
case 'wireguard': settings = wireguardToWire(values.settings); break;
case 'hysteria': settings = hysteriaToWire(values.settings); break;
case 'freedom': settings = freedomToWire(values.settings); break;
case 'blackhole': settings = blackholeToWire(values.settings); break;
case 'dns': settings = dnsToWire(values.settings); break;
case 'loopback': settings = loopbackToWire(values.settings); break;
}
const result: Raw = {
protocol: values.protocol,
settings,
};
if (values.tag) result.tag = values.tag;
// streamSettings emission gates on canEnableStream — non-stream protocols
// still emit just `sockopt` if that key is present (legacy behavior).
if (values.streamSettings) {
if (STREAM_PROTOCOLS.has(values.protocol)) {
result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
} else {
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
if (sockopt) result.streamSettings = { sockopt };
}
}
if (values.sendThrough) result.sendThrough = values.sendThrough;
// mux may be absent when the modal didn't render the Mux switch (non-
// stream protocols or when isMuxAllowed gated it out). validateFields()
// only returns registered fields, so values.mux can be undefined.
if (values.mux?.enabled && muxAllowed(values)) {
result.mux = values.mux;
}
return result;
}
@@ -1,439 +0,0 @@
import { Base64 } from '@/utils';
// Focused share-link parser for the OutboundFormModal's link-import
// helper. Each parser returns a wire-shape outbound record (the same
// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
// null when the input doesn't match.
//
// Scope: address + port + auth + remark, plus the network/security
// fields the common vmess:// / vless:// links carry as query params.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux, reality shortIds,
// padding obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — the legacy Outbound.fromLink
// was ~250 lines of dense edge-case handling we don't need to
// replicate verbatim for the common phone-to-panel workflow.
type Raw = Record<string, unknown>;
// XHTTP knob keys grouped by wire type. Used by both the URL query-param
// (vless/trojan) branch and the vmess JSON branch to consistently pull
// the same set of advanced fields when present. Keep order ~stable to
// match the schema's authoring order so diffs read naturally.
const XHTTP_STRING_KEYS = [
'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
'xPaddingMethod', 'sessionPlacement', 'sessionKey', 'seqPlacement',
'seqKey', 'uplinkDataPlacement', 'uplinkDataKey', 'scMaxEachPostBytes',
'scMinPostsIntervalMs', 'scStreamUpServerSecs', 'uplinkHTTPMethod',
] as const;
const XHTTP_NUMBER_KEYS = [
'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
] as const;
const XHTTP_BOOL_KEYS = [
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
] as const;
function asBool(s: string | null): boolean | undefined {
if (s === null) return undefined;
return s === 'true' || s === '1';
}
function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
// Precedence from lowest to highest: stream-init default →
// x_padding_bytes snake_case alias → extra JSON payload →
// explicit camelCase URL param. Apply in that order so each tier
// overwrites the previous when present.
const padBytesAlt = params.get('x_padding_bytes');
if (padBytesAlt !== null && padBytesAlt !== '') {
xhttp.xPaddingBytes = padBytesAlt;
}
// The inbound link bundles advanced xhttp knobs into `extra=<json>`.
// Decode and merge so re-importing a share link round-trips the full
// xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
const extra = params.get('extra');
if (extra) {
try {
const parsed = JSON.parse(extra) as Record<string, unknown>;
applyXhttpStringFromJson(xhttp, parsed);
if (parsed.headers && typeof parsed.headers === 'object') {
xhttp.headers = parsed.headers;
}
} catch {
// malformed extra — silently ignore, the panel can still operate
// on the rest of the link
}
}
for (const k of XHTTP_STRING_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = v;
}
for (const k of XHTTP_NUMBER_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
}
for (const k of XHTTP_BOOL_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = asBool(v);
}
}
function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
for (const k of XHTTP_STRING_KEYS) {
if (typeof json[k] === 'string') xhttp[k] = json[k];
}
for (const k of XHTTP_NUMBER_KEYS) {
if (typeof json[k] === 'number') xhttp[k] = json[k];
}
for (const k of XHTTP_BOOL_KEYS) {
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
}
}
function buildStream(network: string, security: string): Raw {
const stream: Raw = { network, security };
switch (network) {
case 'tcp':
stream.tcpSettings = { header: { type: 'none' } };
break;
case 'kcp':
stream.kcpSettings = {
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
cwndMultiplier: 1, maxSendingWindow: 2097152,
};
break;
case 'ws':
stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
break;
case 'grpc':
stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
break;
case 'httpupgrade':
stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
break;
case 'xhttp':
stream.xhttpSettings = {
path: '/', host: '', mode: 'auto', headers: {},
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
};
break;
default:
stream.tcpSettings = { header: { type: 'none' } };
}
if (security === 'tls') {
stream.tlsSettings = {
serverName: '', alpn: [], fingerprint: '',
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
};
} else if (security === 'reality') {
stream.realitySettings = {
publicKey: '', fingerprint: 'chrome', serverName: '',
shortId: '', spiderX: '', mldsa65Verify: '',
};
}
return stream;
}
function applyTransportParams(stream: Raw, params: URLSearchParams): void {
const network = stream.network as string;
const host = params.get('host') ?? '';
const path = params.get('path') ?? '/';
switch (network) {
case 'ws':
(stream.wsSettings as Raw).host = host;
(stream.wsSettings as Raw).path = path;
break;
case 'grpc': {
const grpc = stream.grpcSettings as Raw;
const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
grpc.serviceName = serviceName;
grpc.authority = params.get('authority') ?? '';
grpc.multiMode = params.get('mode') === 'multi';
break;
}
case 'httpupgrade':
(stream.httpupgradeSettings as Raw).host = host;
(stream.httpupgradeSettings as Raw).path = path;
break;
case 'xhttp': {
const xhttp = stream.xhttpSettings as Raw;
xhttp.host = host;
xhttp.path = path;
if (params.get('mode')) xhttp.mode = params.get('mode');
applyXhttpStringFromParams(xhttp, params);
break;
}
case 'tcp':
// vless/trojan TCP HTTP camouflage rides on header=http+host+path
if (params.get('headerType') === 'http' || params.get('type') === 'http') {
(stream.tcpSettings as Raw).header = {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: path.split(',').filter(Boolean),
headers: host ? { Host: host.split(',').filter(Boolean) } : {},
},
};
}
break;
}
}
// The inbound link emits the entire finalmask object as a JSON-encoded
// `fm` query param. Decode and attach to streamSettings so udpHop /
// quicParams / tcp+udp masks round-trip on outbound import.
function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
const fm = params.get('fm');
if (!fm) return;
try {
const parsed = JSON.parse(fm) as Record<string, unknown>;
if (parsed && typeof parsed === 'object') {
stream.finalmask = parsed;
}
} catch {
// malformed fm — leave streamSettings.finalmask absent
}
}
function applySecurityParams(stream: Raw, params: URLSearchParams): void {
if (stream.security === 'tls') {
const tls = stream.tlsSettings as Raw;
tls.serverName = params.get('sni') ?? '';
tls.fingerprint = params.get('fp') ?? '';
const alpn = params.get('alpn');
if (alpn) tls.alpn = alpn.split(',');
} else if (stream.security === 'reality') {
const reality = stream.realitySettings as Raw;
reality.serverName = params.get('sni') ?? '';
reality.fingerprint = params.get('fp') ?? 'chrome';
reality.publicKey = params.get('pbk') ?? '';
reality.shortId = params.get('sid') ?? '';
reality.spiderX = params.get('spx') ?? '';
}
}
function decodeRemark(url: URL): string {
try {
return decodeURIComponent(url.hash.replace(/^#/, ''));
} catch {
return url.hash.replace(/^#/, '');
}
}
export function parseVmessLink(link: string): Raw | null {
if (!link.startsWith('vmess://')) return null;
try {
const decoded = Base64.decode(link.slice('vmess://'.length));
const json = JSON.parse(decoded) as Record<string, unknown>;
const network = (json.net as string) || 'tcp';
const security = json.tls === 'tls' ? 'tls' : 'none';
const stream = buildStream(network, security);
// Map the vmess JSON's net-specific keys onto the stream branch.
if (network === 'tcp' && json.type === 'http') {
(stream.tcpSettings as Raw).header = {
type: 'http',
request: {
version: '1.1', method: 'GET',
path: (json.path as string ?? '/').split(',').filter(Boolean),
headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
},
};
} else if (network === 'ws') {
(stream.wsSettings as Raw).host = json.host ?? '';
(stream.wsSettings as Raw).path = json.path ?? '/';
} else if (network === 'grpc') {
(stream.grpcSettings as Raw).serviceName = json.path ?? '';
(stream.grpcSettings as Raw).authority = json.authority ?? '';
(stream.grpcSettings as Raw).multiMode = json.type === 'multi';
} else if (network === 'httpupgrade') {
(stream.httpupgradeSettings as Raw).host = json.host ?? '';
(stream.httpupgradeSettings as Raw).path = json.path ?? '/';
} else if (network === 'xhttp') {
const xhttp = stream.xhttpSettings as Raw;
xhttp.host = json.host ?? '';
xhttp.path = json.path ?? '/';
if (json.mode) xhttp.mode = json.mode;
applyXhttpStringFromJson(xhttp, json);
}
if (security === 'tls') {
const tls = stream.tlsSettings as Raw;
tls.serverName = json.sni ?? '';
tls.fingerprint = json.fp ?? '';
if (json.alpn) tls.alpn = (json.alpn as string).split(',');
}
const port = Number(json.port) || 443;
return {
protocol: 'vmess',
tag: typeof json.ps === 'string' ? json.ps : '',
settings: {
vnext: [{
address: json.add ?? '',
port,
users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
}],
},
streamSettings: stream,
};
} catch {
return null;
}
}
function parseUrlLink(link: string, expectedProto: string): URL | null {
try {
const url = new URL(link);
if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
return url;
} catch {
return null;
}
}
export function parseVlessLink(link: string): Raw | null {
const url = parseUrlLink(link, 'vless');
if (!url) return null;
const id = url.username;
const address = url.hostname;
const port = Number(url.port) || 443;
const params = url.searchParams;
const network = params.get('type') ?? 'tcp';
const security = (params.get('security') ?? 'none') as string;
const stream = buildStream(network, security);
applyTransportParams(stream, params);
applySecurityParams(stream, params);
applyFinalMaskParam(stream, params);
return {
protocol: 'vless',
tag: decodeRemark(url),
settings: {
address,
port,
id,
flow: params.get('flow') ?? '',
encryption: params.get('encryption') ?? 'none',
},
streamSettings: stream,
};
}
export function parseTrojanLink(link: string): Raw | null {
const url = parseUrlLink(link, 'trojan');
if (!url) return null;
const password = url.username;
const address = url.hostname;
const port = Number(url.port) || 443;
const params = url.searchParams;
const network = params.get('type') ?? 'tcp';
const security = (params.get('security') ?? 'tls') as string;
const stream = buildStream(network, security);
applyTransportParams(stream, params);
applySecurityParams(stream, params);
applyFinalMaskParam(stream, params);
return {
protocol: 'trojan',
tag: decodeRemark(url),
settings: {
servers: [{ address, port, password }],
},
streamSettings: stream,
};
}
export function parseShadowsocksLink(link: string): Raw | null {
if (!link.startsWith('ss://')) return null;
// Two link shapes coexist:
// modern: ss://base64(method:password)@host:port#remark
// legacy: ss://base64(method:password@host:port)#remark
// Try modern first; fall back to legacy decode of the whole userinfo+host.
let userInfo: string;
let host: string;
let port: number;
let remark = '';
const hashIndex = link.indexOf('#');
const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
if (hashIndex >= 0) {
try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
}
const atIndex = linkNoHash.indexOf('@');
if (atIndex >= 0) {
try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
const hostPort = linkNoHash.slice(atIndex + 1);
const colon = hostPort.lastIndexOf(':');
if (colon < 0) return null;
host = hostPort.slice(0, colon);
port = Number(hostPort.slice(colon + 1)) || 443;
} else {
let decoded: string;
try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
catch { return null; }
const at = decoded.indexOf('@');
if (at < 0) return null;
userInfo = decoded.slice(0, at);
const hostPort = decoded.slice(at + 1);
const colon = hostPort.lastIndexOf(':');
if (colon < 0) return null;
host = hostPort.slice(0, colon);
port = Number(hostPort.slice(colon + 1)) || 443;
}
const sep = userInfo.indexOf(':');
const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
return {
protocol: 'shadowsocks',
tag: remark,
settings: {
servers: [{ address: host, port, password, method }],
},
};
}
export function parseHysteria2Link(link: string): Raw | null {
const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
if (!url) return null;
// hysteria2's auth rides as the URL userinfo. The streamSettings
// network branch is the dedicated 'hysteria' transport — the modal's
// newStreamSlice('hysteria') initializer fills in receive-window
// defaults; we override the user-set fields here.
const auth = url.username;
const address = url.hostname;
const port = Number(url.port) || 443;
const params = url.searchParams;
const stream: Raw = {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2, auth, udpIdleTimeout: 60,
},
tlsSettings: {
serverName: params.get('sni') ?? '',
alpn: ['h3'],
fingerprint: '',
echConfigList: '',
verifyPeerCertByName: '',
pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
},
};
return {
protocol: 'hysteria',
tag: decodeRemark(url),
settings: { address, port, version: 2 },
streamSettings: stream,
};
}
// Dispatcher — first non-null parser wins. Returns null when no parser
// recognizes the link's protocol scheme.
export function parseOutboundLink(link: string): Raw | null {
const trimmed = link.trim();
if (!trimmed) return null;
return (
parseVmessLink(trimmed)
?? parseVlessLink(trimmed)
?? parseTrojanLink(trimmed)
?? parseShadowsocksLink(trimmed)
?? parseHysteria2Link(trimmed)
);
}
@@ -1,74 +0,0 @@
// Pure-function ports of the legacy Inbound class capability predicates
// (canEnableTls, canEnableReality, canEnableTlsFlow, canEnableStream,
// canEnableVisionSeed, isSS2022, isSSMultiUser). Each accepts the minimal
// slice of an InboundFormValues it needs, so the same predicate can be
// called against a partial-row, a full form value, or a hand-built test
// fixture without the caller projecting a whole object.
const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks'];
const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp'];
const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan'];
const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp'];
const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria'];
const VISION_FLOW = 'xtls-rprx-vision';
const SS_2022_PREFIX = '2022';
const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
export interface CapabilityProtocolSlice {
protocol: string;
streamSettings?: { network?: string; security?: string };
}
export interface CapabilityVlessSlice extends CapabilityProtocolSlice {
settings?: { clients?: { flow?: string }[] };
}
export interface CapabilityShadowsocksSlice {
protocol: string;
settings?: { method?: string };
}
export function canEnableTls(values: CapabilityProtocolSlice): boolean {
if (values.protocol === 'hysteria') return true;
if (!TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
}
export function canEnableReality(values: CapabilityProtocolSlice): boolean {
if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
}
export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
const security = values.streamSettings?.security;
if (security !== 'tls' && security !== 'reality') return false;
if (values.streamSettings?.network !== 'tcp') return false;
return values.protocol === 'vless';
}
export function canEnableStream(values: { protocol: string }): boolean {
return STREAM_PROTOCOLS.includes(values.protocol);
}
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
// AND at least one VLESS client uses the vision flow. Excludes UDP variant.
export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean {
if (!canEnableTlsFlow(values)) return false;
const clients = values.settings?.clients;
if (!Array.isArray(clients)) return false;
return clients.some((c) => c?.flow === VISION_FLOW);
}
// Why: legacy returns true on non-SS protocols too (the method getter
// resolves to "" and "" !== blake3-chacha20-poly1305). Preserved for
// parity with the legacy class; in practice the callers all narrow on
// protocol === shadowsocks before checking.
export function isSSMultiUser(values: CapabilityShadowsocksSlice): boolean {
const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
return method !== SS_BLAKE3_CHACHA20;
}
export function isSS2022(values: CapabilityShadowsocksSlice): boolean {
const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
return method.substring(0, 4) === SS_2022_PREFIX;
}

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