mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 09:35:00 +03:00
Compare commits
33 Commits
v3.4.2
...
ccd56a56a8
| Author | SHA1 | Date | |
|---|---|---|---|
| ccd56a56a8 | |||
| 7a844682b3 | |||
| 6626bf4a07 | |||
| c0df365524 | |||
| 5361b56e5e | |||
| 9e13b32c34 | |||
| ade74eb321 | |||
| 97e2c9e7ba | |||
| 5e8327e728 | |||
| 7c12700c7d | |||
| c0d17e132d | |||
| fc5be5b9e4 | |||
| c3cc8b4374 | |||
| 97588dd0b9 | |||
| fb1d055b06 | |||
| 4fc301682f | |||
| 28f7690224 | |||
| 92303094fd | |||
| fb3a1559b2 | |||
| a335456cd3 | |||
| 9a3a12b260 | |||
| 4d6f2ddd97 | |||
| 62f303905e | |||
| c8ef1b1f68 | |||
| 64c306037f | |||
| 8dd3b31ee8 | |||
| e5b56c9444 | |||
| 1153d5db8c | |||
| 539bcc897c | |||
| 273f88721e | |||
| 1f2e3e1447 | |||
| 49773c18de | |||
| 427613b308 |
@@ -30,7 +30,8 @@ jobs:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-6
|
||||
--model claude-sonnet-5
|
||||
--effort xhigh
|
||||
--max-turns 300
|
||||
--allowedTools "Bash(gh:*),Read,Glob,Grep"
|
||||
prompt: |
|
||||
@@ -282,7 +283,8 @@ jobs:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: |
|
||||
--model claude-opus-4-8
|
||||
--model claude-sonnet-5
|
||||
--effort max
|
||||
--max-turns 250
|
||||
--allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep"
|
||||
prompt: |
|
||||
@@ -500,7 +502,8 @@ jobs:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
claude_args: |
|
||||
--model claude-opus-4-8
|
||||
--model claude-sonnet-5
|
||||
--effort max
|
||||
--max-turns 250
|
||||
--allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep,Edit,Write"
|
||||
--append-system-prompt "You are replying to an @claude mention in the MHSanaei/3x-ui repository, an open-source web panel for managing Xray-core servers. The full repo source is checked out in the working directory; use Read, Glob and Grep to open and verify the relevant files before stating any default, path, flag, option name, or behavior.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
.claude/
|
||||
.claude/*
|
||||
.cache/
|
||||
.sync*
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Operational guide for AI agents working in this repo. Long-form human docs:
|
||||
`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
|
||||
Read those before large changes. This file is the short, must-follow version.
|
||||
For a deep navigation map (request lifecycle, cron-job table, symptom → file
|
||||
index, layering rules), read `docs/architecture.md` on demand — do not guess
|
||||
file locations when it can answer in one hop.
|
||||
|
||||
## Stack
|
||||
- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
# 3x-ui — Architecture & Code Map
|
||||
|
||||
> Navigation map for contributors and AI coding agents (referenced from `CLAUDE.md`).
|
||||
> Goal: jump to the right file in one hop instead of grepping the whole tree.
|
||||
> Tracks the `main` branch — paths reflect the latest changes, so verify against the live
|
||||
> tree rather than a pinned release (Go module `github.com/mhsanaei/3x-ui/v3`).
|
||||
>
|
||||
> **How to use this file:** read "Mental model" + "Request lifecycle" first, then
|
||||
> use the **Symptom → File index** to locate work. Respect the **Layering rules**
|
||||
> when adding code. Verify with the commands in **Build / Test / Lint**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mental model (the 30-second version)
|
||||
|
||||
3x-ui is a **web control panel for [Xray-core](https://github.com/XTLS/Xray-core)**. The Go
|
||||
backend is the source of truth: it stores inbounds/clients/settings in a DB, renders an
|
||||
Xray JSON config from that state, supervises the Xray child process, and exposes a REST +
|
||||
WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the UI. A second,
|
||||
separate HTTP server serves **subscription links** to end users.
|
||||
|
||||
The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
|
||||
inbounds exist — the `mtg` Telegram-proxy binary (`internal/mtproto/`).
|
||||
|
||||
Servers and processes, all launched from `main.go`:
|
||||
|
||||
| Server / process | Package | Purpose | Default port |
|
||||
|---|---|---|---|
|
||||
| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 |
|
||||
| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
|
||||
| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` |
|
||||
| **mtg** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds | per inbound |
|
||||
|
||||
Two key ideas that explain most of the complexity:
|
||||
|
||||
1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the
|
||||
backend regenerates the Xray config and applies it — preferring a *hot diff* (live gRPC
|
||||
API mutation) over a full process restart. See §5.1.
|
||||
2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui
|
||||
instances). Every state-changing inbound/client operation is dispatched through a
|
||||
`runtime.Runtime` interface that is either **`Local`** (this box's Xray gRPC API) or
|
||||
**`Remote`** (HTTPS call to a child node, with `verify`/`skip`/`pin`/`mtls` TLS modes).
|
||||
This is the single most important abstraction in the project. See §5.2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
**Backend (Go 1.26):**
|
||||
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
|
||||
- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
|
||||
- Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs.
|
||||
- Xray: **xtls/xray-core** vendored as a library; the panel talks to the running core over
|
||||
its **gRPC API** and also shells out to manage the process.
|
||||
- Telegram bot: **mymmrac/telego**. i18n: **nicksnyder/go-i18n**.
|
||||
- Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP).
|
||||
|
||||
**Frontend (`frontend/`):**
|
||||
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
|
||||
- Data layer: **TanStack Query** (`@tanstack/react-query`) over **axios**; **Zod 4** schemas.
|
||||
- Router: **react-router-dom 7**. Charts: **recharts**. Editor: **CodeMirror 6**.
|
||||
- **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
|
||||
embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
|
||||
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
|
||||
deployment.
|
||||
|
||||
**Important:** the legacy Go-template UI and `web/assets/` are **gone**. All HTML/JS comes
|
||||
from the embedded Vite `dist/`. Don't look for `.html` templates in `internal/web`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Request lifecycle (follow the data)
|
||||
|
||||
### 3.1 Admin API request (e.g. "add a client")
|
||||
|
||||
```
|
||||
Browser (React, axios)
|
||||
→ POST {basePath}/panel/api/...
|
||||
→ Gin engine (internal/web/web.go: initRouter)
|
||||
→ middleware chain: SecurityHeaders → MaxBodyBytes (10 MiB; importDB exempt)
|
||||
→ [DomainValidator, if webDomain set] → gzip → sessions("3x-ui")
|
||||
→ base-path/cache-control context → Localizer
|
||||
→ API routes add: ConfigEnvelope (zstd + SHA-256) → CSRF
|
||||
→ Controller (internal/web/controller/*.go) // HTTP concerns only: bind, validate, respond
|
||||
→ Service (internal/web/service/*.go) // business logic + transactions
|
||||
→ GORM → DB (internal/database) // persistence
|
||||
→ runtime.Runtime dispatch // apply to Xray (Local) or node (Remote)
|
||||
→ Local: internal/xray (gRPC API or config regen + restart)
|
||||
→ Remote: internal/web/runtime/remote.go → HTTPS → child node's API
|
||||
```
|
||||
|
||||
The controller layer is thin. **Business logic lives in services.** When something is wrong
|
||||
with *behavior*, the bug is almost always in a service file, not a controller.
|
||||
|
||||
### 3.2 Subscription request (end-user fetching their config)
|
||||
|
||||
```
|
||||
End user → GET {subPath}/{subId} (separate server, internal/sub)
|
||||
→ internal/sub/controller.go (routes: raw / JSON / Clash variants, feature-flagged)
|
||||
→ internal/sub/service.go (~2.5k lines — the link/config builder)
|
||||
→ reads inbounds+clients+hosts from DB, renders per-protocol share links /
|
||||
Clash YAML / JSON (Host rows can override address/SNI/path per inbound)
|
||||
```
|
||||
|
||||
### 3.3 Background work (cron jobs)
|
||||
|
||||
Scheduled in `internal/web/web.go` → `startTask()`. Each job is a struct in
|
||||
`internal/web/job/`. Examples: poll Xray traffic every 5s, check client IP limits every 10s,
|
||||
node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly). See §5.4.
|
||||
|
||||
---
|
||||
|
||||
## 4. Directory map (what lives where)
|
||||
|
||||
```
|
||||
3x-ui/
|
||||
├── main.go # Entry point: CLI (run / migrate / migrate-db / setting / cert),
|
||||
│ # bootstrap, signal handling, restart loop
|
||||
├── go.mod / go.sum # Go deps (module path ends in /v3)
|
||||
│
|
||||
├── internal/ # ALL backend Go code (private packages)
|
||||
│ ├── config/ # Env-var config: paths, DB kind/DSN, log level, version
|
||||
│ │ # Every XUI_* env var is read here (config.go)
|
||||
│ ├── database/
|
||||
│ │ ├── db.go # InitDB: connect, AutoMigrate, seeders (~1.4k lines). DB hotspot.
|
||||
│ │ ├── migrate_data.go # Data migrations (seeders/normalizers beyond AutoMigrate)
|
||||
│ │ ├── dialect.go # SQLite vs Postgres SQL differences
|
||||
│ │ ├── dump_sqlite.go # DB export/backup
|
||||
│ │ └── model/ # **ALL GORM models** (model.go ~1.1k lines + siblings:
|
||||
│ │ # node_client_traffic.go, node_client_ip.go,
|
||||
│ │ # client_global_traffic.go). ⭐ Start here for data shape.
|
||||
│ ├── eventbus/ # In-process pub/sub (buffered channel): outbound.down|up,
|
||||
│ │ # xray.crash, node.down|up, cpu.high, memory.high, login.attempt
|
||||
│ ├── tunnelmonitor/ # Optional tunnel health probe (XUI_TUNNEL_HEALTH_* env vars):
|
||||
│ │ # HTTP probe (default Cloudflare trace); repeated failures
|
||||
│ │ # trigger an Xray restart hook. Independent of panel settings.
|
||||
│ ├── xray/ # Xray-core integration (the proxy engine wrapper)
|
||||
│ │ ├── process.go # Spawn/supervise the Xray child process (~750 lines)
|
||||
│ │ ├── api.go # gRPC client to a running Xray (add/remove user, stats) (~800 lines)
|
||||
│ │ ├── hot_diff.go # ⭐ Compute minimal live changes to avoid full restart (~500 lines)
|
||||
│ │ ├── config.go # Xray config object model
|
||||
│ │ ├── inbound.go # Inbound JSON shaping
|
||||
│ │ ├── client_traffic.go # ClientTraffic model (persisted as client_traffics)
|
||||
│ │ ├── traffic.go # Traffic type helpers
|
||||
│ │ └── log_writer.go # Pipe Xray stdout/stderr into the panel logger
|
||||
│ │
|
||||
│ ├── web/ # The panel server
|
||||
│ │ ├── web.go # ⭐ Server bootstrap: initRouter (all routes) + startTask (all cron jobs)
|
||||
│ │ ├── controller/ # HTTP handlers (thin). One file per resource:
|
||||
│ │ │ ├── inbound.go # /panel/api/inbounds
|
||||
│ │ │ ├── client.go # /panel/api/clients (CRUD + bulk + ips + onlines)
|
||||
│ │ │ ├── group.go # client-group endpoints
|
||||
│ │ │ ├── node.go # /panel/api/nodes (multi-node management)
|
||||
│ │ │ ├── host.go # /panel/api/hosts (per-inbound subscription host overrides)
|
||||
│ │ │ ├── server.go # /panel/api/server (status, xray version, certs, logs, DB import/export)
|
||||
│ │ │ ├── setting.go # /panel/api/setting (settings + API tokens)
|
||||
│ │ │ ├── xray_setting.go # /panel/api/xray (raw Xray config editor, WARP/Nord)
|
||||
│ │ │ ├── api.go # /panel/api gateway (token auth, envelope + CSRF wiring)
|
||||
│ │ │ ├── index.go # login/logout/csrf/2FA
|
||||
│ │ │ ├── spa.go # SPA fallback for /panel UI routes
|
||||
│ │ │ └── websocket.go # WS upgrade endpoint
|
||||
│ │ ├── service/ # ⭐⭐ Business logic. This is where most real work happens.
|
||||
│ │ │ ├── inbound.go # Inbound CRUD core (~1.4k lines)
|
||||
│ │ │ ├── inbound_node.go # ⭐ Node sync for inbounds: reconcile, traffic merge (~1.1k lines)
|
||||
│ │ │ ├── inbound_traffic.go # Per-client traffic accounting (~1.1k lines)
|
||||
│ │ │ ├── inbound_clients.go # Client-within-inbound operations
|
||||
│ │ │ ├── inbound_sublink.go # Inbound-level subscription link helpers
|
||||
│ │ │ ├── inbound_migration.go # Inbound schema/format migrations
|
||||
│ │ │ ├── client_crud.go # Client create/read/update/delete
|
||||
│ │ │ ├── client_bulk.go # Bulk client ops (~1.6k lines)
|
||||
│ │ │ ├── client_inbound_apply.go # ⭐ Apply client changes to runtime (Local/Remote) (~1.2k lines)
|
||||
│ │ │ ├── client_groups.go # Client grouping
|
||||
│ │ │ ├── client_link.go # Per-client share-link generation
|
||||
│ │ │ ├── client_external_link.go # External links attached to clients
|
||||
│ │ │ ├── client_wireguard.go # WireGuard client specifics
|
||||
│ │ │ ├── client_paging.go # Server-side pagination/sort/filter for client lists
|
||||
│ │ │ ├── node.go # ⭐ NodeService: CRUD, probe, heartbeat, dirty-tracking (~1.1k lines)
|
||||
│ │ │ ├── node_mtls.go # Node mTLS certificate management (master side)
|
||||
│ │ │ ├── node_tree.go # Node hierarchy / descendants
|
||||
│ │ │ ├── host.go # Host rows (subscription output overrides)
|
||||
│ │ │ ├── server.go # ServerService: status, certs, xray install, DB ops (~2.2k lines)
|
||||
│ │ │ ├── setting.go # SettingService: all panel settings + defaults (~1.3k lines)
|
||||
│ │ │ ├── setting_mtls.go # mTLS settings (node hardening)
|
||||
│ │ │ ├── traffic_writer.go # Batched persistence of traffic deltas to the DB
|
||||
│ │ │ ├── xray.go # ⭐ XrayService: config gen + restart/hot-apply (~1.2k lines)
|
||||
│ │ │ ├── xray_setting.go # Raw Xray config persistence
|
||||
│ │ │ ├── xray_metrics.go # Xray observability metrics
|
||||
│ │ │ ├── metric_history.go # Historical system/xray metrics
|
||||
│ │ │ ├── reality_scan.go # REALITY target scanner
|
||||
│ │ │ ├── url_safety.go # Outbound URL validation (SSRF guards)
|
||||
│ │ │ ├── outbound_subscription.go# Outbound subscription (e.g. Warp/Nord provider configs)
|
||||
│ │ │ ├── port_conflict.go # Detect inbound port collisions
|
||||
│ │ │ ├── fallback.go # Xray fallback (SNI/ALPN routing on shared port)
|
||||
│ │ │ ├── email/ # Email notification service (SMTP)
|
||||
│ │ │ ├── integration/ # External providers: warp.go (Cloudflare WARP), nord.go (NordVPN)
|
||||
│ │ │ ├── outbound/ # Outbound config service
|
||||
│ │ │ ├── panel/ # Cross-cutting panel services:
|
||||
│ │ │ │ ├── panel.go # panel-level helpers
|
||||
│ │ │ │ ├── user.go # admin user auth (bcrypt)
|
||||
│ │ │ │ ├── api_token.go # API token CRUD (SHA-256 hashed)
|
||||
│ │ │ │ └── websocket.go # WS hub / push service
|
||||
│ │ │ └── tgbot/ # Telegram bot command handlers
|
||||
│ │ ├── runtime/ # ⭐⭐ The Local/Remote node abstraction (see §5.2)
|
||||
│ │ │ ├── runtime.go # the Runtime interface (the contract)
|
||||
│ │ │ ├── local.go # Local impl → this box's Xray gRPC API
|
||||
│ │ │ ├── remote.go # Remote impl → HTTPS calls to a child node
|
||||
│ │ │ ├── tls_client.go # per-node HTTP client: verify / skip / pin / mtls
|
||||
│ │ │ └── manager.go # RuntimeFor(nodeID) → picks Local or Remote
|
||||
│ │ ├── job/ # Cron job structs (one file per job — see §5.4)
|
||||
│ │ ├── middleware/ # Gin middleware: security.go (headers/HSTS), bodylimit.go,
|
||||
│ │ │ # domainValidator.go, validate.go (CSRF), config_envelope.go
|
||||
│ │ ├── global/ # Global singletons: web server + sub server handles, restart hook
|
||||
│ │ ├── network/ # Custom net listeners (e.g. proxy-protocol aware)
|
||||
│ │ ├── session/ # Session/cookie helpers
|
||||
│ │ ├── websocket/ # WS hub implementation
|
||||
│ │ ├── locale/ + translation/ # i18n middleware + 13 locale JSON catalogs
|
||||
│ │ ├── entity/ # Shared request/response DTOs
|
||||
│ │ └── dist/ # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
|
||||
│ │
|
||||
│ ├── sub/ # The subscription server (separate from panel)
|
||||
│ │ ├── sub.go # server bootstrap
|
||||
│ │ ├── controller.go # routes for raw / JSON / Clash subscription formats
|
||||
│ │ ├── service.go # ⭐ The link/config builder (~2.5k lines — share-link logic lives here)
|
||||
│ │ ├── json_service.go # JSON subscription format
|
||||
│ │ ├── clash_service.go # Clash/Mihomo YAML format
|
||||
│ │ ├── clash_external.go # external Clash config integration
|
||||
│ │ ├── external_subscription.go / external_config.go # external sub import/aggregation
|
||||
│ │ ├── host_sub.go # Host-row overrides applied to subscription output
|
||||
│ │ ├── endpoint.go # subscription endpoint configuration
|
||||
│ │ ├── vless_route.go # VLESS route shaping
|
||||
│ │ ├── remark_vars.go # remark variable expansion
|
||||
│ │ └── links.go # link helpers
|
||||
│ │
|
||||
│ ├── mtproto/ # Embedded MTProto (Telegram) proxy: manager.go + per-OS
|
||||
│ │ # process supervision + orphan cleanup
|
||||
│ ├── logger/ # App logger (op/go-logging + lumberjack rotation)
|
||||
│ └── util/ # Leaf helpers (no business logic):
|
||||
│ ├── common/ # errors, misc
|
||||
│ ├── crypto/ # key/cert generation (x25519, ML-KEM/ML-DSA, ECH)
|
||||
│ ├── link/ # outbound share-link building primitives
|
||||
│ ├── wirecodec/ + wireguard/ # WireGuard codec + integration helpers
|
||||
│ └── random/, json_util/, reflect_util/, sys/, netproxy/, netsafe/, ldap/
|
||||
│
|
||||
├── frontend/ # React SPA (built into internal/web/dist)
|
||||
│ ├── vite.config.js # ⭐ Build config: outDir → ../internal/web/dist, dev on :5173
|
||||
│ │ # (strict) proxying to :2053, entries index/login/subpage.html
|
||||
│ ├── package.json # scripts: dev / build / preview / lint / typecheck / test / gen
|
||||
│ └── src/
|
||||
│ ├── main.tsx / routes.tsx / queryClient.ts # SPA entry, router, query client
|
||||
│ ├── entries/ # Extra HTML entry points: login.tsx, subpage.tsx
|
||||
│ ├── pages/ # ⭐ Route screens. Mirrors the panel's feature areas:
|
||||
│ │ ├── inbounds/ # inbound list + the big inbound form (protocols/security/transport)
|
||||
│ │ ├── clients/ # client management screens
|
||||
│ │ ├── nodes/ # multi-node UI
|
||||
│ │ ├── hosts/ # subscription host-override UI
|
||||
│ │ ├── xray/ # raw Xray config UI (routing, dns, outbounds, balancers, overrides)
|
||||
│ │ ├── index/ # dashboard/home
|
||||
│ │ └── settings/, groups/, sub/, login/, api-docs/
|
||||
│ ├── api/ # ⭐ Data layer: axios-init, QueryProvider, queryKeys, websocket bridge
|
||||
│ │ └── queries/ # TanStack Query hooks (useNodesQuery, useStatusQuery, …)
|
||||
│ ├── schemas/ # Zod schemas: protocols, forms, api, primitives
|
||||
│ ├── generated/ # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts
|
||||
│ ├── components/ # Reusable UI (clients/ form/ ui/ viz/ feedback/ utility/)
|
||||
│ ├── lib/ # Frontend domain logic (xray/ inbounds/ clients/)
|
||||
│ ├── hooks/, models/, layouts/, i18n/, utils/, styles/
|
||||
│ └── test/ # Vitest + golden fixtures (config-generation snapshot tests)
|
||||
│
|
||||
├── tools/openapigen/ # ⭐ Go program that emits frontend/src/generated/* from Go types (§5.5)
|
||||
├── docs/ # Markdown docs (this file, custom-subscription-templates.md, …)
|
||||
├── media/ # README images
|
||||
│
|
||||
├── Dockerfile / docker-compose.yml / DockerEntrypoint.sh / DockerInit.sh # Container build/run
|
||||
├── install.sh / update.sh / x-ui.sh # VPS install + management CLI
|
||||
├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script
|
||||
├── windows_files/ # Windows service support
|
||||
└── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml,
|
||||
# mutation.yml, cleanup_caches.yml, claude-bot.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-cutting subsystems (the parts that span many files)
|
||||
|
||||
### 5.1 DB → Xray config pipeline (config generation & application)
|
||||
|
||||
The panel never edits Xray's running config directly from controllers. The flow is:
|
||||
|
||||
1. A service mutates DB state (inbound/client/setting).
|
||||
2. `XrayService` (`service/xray.go`) builds a fresh `xray.Config` from DB state
|
||||
(`GetXrayConfig`).
|
||||
3. It tries a **hot apply** (`tryHotApply` → `xray/hot_diff.go`): diff old vs new config and
|
||||
push only the deltas over the Xray gRPC API (add/remove inbound, add/remove user) — **no
|
||||
process restart**, so live connections survive.
|
||||
4. If the diff isn't hot-applicable (structural change), it falls back to a **full restart**
|
||||
of the Xray process (`xray/process.go`).
|
||||
|
||||
Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
|
||||
`IsNeedRestartAndSetFalse`), consumed by a `@every 30s` cron task registered in `startTask()`
|
||||
— any number of mutations inside the window causes at most one restart.
|
||||
|
||||
**Key files:** `service/xray.go` (orchestration), `xray/hot_diff.go` (the diff algorithm),
|
||||
`xray/process.go` (process lifecycle), `xray/api.go` (gRPC calls), `xray/config.go` (config model).
|
||||
|
||||
### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
|
||||
|
||||
A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
|
||||
inbound/client operation goes through the `runtime.Runtime` interface so the *same service
|
||||
code* works whether the target is the local Xray or a remote node.
|
||||
|
||||
- **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`,
|
||||
`UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`,
|
||||
`RestartXray`, `ResetClientTraffic`, `ResetInboundTraffic`, `ResetAllTraffics`.
|
||||
- **`Local`** (`local.go`): calls this box's Xray gRPC API directly.
|
||||
- **`Remote`** (`remote.go`): serializes the operation and sends it over HTTPS to the child
|
||||
node's API.
|
||||
- **TLS modes** (`tls_client.go`, per-node `TlsVerifyMode`):
|
||||
`verify` (system CAs, default) / `skip` (no validation) / `pin` (leaf cert SHA-256 must
|
||||
match `PinnedCertSha256`) / `mtls` (master presents a client certificate; node cert checked
|
||||
against system roots; API token optional). Master-side cert management:
|
||||
`service/node_mtls.go` + `service/setting_mtls.go`.
|
||||
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
|
||||
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
|
||||
|
||||
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` *and* an
|
||||
`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
|
||||
online clients back to the originating panel using **stable GUIDs** rather than local IDs.
|
||||
Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge,
|
||||
`synthNodeGuid`, `panelGuid`) and `service/node.go` (`effectiveNodeGuid`, heartbeat, dirty
|
||||
tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an offline node's
|
||||
inbound edits converge once it reconnects.
|
||||
|
||||
**Where to look for node bugs:**
|
||||
- Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`.
|
||||
- Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths).
|
||||
- Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`).
|
||||
- Edits to an offline node not applying on reconnect → dirty/reconcile logic in `service/inbound_node.go` + `service/node.go` (`MarkNodeDirty`/`ClearNodeDirty`/`NodeSyncState`).
|
||||
- TLS/mTLS handshake failures → `runtime/tls_client.go`, `service/node_mtls.go`, `service/node.go` (`FetchCertFingerprint`).
|
||||
|
||||
### 5.3 Traffic accounting
|
||||
|
||||
Per-client and per-inbound up/down counters originate from Xray's stats API and are persisted
|
||||
to the DB. The Xray traffic job polls the core; node traffic is pulled from child nodes and
|
||||
merged with GUID-based baselines to avoid double counting after resets.
|
||||
|
||||
**Key files:** `service/inbound_traffic.go`, `service/traffic_writer.go`,
|
||||
`job/xray_traffic_job.go`, `job/node_traffic_sync_job.go`, `service/inbound_node.go`
|
||||
(`SetRemoteTraffic` / `upsertNodeBaseline`), models `xray.ClientTraffic`,
|
||||
`model.NodeClientTraffic`, `model.ClientGlobalTraffic` (cross-master totals).
|
||||
Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
|
||||
|
||||
### 5.4 Background jobs (cron)
|
||||
|
||||
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
|
||||
|
||||
| Schedule | Job | Purpose / condition |
|
||||
|---|---|---|
|
||||
| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) |
|
||||
| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) |
|
||||
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
|
||||
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
|
||||
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
|
||||
| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
|
||||
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
||||
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | Log cleanup; resets |
|
||||
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
|
||||
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
||||
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
||||
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
||||
| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` |
|
||||
| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` |
|
||||
| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS |
|
||||
|
||||
To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file.
|
||||
|
||||
### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files
|
||||
|
||||
The Go backend is the schema source of truth. `tools/openapigen` (a Go program, with a
|
||||
`StructAllow` allowlist of exported types) emits
|
||||
`frontend/src/generated/{schemas,types,zod,examples}.ts`. The frontend build runs this first:
|
||||
|
||||
- `npm run gen:zod` → `go run ./tools/openapigen` (regenerate from Go)
|
||||
- `npm run gen:api` → builds the OpenAPI doc (`scripts/build-openapi.mjs`, driven by the
|
||||
hand-maintained endpoint registry `src/pages/api-docs/endpoints.ts`)
|
||||
- `npm run build` runs `gen:api` then `vite build`.
|
||||
|
||||
**Implication:** if you change a Go model/DTO that crosses the API boundary, regenerate the
|
||||
frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/` by hand.
|
||||
|
||||
### 5.6 Share-link / subscription generation
|
||||
|
||||
Two distinct code paths produce client configs:
|
||||
- **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go`
|
||||
+ `util/link/outbound.go`.
|
||||
- **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links),
|
||||
`internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML).
|
||||
**`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/
|
||||
security per inbound in subscription output — applied in `sub/host_sub.go`.
|
||||
|
||||
Both paths must agree per protocol. A malformed link for a specific protocol/transport combo
|
||||
(e.g. XHTTP + Reality) is usually a field-lookup mismatch in **`internal/sub/service.go`** (and
|
||||
its tests `service_test.go` / golden fixtures), or in `util/link/outbound.go`. The frontend
|
||||
also has protocol schemas under `frontend/src/schemas/protocols/` and `frontend/src/lib/xray/`.
|
||||
|
||||
### 5.7 Event bus (in-process pub/sub)
|
||||
|
||||
`internal/eventbus/` is a minimal buffered-channel pub/sub. Producers call a non-blocking
|
||||
`Publish(Event)`; all subscribers receive every event. Event types: `outbound.down|up`,
|
||||
`xray.crash`, `node.down|up`, `cpu.high`, `memory.high`, `login.attempt`, with structured
|
||||
payloads (OutboundHealthData, NodeHealthData, LoginEventData, SystemMetricData). Producers
|
||||
include the CPU/memory jobs, node heartbeat, and login handling; consumers include the
|
||||
Telegram bot and the email notifier (`service/email/`). Use it for cross-cutting
|
||||
notifications instead of importing notification services into producers.
|
||||
|
||||
### 5.8 Tunnel health monitor
|
||||
|
||||
`internal/tunnelmonitor/` is an optional watchdog configured **only via env vars**
|
||||
(`XUI_TUNNEL_HEALTH_*`, read in `internal/config/`), deliberately independent of panel
|
||||
settings so it can be enabled from a systemd `EnvironmentFile` even when the panel is
|
||||
unreachable. It periodically probes an HTTP URL (default: Cloudflare trace endpoint) through
|
||||
the tunnel; after N successive failures (default 3) it fires a recovery callback wired to an
|
||||
Xray restart.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model cheat-sheet
|
||||
|
||||
GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered
|
||||
for AutoMigrate in `internal/database/db.go`.
|
||||
|
||||
| Model | Table role | Notable fields |
|
||||
|---|---|---|
|
||||
| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) |
|
||||
| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) |
|
||||
| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) |
|
||||
| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` |
|
||||
| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` |
|
||||
| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` |
|
||||
| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags |
|
||||
| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields |
|
||||
| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) |
|
||||
| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` |
|
||||
| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` |
|
||||
| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` |
|
||||
| `InboundClientIps` | IP set per client email | drives IP-limit enforcement |
|
||||
| `OutboundTraffics` | Outbound counters | per outbound tag |
|
||||
| `OutboundSubscription` | External provider subs | Warp/Nord style |
|
||||
| `Setting` | Key/value panel settings | everything configurable |
|
||||
| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) |
|
||||
| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest |
|
||||
| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations |
|
||||
|
||||
---
|
||||
|
||||
## 7. Symptom → File index (start here when debugging)
|
||||
|
||||
| Symptom / task | Primary file(s) | Then check |
|
||||
|---|---|---|
|
||||
| Add/modify an **API endpoint** | `controller/<resource>.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` |
|
||||
| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` |
|
||||
| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` |
|
||||
| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` |
|
||||
| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` |
|
||||
| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` |
|
||||
| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` |
|
||||
| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` |
|
||||
| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` |
|
||||
| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) |
|
||||
| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) |
|
||||
| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) |
|
||||
| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` |
|
||||
| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests |
|
||||
| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` |
|
||||
| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` |
|
||||
| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` |
|
||||
| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` |
|
||||
| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
|
||||
| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
|
||||
| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
|
||||
| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
|
||||
| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
|
||||
| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
|
||||
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
|
||||
| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
|
||||
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
|
||||
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
|
||||
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
|
||||
| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) |
|
||||
| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` |
|
||||
| **Frontend route / screen** | `frontend/src/pages/<area>/`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` |
|
||||
| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` |
|
||||
| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil |
|
||||
|
||||
---
|
||||
|
||||
## 8. Layering rules (where new code belongs)
|
||||
|
||||
1. **Controllers are thin.** Only: bind/validate input, call one service, shape the HTTP
|
||||
response. No DB queries, no Xray calls, no business rules in `controller/`.
|
||||
2. **Services own the logic and transactions.** All business rules, DB access, and decisions
|
||||
about applying changes live in `service/`. If you're tempted to query GORM from a
|
||||
controller, move it to a service.
|
||||
3. **Never touch Xray's running state from a controller or job directly.** Go through
|
||||
`XrayService` / the `runtime.Runtime` interface so local vs node dispatch stays correct.
|
||||
4. **Any state-changing inbound/client op must dispatch through `runtime.Runtime`**, not
|
||||
straight to `xray/api.go` — otherwise node deployments silently break.
|
||||
5. **`internal/util/*` is leaf-only** (no imports of `service`/`controller`/`database`). Keep
|
||||
helpers pure.
|
||||
6. **Don't hand-edit generated files:** `frontend/src/generated/*` and `internal/web/dist/*`.
|
||||
Regenerate instead.
|
||||
7. **Models are the contract.** Changing a model field that crosses the API boundary means:
|
||||
update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types.
|
||||
8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an *end user*
|
||||
fetches goes in `internal/sub`. Don't blur them.
|
||||
9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead
|
||||
of importing the Telegram/email services into producers.
|
||||
|
||||
---
|
||||
|
||||
## 9. Build / Test / Lint (verify your changes)
|
||||
|
||||
The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make gen`
|
||||
(regenerate Zod/OpenAPI), `make lint` (Go + frontend), `make test` (Go `-shuffle=on` +
|
||||
frontend), `make race`, `make build`. Run `make help` for everything. Raw commands:
|
||||
|
||||
**Backend (Go):**
|
||||
```bash
|
||||
go build ./... # compile everything
|
||||
go test ./... # run all Go tests (many *_test.go alongside sources)
|
||||
go test ./internal/web/service/... # focused: service-layer tests
|
||||
go test ./internal/xray/... # hot-diff / process / api tests
|
||||
go test ./internal/sub/... # subscription + golden link tests
|
||||
go vet ./... # static checks
|
||||
golangci-lint run # full lint (gofumpt + goimports formatting)
|
||||
go run main.go # run the panel locally (serves embedded dist if built)
|
||||
```
|
||||
|
||||
**Frontend (`cd frontend`, Node ≥ 22):**
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint src
|
||||
npm run test # vitest (incl. golden config-generation snapshots)
|
||||
npm run gen # regenerate src/generated/* from Go (gen:zod + gen:api)
|
||||
npm run build # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed)
|
||||
```
|
||||
|
||||
**Full local loop:** `cd frontend && npm run build` (refresh embedded `dist/`) → back to repo
|
||||
root → `go build ./...` / `go run main.go`.
|
||||
|
||||
**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
|
||||
|
||||
**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
|
||||
`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
|
||||
(multi-arch image + release builds), `cleanup_caches.yml`, `claude-bot.yml` (issue bot).
|
||||
|
||||
---
|
||||
|
||||
## 10. Gotchas & conventions
|
||||
|
||||
- **Module path is `.../v3`.** Internal imports use `github.com/mhsanaei/3x-ui/v3/internal/...`.
|
||||
- **SQLite vs Postgres.** Default is SQLite at `{XUI_DB_FOLDER}/x-ui.db`. Postgres via
|
||||
`XUI_DB_TYPE=postgres` + `XUI_DB_DSN`. Some SQL paths are dialect-aware (`database/dialect.go`);
|
||||
test both when touching raw queries (there are `*_scale_postgres_test.go` suites).
|
||||
- **`Inbound.Settings` / `StreamSettings` / `Sniffing` are raw JSON strings**, not structured
|
||||
columns. Parsing/validation happens in services and the `xray` package, not in GORM.
|
||||
- **Hot-reload is the default; full restart is the fallback.** Changes that look config-only
|
||||
but cause a restart usually mean the diff in `xray/hot_diff.go` didn't recognize them as hot.
|
||||
- **Node TLS:** remote calls honor `TlsVerifyMode` (`verify`/`skip`/`pin`/`mtls`). "Works on
|
||||
skip, fails on verify/pin/mtls" → cert/fingerprint handling in `service/node.go`
|
||||
(`FetchCertFingerprint`), `service/node_mtls.go`, and `runtime/tls_client.go`.
|
||||
- **Restart is signal-driven.** `main.go` traps SIGHUP to restart panel+sub servers; the
|
||||
in-process restart hook (`global.SetRestartHook`) funnels into the same path.
|
||||
- **i18n:** backend catalogs in `internal/web/translation/` (13 locales, shared with the
|
||||
frontend); frontend wiring in `frontend/src/i18n/`. Persian (`fa_IR`) is a first-class
|
||||
locale (Jalali calendar via `persian-calendar-suite`).
|
||||
- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
|
||||
`frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
|
||||
not blindly, when output changes.
|
||||
Generated
+168
-177
@@ -11,21 +11,22 @@
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.3",
|
||||
"i18next": "^26.3.4",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"recharts": "^3.9.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.1",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -44,8 +45,8 @@
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "8.1.0",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "8.1.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1105,9 +1106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
|
||||
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1843,20 +1844,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.8",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1871,9 +1862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1888,9 +1879,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
|
||||
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1905,9 +1896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
|
||||
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1922,9 +1913,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
|
||||
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
|
||||
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1939,9 +1930,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1959,9 +1950,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
|
||||
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1979,9 +1970,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1999,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2019,9 +2010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
|
||||
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2039,9 +2030,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
|
||||
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2059,9 +2050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
|
||||
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2076,9 +2067,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
|
||||
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
|
||||
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2095,9 +2086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
||||
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2112,9 +2103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
|
||||
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3108,17 +3099,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
|
||||
"integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
|
||||
"integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.62.0",
|
||||
"@typescript-eslint/type-utils": "8.62.0",
|
||||
"@typescript-eslint/utils": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||
"@typescript-eslint/scope-manager": "8.62.1",
|
||||
"@typescript-eslint/type-utils": "8.62.1",
|
||||
"@typescript-eslint/utils": "8.62.1",
|
||||
"@typescript-eslint/visitor-keys": "8.62.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -3131,7 +3122,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.62.0",
|
||||
"@typescript-eslint/parser": "^8.62.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -3147,16 +3138,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz",
|
||||
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
|
||||
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.62.0",
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||
"@typescript-eslint/scope-manager": "8.62.1",
|
||||
"@typescript-eslint/types": "8.62.1",
|
||||
"@typescript-eslint/typescript-estree": "8.62.1",
|
||||
"@typescript-eslint/visitor-keys": "8.62.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3172,14 +3163,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
|
||||
"integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
|
||||
"integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.62.0",
|
||||
"@typescript-eslint/types": "^8.62.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.62.1",
|
||||
"@typescript-eslint/types": "^8.62.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3194,14 +3185,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
|
||||
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
|
||||
"integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0"
|
||||
"@typescript-eslint/types": "8.62.1",
|
||||
"@typescript-eslint/visitor-keys": "8.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3212,9 +3203,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
|
||||
"integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
|
||||
"integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3229,15 +3220,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
|
||||
"integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
|
||||
"integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||
"@typescript-eslint/utils": "8.62.0",
|
||||
"@typescript-eslint/types": "8.62.1",
|
||||
"@typescript-eslint/typescript-estree": "8.62.1",
|
||||
"@typescript-eslint/utils": "8.62.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -3254,9 +3245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
|
||||
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3268,16 +3259,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
|
||||
"integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
|
||||
"integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.62.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.62.0",
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||
"@typescript-eslint/project-service": "8.62.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.62.1",
|
||||
"@typescript-eslint/types": "8.62.1",
|
||||
"@typescript-eslint/visitor-keys": "8.62.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -3309,16 +3300,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
|
||||
"integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
|
||||
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.62.0",
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/typescript-estree": "8.62.0"
|
||||
"@typescript-eslint/scope-manager": "8.62.1",
|
||||
"@typescript-eslint/types": "8.62.1",
|
||||
"@typescript-eslint/typescript-estree": "8.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3333,13 +3324,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
|
||||
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
|
||||
"integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.62.0",
|
||||
"@typescript-eslint/types": "8.62.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3918,9 +3909,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"version": "2.10.41",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
||||
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -4058,9 +4049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4644,9 +4635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.381",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
|
||||
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
|
||||
"version": "1.5.384",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
|
||||
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -4777,9 +4768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -5700,9 +5691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
|
||||
"integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
|
||||
"version": "26.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5758,9 +5749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"version": "11.1.9",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
|
||||
"integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -7690,9 +7681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
||||
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -7712,12 +7703,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
|
||||
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.0"
|
||||
"react-router": "7.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -7748,9 +7739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz",
|
||||
"integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==",
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz",
|
||||
"integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
@@ -7761,7 +7752,7 @@
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
@@ -7927,13 +7918,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
||||
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.137.0",
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -7943,21 +7934,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.3",
|
||||
"@rolldown/binding-darwin-x64": "1.1.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-x64": "1.1.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.4",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
@@ -8830,16 +8821,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.62.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
|
||||
"integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
|
||||
"integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||
"@typescript-eslint/utils": "8.62.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.62.1",
|
||||
"@typescript-eslint/parser": "8.62.1",
|
||||
"@typescript-eslint/typescript-estree": "8.62.1",
|
||||
"@typescript-eslint/utils": "8.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -8971,16 +8962,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
|
||||
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "~1.1.2",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -24,21 +24,22 @@
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.3",
|
||||
"i18next": "^26.3.4",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"recharts": "^3.9.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.1",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -57,8 +58,8 @@
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "8.1.0",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "8.1.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -2147,6 +2147,34 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PanelUpdateStatus": {
|
||||
"description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"finishedAt": {
|
||||
"example": 1735689612,
|
||||
"type": "integer"
|
||||
},
|
||||
"runId": {
|
||||
"example": "1735689600123456789",
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"example": "success",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exitCode",
|
||||
"finishedAt",
|
||||
"runId",
|
||||
"state"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"properties": {
|
||||
"cpuPct": {
|
||||
@@ -3901,6 +3929,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/getUpdateStatus": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.",
|
||||
"operationId": "get_panel_api_server_getUpdateStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/PanelUpdateStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"exitCode": 0,
|
||||
"finishedAt": 1735689612,
|
||||
"runId": "1735689600123456789",
|
||||
"state": "success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/getConfigJson": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4427,6 +4496,12 @@
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"runId": "1735689600123456789"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,12 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"total": 0,
|
||||
"up": 0
|
||||
},
|
||||
"PanelUpdateStatus": {
|
||||
"exitCode": 0,
|
||||
"finishedAt": 1735689612,
|
||||
"runId": "1735689600123456789",
|
||||
"state": "success"
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"cpuPct": 12.5,
|
||||
"error": "",
|
||||
|
||||
@@ -2121,6 +2121,34 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PanelUpdateStatus": {
|
||||
"description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"finishedAt": {
|
||||
"example": 1735689612,
|
||||
"type": "integer"
|
||||
},
|
||||
"runId": {
|
||||
"example": "1735689600123456789",
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"example": "success",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exitCode",
|
||||
"finishedAt",
|
||||
"runId",
|
||||
"state"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"properties": {
|
||||
"cpuPct": {
|
||||
|
||||
@@ -464,6 +464,13 @@ export interface OutboundTraffics {
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface PanelUpdateStatus {
|
||||
exitCode: number;
|
||||
finishedAt: number;
|
||||
runId: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface ProbeResultUI {
|
||||
cpuPct: number;
|
||||
error: string;
|
||||
|
||||
@@ -495,6 +495,14 @@ export const OutboundTrafficsSchema = z.object({
|
||||
});
|
||||
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
|
||||
|
||||
export const PanelUpdateStatusSchema = z.object({
|
||||
exitCode: z.number().int(),
|
||||
finishedAt: z.number().int(),
|
||||
runId: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
|
||||
|
||||
export const ProbeResultUISchema = z.object({
|
||||
cpuPct: z.number(),
|
||||
error: z.string(),
|
||||
|
||||
@@ -551,9 +551,9 @@ export function useClients() {
|
||||
|
||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientStatRow[] };
|
||||
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
setAllClientStats(p.clients);
|
||||
if (p.snapshot !== false) setAllClientStats(p.clients);
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { Sniffing } from '@/schemas/primitives';
|
||||
import type { z } from 'zod';
|
||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||
|
||||
@@ -164,7 +164,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
const streamRecord = streamSettings as unknown as Record<string, unknown>;
|
||||
const xh = streamRecord.xhttpSettings;
|
||||
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
|
||||
const xhttp = xh as Record<string, unknown>;
|
||||
const parsed = XHttpStreamSettingsSchema.safeParse(xh);
|
||||
const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
|
||||
streamRecord.xhttpSettings = xhttp;
|
||||
const xmux = xhttp.xmux;
|
||||
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
|
||||
xhttp.enableXmux = true;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
import { getHeaderValue } from './headers';
|
||||
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||
import { deriveSpiderX } from './spider-x';
|
||||
|
||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
||||
@@ -322,6 +323,7 @@ export interface GenVlessLinkInput {
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientId: string;
|
||||
clientKey?: string;
|
||||
flow?: VlessClient['flow'];
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
@@ -350,6 +352,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientId,
|
||||
clientKey = '',
|
||||
flow = '',
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
@@ -430,7 +433,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
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);
|
||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
||||
if (spx.length > 0) params.set('spx', spx);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
} else {
|
||||
@@ -512,7 +516,7 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
|
||||
|
||||
// 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 {
|
||||
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams, clientKey: string): void {
|
||||
if (stream.security !== 'reality') return;
|
||||
const reality = stream.realitySettings;
|
||||
params.set('pbk', reality.settings.publicKey);
|
||||
@@ -526,7 +530,8 @@ function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, para
|
||||
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);
|
||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
||||
if (spx.length > 0) params.set('spx', spx);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
|
||||
@@ -537,6 +542,7 @@ export interface GenTrojanLinkInput {
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientPassword: string;
|
||||
clientKey?: string;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
@@ -551,6 +557,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientPassword,
|
||||
clientKey = '',
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
@@ -571,7 +578,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
} else if (security === 'reality') {
|
||||
params.set('security', 'reality');
|
||||
writeRealityParams(stream, params);
|
||||
writeRealityParams(stream, params, clientKey);
|
||||
} else {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
@@ -1017,7 +1024,13 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
|
||||
// `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 };
|
||||
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string; subId?: string };
|
||||
|
||||
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
||||
// keys on — subscription id first, unique email as the fallback.
|
||||
function clientSubKey(client: ClientShape): string {
|
||||
return client.subId || client.email || '';
|
||||
}
|
||||
|
||||
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
||||
switch (inbound.protocol) {
|
||||
@@ -1066,6 +1079,7 @@ export function genLink(input: GenLinkInput): string {
|
||||
return genVlessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
clientId: client.id ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
flow: client.flow,
|
||||
externalProxy,
|
||||
});
|
||||
@@ -1081,6 +1095,7 @@ export function genLink(input: GenLinkInput): string {
|
||||
return genTrojanLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
clientPassword: client.password ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
externalProxy,
|
||||
});
|
||||
case 'hysteria':
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||
|
||||
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
|
||||
// links and subscription links agree; returns '' when there is no seed and
|
||||
// no client key (the caller then omits spx, as the legacy builder did).
|
||||
export function deriveSpiderX(seed: string, clientKey: string): string {
|
||||
if (!seed && !clientKey) return '';
|
||||
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
|
||||
}
|
||||
@@ -108,6 +108,18 @@ export function validateRealityTarget(target: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
|
||||
const lift = (legacy: string, renamed: string) => {
|
||||
const v = obj[legacy];
|
||||
if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
|
||||
obj[renamed] = v;
|
||||
}
|
||||
delete obj[legacy];
|
||||
};
|
||||
lift('sessionPlacement', 'sessionIDPlacement');
|
||||
lift('sessionKey', 'sessionIDKey');
|
||||
}
|
||||
|
||||
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||
for (const key of keys) {
|
||||
const v = obj[key];
|
||||
@@ -160,6 +172,7 @@ export function normalizeXhttpForWire(
|
||||
side: StreamWireSide,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
liftLegacyXhttpSessionKeys(out);
|
||||
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
||||
const enableXmux = out.enableXmux === true;
|
||||
delete out.enableXmux;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type VlessAuthKind =
|
||||
| 'x25519'
|
||||
| 'x25519_xorpub'
|
||||
| 'x25519_random'
|
||||
| 'mlkem768'
|
||||
| 'mlkem768_xorpub'
|
||||
| 'mlkem768_random';
|
||||
|
||||
export const VLESS_AUTH_LABEL_KEYS: Record<VlessAuthKind, string> = {
|
||||
x25519: 'pages.inbounds.vlessAuthX25519',
|
||||
x25519_xorpub: 'pages.inbounds.vlessAuthX25519Xorpub',
|
||||
x25519_random: 'pages.inbounds.vlessAuthX25519Random',
|
||||
mlkem768: 'pages.inbounds.vlessAuthMlkem768',
|
||||
mlkem768_xorpub: 'pages.inbounds.vlessAuthMlkem768Xorpub',
|
||||
mlkem768_random: 'pages.inbounds.vlessAuthMlkem768Random',
|
||||
};
|
||||
|
||||
const MLKEM768_MIN_KEY_LENGTH = 300;
|
||||
|
||||
export function vlessEncryptionAuthKind(encryption: string): VlessAuthKind | null {
|
||||
if (!encryption || encryption === 'none') return null;
|
||||
const parts = encryption.split('.').filter(Boolean);
|
||||
const authKey = parts[parts.length - 1] || '';
|
||||
if (!authKey) return null;
|
||||
const mode = parts[1] || 'native';
|
||||
const keyType = authKey.length > MLKEM768_MIN_KEY_LENGTH ? 'mlkem768' : 'x25519';
|
||||
if (mode === 'xorpub' || mode === 'random') return `${keyType}_${mode}`;
|
||||
return keyType;
|
||||
}
|
||||
@@ -103,6 +103,9 @@ export class AllSetting {
|
||||
hasWarpSecret = false;
|
||||
hasNordSecret = false;
|
||||
hasSmtpPassword = false;
|
||||
clearTgBotToken = false;
|
||||
clearLdapPassword = false;
|
||||
clearSmtpPassword = false;
|
||||
|
||||
constructor(data?: unknown) {
|
||||
if (data != null) {
|
||||
|
||||
@@ -323,6 +323,12 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/server/getPanelUpdateInfo',
|
||||
summary: 'Check whether a newer 3x-ui release is available on GitHub.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getUpdateStatus',
|
||||
summary: 'Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.',
|
||||
responseSchema: 'PanelUpdateStatus',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getConfigJson',
|
||||
@@ -407,6 +413,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updatePanel',
|
||||
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||
response: '{\n "success": true,\n "obj": {\n "runId": "1735689600123456789"\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -492,6 +492,13 @@ export default function ClientFormModal({
|
||||
if (form.wgPreSharedKey) {
|
||||
clientPayload.preSharedKey = form.wgPreSharedKey;
|
||||
}
|
||||
const allowedIPs = form.wgAllowedIPs
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
if (allowedIPs.length > 0) {
|
||||
clientPayload.allowedIPs = allowedIPs;
|
||||
}
|
||||
}
|
||||
|
||||
const externalLinks: ExternalLinkInput[] = form.externalLinks
|
||||
@@ -802,11 +809,16 @@ export default function ClientFormModal({
|
||||
onChange={(e) => update('wgPreSharedKey', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isEdit && form.wgAllowedIPs && (
|
||||
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
|
||||
<Input value={form.wgAllowedIPs} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
label={t('pages.clients.wireguardAllowedIPs')}
|
||||
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<Input
|
||||
value={form.wgAllowedIPs}
|
||||
placeholder="10.0.0.2/32"
|
||||
onChange={(e) => update('wgAllowedIPs', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Protocols } from '@/schemas/primitives';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
|
||||
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||
import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
|
||||
import { SniffingSchema } from '@/schemas/primitives/sniffing';
|
||||
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
|
||||
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
|
||||
@@ -248,6 +249,7 @@ export default function InboundFormModal({
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
randomizeSpiderX,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
pinFromCert,
|
||||
@@ -316,27 +318,14 @@ export default function InboundFormModal({
|
||||
form.setFieldValue(['settings', 'encryption'], 'none');
|
||||
};
|
||||
|
||||
const vlessAuthKind = vlessEncryptionAuthKind(
|
||||
typeof vlessEncryption === 'string' ? vlessEncryption : '',
|
||||
);
|
||||
const selectedVlessAuth = (() => {
|
||||
const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
|
||||
if (!enc || enc === 'none') return 'None';
|
||||
const parts = enc.split('.').filter(Boolean);
|
||||
const authKey = parts[parts.length - 1] || '';
|
||||
if (!authKey) return t('pages.inbounds.vlessAuthCustom');
|
||||
const mode = parts[1] || 'native';
|
||||
const keyType = authKey.length > 300 ? 'mlkem768' : 'x25519';
|
||||
if (mode === 'xorpub') {
|
||||
return keyType === 'mlkem768'
|
||||
? t('pages.inbounds.vlessAuthMlkem768Xorpub')
|
||||
: t('pages.inbounds.vlessAuthX25519Xorpub');
|
||||
}
|
||||
if (mode === 'random') {
|
||||
return keyType === 'mlkem768'
|
||||
? t('pages.inbounds.vlessAuthMlkem768Random')
|
||||
: t('pages.inbounds.vlessAuthX25519Random');
|
||||
}
|
||||
return keyType === 'mlkem768'
|
||||
? t('pages.inbounds.vlessAuthMlkem768')
|
||||
: t('pages.inbounds.vlessAuthX25519');
|
||||
if (!vlessAuthKind) return t('pages.inbounds.vlessAuthCustom');
|
||||
return t(VLESS_AUTH_LABEL_KEYS[vlessAuthKind]);
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -702,7 +691,7 @@ export default function InboundFormModal({
|
||||
|
||||
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
|
||||
|
||||
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
||||
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
||||
|
||||
{isFallbackHost && fallbacksCard}
|
||||
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
|
||||
@@ -896,6 +885,7 @@ export default function InboundFormModal({
|
||||
scanRealityCandidates={scanRealityCandidates}
|
||||
applyRealityScanResult={applyRealityScanResult}
|
||||
randomizeShortIds={randomizeShortIds}
|
||||
randomizeSpiderX={randomizeSpiderX}
|
||||
genRealityKeypair={genRealityKeypair}
|
||||
clearRealityKeypair={clearRealityKeypair}
|
||||
genMldsa65={genMldsa65}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
|
||||
type VlessAuthKind =
|
||||
| 'x25519'
|
||||
| 'x25519_xorpub'
|
||||
| 'x25519_random'
|
||||
| 'mlkem768'
|
||||
| 'mlkem768_xorpub'
|
||||
| 'mlkem768_random';
|
||||
import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
|
||||
|
||||
interface VlessFieldsProps {
|
||||
saving: boolean;
|
||||
selectedVlessAuth: string;
|
||||
vlessAuthKind: VlessAuthKind | null;
|
||||
network: string;
|
||||
security: string;
|
||||
getNewVlessEnc: (kind: VlessAuthKind) => void;
|
||||
@@ -22,22 +17,22 @@ interface VlessFieldsProps {
|
||||
export default function VlessFields({
|
||||
saving,
|
||||
selectedVlessAuth,
|
||||
vlessAuthKind,
|
||||
network,
|
||||
security,
|
||||
getNewVlessEnc,
|
||||
clearVlessEnc,
|
||||
}: VlessFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519');
|
||||
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
|
||||
|
||||
const authOptions = [
|
||||
{ value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') },
|
||||
{ value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') },
|
||||
{ value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
|
||||
{ value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') },
|
||||
{ value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') },
|
||||
{ value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') },
|
||||
];
|
||||
useEffect(() => {
|
||||
setAuthKind(vlessAuthKind ?? 'x25519');
|
||||
}, [vlessAuthKind]);
|
||||
|
||||
const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map(
|
||||
([value, labelKey]) => ({ value, label: t(labelKey) }),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RealityFormProps {
|
||||
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
||||
applyRealityScanResult: (result: RealityScanResult) => void;
|
||||
randomizeShortIds: () => void;
|
||||
randomizeSpiderX: () => void;
|
||||
genRealityKeypair: () => void;
|
||||
clearRealityKeypair: () => void;
|
||||
genMldsa65: () => void;
|
||||
@@ -30,6 +31,7 @@ export default function RealityForm({
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
randomizeSpiderX,
|
||||
genRealityKeypair,
|
||||
clearRealityKeypair,
|
||||
genMldsa65,
|
||||
@@ -147,10 +149,18 @@ export default function RealityForm({
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
|
||||
label={t('pages.inbounds.form.spiderX')}
|
||||
tooltip={t('pages.inbounds.form.spiderXHint')}
|
||||
>
|
||||
<Input />
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
|
||||
|
||||
@@ -124,6 +124,13 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
);
|
||||
};
|
||||
|
||||
const randomizeSpiderX = () => {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'realitySettings', 'settings', 'spiderX'],
|
||||
`/${RandomUtil.randomSeq(15)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const getNewEchCert = async () => {
|
||||
const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
|
||||
setSaving(true);
|
||||
@@ -270,6 +277,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
}
|
||||
form.setFieldValue('streamSettings', cleaned);
|
||||
if (next === 'reality') {
|
||||
randomizeSpiderX();
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
||||
if (msg?.success) {
|
||||
@@ -292,6 +300,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
randomizeSpiderX,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
pinFromCert,
|
||||
|
||||
@@ -6,8 +6,11 @@ import axios from 'axios';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import type { PanelUpdateStatus } from '@/generated/types';
|
||||
import './PanelUpdateModal.css';
|
||||
|
||||
type UpdateOutcome = 'success' | 'failed' | 'timeout';
|
||||
|
||||
export interface PanelUpdateInfo {
|
||||
channel?: string;
|
||||
currentVersion: string;
|
||||
@@ -45,19 +48,23 @@ export default function PanelUpdateModal({
|
||||
|
||||
const isDev = info.channel === 'dev';
|
||||
|
||||
async function pollUntilBack(): Promise<boolean> {
|
||||
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||
if (r?.data?.success) return true;
|
||||
const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 });
|
||||
const status = r?.data?.obj as PanelUpdateStatus | undefined;
|
||||
if (status?.runId === expectedRunId) {
|
||||
if (status.state === 'success') return 'success';
|
||||
if (status.state === 'failed') return 'failed';
|
||||
}
|
||||
} catch {
|
||||
/* still restarting */
|
||||
}
|
||||
await PromiseUtil.sleep(2000);
|
||||
}
|
||||
return false;
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
async function handleChannel(checked: boolean) {
|
||||
@@ -81,14 +88,24 @@ export default function PanelUpdateModal({
|
||||
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
|
||||
onClose();
|
||||
onBusy({ busy: true, tip });
|
||||
const result = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
|
||||
if (!result?.success) {
|
||||
onBusy({ busy: false });
|
||||
return;
|
||||
}
|
||||
const back = await pollUntilBack();
|
||||
if (back) await PromiseUtil.sleep(800);
|
||||
window.location.reload();
|
||||
const outcome = await pollUpdateStatus(result.obj?.runId ?? '');
|
||||
onBusy({ busy: false });
|
||||
if (outcome === 'success') {
|
||||
await PromiseUtil.sleep(800);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
modal[outcome === 'failed' ? 'error' : 'warning']({
|
||||
title: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedTitle' : 'pages.index.panelUpdateUnknownTitle'),
|
||||
content: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedDesc' : 'pages.index.panelUpdateUnknownDesc'),
|
||||
okText: t('refresh'),
|
||||
onOk: () => window.location.reload(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingListItem } from '@/components/ui';
|
||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import SecretInput from './SecretInput';
|
||||
|
||||
interface EmailTabProps {
|
||||
allSetting: AllSetting;
|
||||
@@ -72,10 +73,13 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
|
||||
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
|
||||
<Input.Password value={allSetting.smtpPassword}
|
||||
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
|
||||
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
|
||||
description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
|
||||
<SecretInput value={allSetting.smtpPassword}
|
||||
configured={allSetting.hasSmtpPassword}
|
||||
clearArmed={allSetting.clearSmtpPassword}
|
||||
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
|
||||
onChange={(v) => updateSetting({ smtpPassword: v })}
|
||||
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath } from './uriPath';
|
||||
import SecretInput from './SecretInput';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
@@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('password')}
|
||||
description={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
|
||||
description={allSetting.hasLdapPassword && !allSetting.clearLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
|
||||
>
|
||||
<Input.Password
|
||||
<SecretInput
|
||||
value={allSetting.ldapPassword}
|
||||
placeholder={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordPlaceholder') : ''}
|
||||
onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
|
||||
configured={allSetting.hasLdapPassword}
|
||||
clearArmed={allSetting.clearLdapPassword}
|
||||
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
|
||||
onChange={(v) => updateSetting({ ldapPassword: v })}
|
||||
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SecretInputProps {
|
||||
value: string;
|
||||
configured: boolean;
|
||||
clearArmed: boolean;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
onClearArmedChange: (armed: boolean) => void;
|
||||
}
|
||||
|
||||
export default function SecretInput({
|
||||
value,
|
||||
configured,
|
||||
clearArmed,
|
||||
placeholder,
|
||||
onChange,
|
||||
onClearArmedChange,
|
||||
}: SecretInputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input.Password
|
||||
value={value}
|
||||
placeholder={configured && !clearArmed ? placeholder : ''}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (clearArmed) onClearArmedChange(false);
|
||||
}}
|
||||
/>
|
||||
{configured && (
|
||||
<Button
|
||||
danger={clearArmed}
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
onClearArmedChange(!clearArmed);
|
||||
}}
|
||||
>
|
||||
{clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
|
||||
</Button>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export default function SettingsPage() {
|
||||
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
}
|
||||
}, [activeSlug, allSetting, updateSetting]);
|
||||
}, [activeSlug, allSetting, updateSetting, savePayload]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingListItem } from '@/components/ui';
|
||||
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import SecretInput from './SecretInput';
|
||||
|
||||
interface TelegramTabProps {
|
||||
allSetting: AllSetting;
|
||||
@@ -193,12 +194,15 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.telegramToken')}
|
||||
description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
|
||||
description={allSetting.hasTgBotToken && !allSetting.clearTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
|
||||
>
|
||||
<Input.Password
|
||||
<SecretInput
|
||||
value={allSetting.tgBotToken}
|
||||
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
|
||||
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
|
||||
configured={allSetting.hasTgBotToken}
|
||||
clearArmed={allSetting.clearTgBotToken}
|
||||
placeholder={t('pages.settings.telegramTokenPlaceholder')}
|
||||
onChange={(v) => updateSetting({ tgBotToken: v })}
|
||||
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
|
||||
@@ -367,7 +367,6 @@ export default function BalancersTab({
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={mutate}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd';
|
||||
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
|
||||
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import {
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
type PingConfigObject,
|
||||
} from '@/schemas/observatory';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { settingsRequireBurstObservatory } from './balancer-helpers';
|
||||
|
||||
interface ObservatorySettingsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
mutate: (mutator: (next: XraySettingsValue) => void) => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
|
||||
@@ -43,7 +43,6 @@ function SelectorTags({ tags }: { tags: string[] }) {
|
||||
export default function ObservatorySettingsTab({
|
||||
templateSettings,
|
||||
mutate,
|
||||
isMobile,
|
||||
}: ObservatorySettingsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -63,13 +62,10 @@ export default function ObservatorySettingsTab({
|
||||
|
||||
const hasObservatory = observatory != null;
|
||||
const hasBurst = burst != null;
|
||||
|
||||
const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory');
|
||||
const effectiveView = !hasObservatory && hasBurst
|
||||
const hasMixedObservers = hasObservatory && hasBurst;
|
||||
const activeView = hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
|
||||
? 'burstObservatory'
|
||||
: !hasBurst && hasObservatory
|
||||
? 'observatory'
|
||||
: view;
|
||||
: 'observatory';
|
||||
|
||||
function patchObservatory(patch: Partial<ObservatoryObject>) {
|
||||
mutate((tt) => {
|
||||
@@ -220,19 +216,15 @@ export default function ObservatorySettingsTab({
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} />
|
||||
{hasObservatory && hasBurst && (
|
||||
<Segmented
|
||||
block={isMobile}
|
||||
value={effectiveView}
|
||||
onChange={(v) => setView(v as 'observatory' | 'burstObservatory')}
|
||||
options={[
|
||||
{ label: t('pages.xray.observatory.title'), value: 'observatory' },
|
||||
{ label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
|
||||
]}
|
||||
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
|
||||
{hasMixedObservers && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('pages.xray.observatory.mixedLegacy')}
|
||||
/>
|
||||
)}
|
||||
<div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
|
||||
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,16 @@ export function collectSelectors(list: BalancerObject[]): string[] {
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
|
||||
const type = b.strategy?.type || 'random';
|
||||
return type === 'leastLoad' || ((type === 'random' || type === 'roundRobin') && (b.fallbackTag ?? '').length > 0);
|
||||
}
|
||||
|
||||
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
|
||||
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
|
||||
return balancers.some(balancerRequiresBurstObservatory);
|
||||
}
|
||||
|
||||
// syncObservatories keeps the (burst)observatory sections aligned with the
|
||||
// balancer strategies that actually require them. Observatories have no runtime
|
||||
// reload API in xray-core, so creating OR removing one forces a full process
|
||||
@@ -36,39 +46,35 @@ export function collectSelectors(list: BalancerObject[]): string[] {
|
||||
// when its fallbackTag is set (issue #5605): with a fallbackTag the strategy
|
||||
// calls RequireFeatures(Observatory) and the core aborts startup with "not all
|
||||
// dependencies are resolved" if none exists; without a fallbackTag it never even
|
||||
// consults an observatory. leastLoad always needs the burst observer, leastPing
|
||||
// the regular one.
|
||||
// consults an observatory. leastLoad needs the burst observer, while leastPing
|
||||
// can use any extension.Observatory result with Alive/Delay. When a burst
|
||||
// observer is required, keep all observer-backed balancers on burstObservatory
|
||||
// to avoid xray-core resolving the earlier regular observatory feature instead.
|
||||
//
|
||||
// So each observer lives exactly as long as something requires it, and is
|
||||
// dropped the moment nothing does — clearing the last fallbackTag (or deleting
|
||||
// the last leastLoad) removes the burst observer again. A no-fallback balancer's
|
||||
// selector is still probed while the observer exists for another reason, but
|
||||
// never keeps it alive on its own.
|
||||
// the last leastLoad) removes the burst observer again. A no-fallback
|
||||
// Random/RoundRobin balancer never expands the observer either, because those
|
||||
// strategies do not consume observer data.
|
||||
export function syncObservatories(t: XraySettingsValue) {
|
||||
const balancers = (t.routing?.balancers || []) as BalancerObject[];
|
||||
|
||||
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
||||
if (leastPings.length > 0) {
|
||||
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
|
||||
(t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
|
||||
} else {
|
||||
delete t.observatory;
|
||||
}
|
||||
|
||||
const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0;
|
||||
const required = balancers.filter((b) => {
|
||||
const type = b.strategy?.type || 'random';
|
||||
if (type === 'leastLoad') return true;
|
||||
return (type === 'random' || type === 'roundRobin') && hasFallback(b);
|
||||
});
|
||||
const optional = balancers.filter((b) => {
|
||||
const type = b.strategy?.type || 'random';
|
||||
return (type === 'random' || type === 'roundRobin') && !hasFallback(b);
|
||||
});
|
||||
const required = balancers.filter(balancerRequiresBurstObservatory);
|
||||
if (required.length > 0) {
|
||||
delete t.observatory;
|
||||
if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]);
|
||||
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([
|
||||
...required,
|
||||
...leastPings,
|
||||
]);
|
||||
} else {
|
||||
delete t.burstObservatory;
|
||||
if (leastPings.length > 0) {
|
||||
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
|
||||
(t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
|
||||
} else {
|
||||
delete t.observatory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
|
||||
|
||||
exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
|
||||
|
||||
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
|
||||
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
|
||||
|
||||
exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
|
||||
|
||||
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
|
||||
|
||||
exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
|
||||
|
||||
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
|
||||
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
|
||||
|
||||
exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
|
||||
|
||||
|
||||
@@ -7,12 +7,208 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
|
||||
return { routing, ...extra } as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
|
||||
|
||||
function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, selectors: string[] = []) {
|
||||
if (expected === 'none') {
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected === 'observatory') {
|
||||
expect(t.observatory).toBeDefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
|
||||
return;
|
||||
}
|
||||
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
|
||||
}
|
||||
|
||||
// Observatory sections have no reload API in xray-core, so creating one turns
|
||||
// a balancer save from a live (hot-applied) routing change into a full
|
||||
// restart. These tests pin the rule: only strategies that genuinely need an
|
||||
// observer may create one — which, for random/roundRobin, means a fallbackTag
|
||||
// is set (xray-core then requires the Observatory feature; see #5605).
|
||||
describe('syncObservatories', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'random without fallback',
|
||||
balancers: [{ tag: 'random', selector: ['random-out'] }],
|
||||
expected: 'none' as const,
|
||||
selectors: [],
|
||||
},
|
||||
{
|
||||
name: 'random with fallback',
|
||||
balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['random-out'],
|
||||
},
|
||||
{
|
||||
name: 'roundRobin without fallback',
|
||||
balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
|
||||
expected: 'none' as const,
|
||||
selectors: [],
|
||||
},
|
||||
{
|
||||
name: 'roundRobin with fallback',
|
||||
balancers: [{ tag: 'rr', selector: ['rr-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['rr-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing without fallback',
|
||||
balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing with fallback',
|
||||
balancers: [{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } }],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad without fallback',
|
||||
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad with fallback',
|
||||
balancers: [{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } }],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
|
||||
const t = tpl({ balancers });
|
||||
syncObservatories(t);
|
||||
expectObserver(t, expected, selectors);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'random + roundRobin without fallbacks',
|
||||
balancers: [
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'none' as const,
|
||||
selectors: [],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + random without fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + roundRobin without fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'random fallback + random without fallback',
|
||||
balancers: [
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['random-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'roundRobin fallback + roundRobin without fallback',
|
||||
balancers: [
|
||||
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['rr-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad + random without fallback',
|
||||
balancers: [
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad + roundRobin without fallback',
|
||||
balancers: [
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + leastLoad with disjoint selectors',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + random fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'random-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + roundRobin fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'rr-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'all strategies mixed',
|
||||
balancers: [
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
|
||||
},
|
||||
{
|
||||
name: 'shared selectors are de-duplicated',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
|
||||
},
|
||||
])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
|
||||
const t = tpl({ balancers });
|
||||
syncObservatories(t);
|
||||
expectObserver(t, expected, selectors);
|
||||
});
|
||||
|
||||
it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
|
||||
syncObservatories(t);
|
||||
@@ -64,7 +260,7 @@ describe('syncObservatories', () => {
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps burstObservatory while another fallback balancer still needs it', () => {
|
||||
it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
@@ -76,7 +272,7 @@ describe('syncObservatories', () => {
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']);
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('creates burstObservatory for leastLoad (required by the strategy)', () => {
|
||||
@@ -86,20 +282,55 @@ describe('syncObservatories', () => {
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('creates observatory for leastPing (required by the strategy)', () => {
|
||||
it('creates observatory for leastPing when no burst observer is required', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeDefined();
|
||||
expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('keeps an existing burstObservatory in sync for random balancers (legacy setups)', () => {
|
||||
it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['least-load-out', 'least-ping-out']),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{ tag: 'rr', selector: ['round-robin-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
},
|
||||
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
|
||||
{ burstObservatory: { subjectSelector: ['stale'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']);
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('removes observatories when no balancer can use them', () => {
|
||||
@@ -112,6 +343,30 @@ describe('syncObservatories', () => {
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
|
||||
{
|
||||
observatory: { subjectSelector: ['stale-observatory-out'] },
|
||||
burstObservatory: { subjectSelector: ['stale-burst-out'] },
|
||||
},
|
||||
);
|
||||
syncObservatories(t);
|
||||
expectObserver(t, 'observatory', ['lp-out']);
|
||||
});
|
||||
|
||||
it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
|
||||
{
|
||||
observatory: { subjectSelector: ['stale-observatory-out'] },
|
||||
burstObservatory: { subjectSelector: ['stale-burst-out'] },
|
||||
},
|
||||
);
|
||||
syncObservatories(t);
|
||||
expectObserver(t, 'burstObservatory', ['ll-out']);
|
||||
});
|
||||
|
||||
it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RawInboundRow,
|
||||
} from '@/lib/xray/inbound-form-adapter';
|
||||
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
|
||||
@@ -304,3 +305,51 @@ describe('subSortIndex', () => {
|
||||
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy xhttp session keys on edit (#5621)', () => {
|
||||
const legacyXhttpRow: RawInboundRow = {
|
||||
...vlessRow,
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: {
|
||||
path: '/xh',
|
||||
mode: 'packet-up',
|
||||
sessionPlacement: 'cookie',
|
||||
sessionKey: 'x_session',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => {
|
||||
const values = rawInboundToFormValues(legacyXhttpRow);
|
||||
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
|
||||
expect(xhttp.sessionIDPlacement).toBe('cookie');
|
||||
expect(xhttp.sessionIDKey).toBe('x_session');
|
||||
expect(xhttp.sessionPlacement).toBeUndefined();
|
||||
expect(xhttp.sessionKey).toBeUndefined();
|
||||
expect(xhttp.path).toBe('/xh');
|
||||
expect(xhttp.xPaddingBytes).toBe('100-1000');
|
||||
});
|
||||
|
||||
it('formValuesToWirePayload never emits the legacy key names', () => {
|
||||
const values = rawInboundToFormValues(legacyXhttpRow);
|
||||
const payload = formValuesToWirePayload(values);
|
||||
const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
|
||||
expect(stream.xhttpSettings.sessionPlacement).toBeUndefined();
|
||||
expect(stream.xhttpSettings.sessionKey).toBeUndefined();
|
||||
expect(stream.xhttpSettings.sessionIDPlacement).toBe('cookie');
|
||||
expect(stream.xhttpSettings.sessionIDKey).toBe('x_session');
|
||||
});
|
||||
|
||||
it('normalizeXhttpForWire lifts stale legacy keys that bypassed the schema', () => {
|
||||
const out = normalizeXhttpForWire(
|
||||
{ sessionPlacement: 'header', sessionKey: 'x_raw' },
|
||||
'inbound',
|
||||
);
|
||||
expect(out.sessionIDPlacement).toBe('header');
|
||||
expect(out.sessionIDKey).toBe('x_raw');
|
||||
expect(out.sessionPlacement).toBeUndefined();
|
||||
expect(out.sessionKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('inbound security forms', () => {
|
||||
scanRealityCandidates={async () => []}
|
||||
applyRealityScanResult={noop}
|
||||
randomizeShortIds={noop}
|
||||
randomizeSpiderX={noop}
|
||||
genRealityKeypair={noop}
|
||||
clearRealityKeypair={noop}
|
||||
genMldsa65={noop}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import ObservatorySettingsTab from '@/pages/xray/balancers/ObservatorySettingsTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function renderTab(templateSettings: XraySettingsValue) {
|
||||
renderWithProviders(
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ObservatorySettingsTab', () => {
|
||||
it('shows one burst settings panel and warns for legacy mixed observers', () => {
|
||||
renderTab({
|
||||
routing: {
|
||||
balancers: [{ tag: 'll', selector: ['proxy-a'], strategy: { type: 'leastLoad' } }],
|
||||
},
|
||||
observatory: { subjectSelector: ['stale-regular'] },
|
||||
burstObservatory: { subjectSelector: ['proxy-a'] },
|
||||
} as unknown as XraySettingsValue);
|
||||
|
||||
expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
|
||||
expect(document.querySelector('.ant-segmented')).toBeFalsy();
|
||||
expect(screen.getByText('Probe Destination')).toBeTruthy();
|
||||
expect(screen.queryByText('Probe URL')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows regular observatory settings for mixed legacy configs that normalize back to leastPing only', () => {
|
||||
renderTab({
|
||||
routing: {
|
||||
balancers: [{ tag: 'lp', selector: ['proxy-b'], strategy: { type: 'leastPing' } }],
|
||||
},
|
||||
observatory: { subjectSelector: ['proxy-b'] },
|
||||
burstObservatory: { subjectSelector: ['stale-burst'] },
|
||||
} as unknown as XraySettingsValue);
|
||||
|
||||
expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
|
||||
expect(document.querySelector('.ant-segmented')).toBeFalsy();
|
||||
expect(screen.getByText('Probe URL')).toBeTruthy();
|
||||
expect(screen.queryByText('Probe Destination')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,65 @@ describe('outbound deletion', () => {
|
||||
expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cascade-removes the burst observer when deleting an outbound removes the last leastLoad balancer', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'll-out' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['ll-out'] },
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 0);
|
||||
expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
|
||||
expect(impact.burst).toBe(true);
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('cascade-switches from burst to regular observer when only leastPing remains', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 1);
|
||||
expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
|
||||
expect(impact.burst).toBe(true);
|
||||
applyOutboundDeletion(tt, 1);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
|
||||
});
|
||||
|
||||
it('cascade-keeps burst observer when leastPing is removed but leastLoad remains', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 0);
|
||||
expect(impact.balancers).toEqual([{ tag: 'lp', reason: 'selectorEmptied' }]);
|
||||
expect(impact.burst).toBe(false);
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
|
||||
});
|
||||
|
||||
it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [
|
||||
@@ -253,6 +312,68 @@ describe('balancer deletion', () => {
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports and removes the burst observer when deleting the last leastLoad balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [], balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports and removes the burst observer when deleting the last fallback balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [], balancers: [{ tag: 'rf', selector: ['a'], fallbackTag: 'direct' }] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('switches from burst to regular observer when the deleted balancer was the last burst-required one', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planBalancerDeletion(tt, 1);
|
||||
expect(impact.burst).toBe(true);
|
||||
expect(impact.observatory).toBe(false);
|
||||
applyBalancerDeletion(tt, 1);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
|
||||
});
|
||||
|
||||
it('keeps burst observer when deleting leastPing but a burst-required balancer remains', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planBalancerDeletion(tt, 0);
|
||||
expect(impact.burst).toBe(false);
|
||||
expect(impact.observatory).toBe(false);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
|
||||
});
|
||||
|
||||
it('does not report rules when the deleted balancer is unreferenced', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { deriveSpiderX } from '@/lib/xray/spider-x';
|
||||
|
||||
// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors
|
||||
// in internal/sub/service_sharelink_test.go: subscription links come from Go,
|
||||
// panel links from this module, and the two must agree byte-for-byte.
|
||||
describe('deriveSpiderX', () => {
|
||||
it('matches the Go deriveSpiderX vectors', () => {
|
||||
expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
|
||||
expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
|
||||
});
|
||||
|
||||
it('is stable per client, distinct across clients, and rotates with the seed', () => {
|
||||
expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice'));
|
||||
expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob'));
|
||||
expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice'));
|
||||
});
|
||||
|
||||
it('returns empty when there is nothing to derive from', () => {
|
||||
expect(deriveSpiderX('', '')).toBe('');
|
||||
});
|
||||
|
||||
it('emits a /-prefixed 15-hex-char path', () => {
|
||||
expect(deriveSpiderX('/some-seed', 'client@example.com')).toMatch(/^\/[0-9a-f]{15}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
|
||||
|
||||
const x25519Key = 'kO9pIKKPtoUCzo3ZWfWfp0lQoWCyJC1TqL8oz1hpsFM';
|
||||
const mlkem768Key = 'A'.repeat(1590);
|
||||
|
||||
describe('vlessEncryptionAuthKind', () => {
|
||||
const cases: { name: string; encryption: string; want: ReturnType<typeof vlessEncryptionAuthKind> }[] = [
|
||||
{ name: 'empty string', encryption: '', want: null },
|
||||
{ name: 'none', encryption: 'none', want: null },
|
||||
{ name: 'only dots', encryption: '...', want: null },
|
||||
{ name: 'x25519 native', encryption: `mlkem768x25519plus.native.600s.${x25519Key}`, want: 'x25519' },
|
||||
{ name: 'x25519 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${x25519Key}`, want: 'x25519_xorpub' },
|
||||
{ name: 'x25519 random', encryption: `mlkem768x25519plus.random.600s.${x25519Key}`, want: 'x25519_random' },
|
||||
{ name: 'mlkem768 native', encryption: `mlkem768x25519plus.native.600s.${mlkem768Key}`, want: 'mlkem768' },
|
||||
{ name: 'mlkem768 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${mlkem768Key}`, want: 'mlkem768_xorpub' },
|
||||
{ name: 'mlkem768 random', encryption: `mlkem768x25519plus.random.600s.${mlkem768Key}`, want: 'mlkem768_random' },
|
||||
{ name: 'two-segment value treated as native', encryption: `mlkem768x25519plus.${x25519Key}`, want: 'x25519' },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(c.name, () => {
|
||||
expect(vlessEncryptionAuthKind(c.encryption)).toBe(c.want);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.5
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.72.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.38.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/grpc v1.82.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@@ -63,7 +63,7 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/ratelimit v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.18.6
|
||||
github.com/klauspost/compress v1.19.0
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
|
||||
@@ -115,8 +115,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -178,8 +178,8 @@ github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
|
||||
github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -281,8 +281,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+96
-18
@@ -373,7 +373,7 @@ setup_ssl_certificate() {
|
||||
fi
|
||||
|
||||
# Install certificate
|
||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||
~/.acme.sh/acme.sh --installcert --force -d ${domain} \
|
||||
--key-file /root/cert/${domain}/privkey.pem \
|
||||
--fullchain-file /root/cert/${domain}/fullchain.pem \
|
||||
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
|
||||
@@ -517,7 +517,7 @@ setup_ip_certificate() {
|
||||
# Install certificate
|
||||
# Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
|
||||
# but the cert files are still installed. We check for files instead of exit code.
|
||||
~/.acme.sh/acme.sh --installcert -d ${ipv4} \
|
||||
~/.acme.sh/acme.sh --installcert --force -d ${ipv4} \
|
||||
--key-file "${certDir}/privkey.pem" \
|
||||
--fullchain-file "${certDir}/fullchain.pem" \
|
||||
--reloadcmd "${reloadCmd}" 2>&1 || true
|
||||
@@ -705,7 +705,7 @@ ssl_cert_issue() {
|
||||
|
||||
# install the certificate
|
||||
local installOutput=""
|
||||
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||
installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
|
||||
--key-file /root/cert/${domain}/privkey.pem \
|
||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||||
local installRc=$?
|
||||
@@ -1326,6 +1326,40 @@ setup_fail2ban() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
|
||||
# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
|
||||
# truncated unit file at the live path -- systemd would then fail to parse
|
||||
# it on the next daemon-reload/start. Same pattern already used for
|
||||
# /usr/bin/x-ui elsewhere in this script. source_is_url picks cp (from a
|
||||
# file already extracted from the release tarball) vs curl (GitHub fallback).
|
||||
_install_xui_service_unit() {
|
||||
local source="$1"
|
||||
local source_is_url="$2"
|
||||
local dest="${xui_service}/x-ui.service"
|
||||
local temp_file="${dest}.tmp.$$"
|
||||
|
||||
rm -f "$temp_file"
|
||||
if [[ "$source_is_url" == "true" ]]; then
|
||||
curl -fLRo "$temp_file" "$source" > /dev/null 2>&1
|
||||
else
|
||||
cp -f "$source" "$temp_file" > /dev/null 2>&1
|
||||
fi
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -s "$temp_file" ]]; then
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
mv -f "$temp_file" "$dest"
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
install_x-ui() {
|
||||
cd ${xui_folder%/x-ui}/
|
||||
|
||||
@@ -1342,6 +1376,11 @@ install_x-ui() {
|
||||
echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
|
||||
rm ${xui_folder}-linux-$(arch).tar.gz -f
|
||||
echo -e "${red}Downloaded x-ui release archive is empty${plain}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
tag_version=$1
|
||||
# The rolling dev channel ships under a fixed, non-semver tag that is
|
||||
@@ -1367,12 +1406,25 @@ install_x-ui() {
|
||||
echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
|
||||
rm ${xui_folder}-linux-$(arch).tar.gz -f
|
||||
echo -e "${red}Downloaded x-ui release archive is empty${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
curl -fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
|
||||
local xui_script_temp="/usr/bin/x-ui-temp.$$"
|
||||
rm -f "${xui_script_temp}"
|
||||
curl -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm -f "${xui_script_temp}"
|
||||
echo -e "${red}Failed to download x-ui.sh${plain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "${xui_script_temp}" ]]; then
|
||||
rm -f "${xui_script_temp}"
|
||||
echo -e "${red}Downloaded x-ui.sh is empty${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop x-ui service and remove old resources
|
||||
if [[ -e ${xui_folder}/ ]]; then
|
||||
@@ -1391,9 +1443,20 @@ install_x-ui() {
|
||||
|
||||
# Extract resources and set permissions
|
||||
tar zxvf x-ui-linux-$(arch).tar.gz
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm x-ui-linux-$(arch).tar.gz -f
|
||||
rm -f "${xui_script_temp}"
|
||||
echo -e "${red}Failed to extract the x-ui release archive -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}"
|
||||
exit 1
|
||||
fi
|
||||
rm x-ui-linux-$(arch).tar.gz -f
|
||||
|
||||
cd x-ui
|
||||
if [[ $? -ne 0 || ! -s x-ui ]]; then
|
||||
rm -f "${xui_script_temp}"
|
||||
echo -e "${red}Extracted x-ui archive is missing the x-ui binary -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x x-ui
|
||||
chmod +x x-ui.sh
|
||||
|
||||
@@ -1414,7 +1477,12 @@ install_x-ui() {
|
||||
fi
|
||||
|
||||
# Update x-ui cli and se set permission
|
||||
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
|
||||
mv -f "${xui_script_temp}" /usr/bin/x-ui
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm -f "${xui_script_temp}"
|
||||
echo -e "${red}Failed to install x-ui.sh${plain}"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x /usr/bin/x-ui
|
||||
mkdir -p /var/log/x-ui
|
||||
config_after_install
|
||||
@@ -1434,11 +1502,25 @@ install_x-ui() {
|
||||
fi
|
||||
|
||||
if [[ $release == "alpine" ]]; then
|
||||
curl -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
|
||||
xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
|
||||
rm -f "${xui_rc_temp}"
|
||||
curl -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm -f "${xui_rc_temp}"
|
||||
echo -e "${red}Failed to download x-ui.rc${plain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "${xui_rc_temp}" ]]; then
|
||||
rm -f "${xui_rc_temp}"
|
||||
echo -e "${red}Downloaded x-ui.rc is empty${plain}"
|
||||
exit 1
|
||||
fi
|
||||
mv -f "${xui_rc_temp}" /etc/init.d/x-ui
|
||||
if [[ $? -ne 0 ]]; then
|
||||
rm -f "${xui_rc_temp}"
|
||||
echo -e "${red}Failed to install x-ui.rc${plain}"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x /etc/init.d/x-ui
|
||||
rc-update add x-ui
|
||||
rc-service x-ui start
|
||||
@@ -1448,8 +1530,7 @@ install_x-ui() {
|
||||
|
||||
if [ -f "x-ui.service" ]; then
|
||||
echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
if _install_xui_service_unit "x-ui.service" "false"; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
@@ -1459,8 +1540,7 @@ install_x-ui() {
|
||||
ubuntu | debian | armbian)
|
||||
if [ -f "x-ui.service.debian" ]; then
|
||||
echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
if _install_xui_service_unit "x-ui.service.debian" "false"; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
@@ -1468,8 +1548,7 @@ install_x-ui() {
|
||||
arch | manjaro | parch)
|
||||
if [ -f "x-ui.service.arch" ]; then
|
||||
echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
if _install_xui_service_unit "x-ui.service.arch" "false"; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
@@ -1477,8 +1556,7 @@ install_x-ui() {
|
||||
*)
|
||||
if [ -f "x-ui.service.rhel" ]; then
|
||||
echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
|
||||
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
|
||||
if [[ $? -eq 0 ]]; then
|
||||
if _install_xui_service_unit "x-ui.service.rhel" "false"; then
|
||||
service_installed=true
|
||||
fi
|
||||
fi
|
||||
@@ -1491,17 +1569,17 @@ install_x-ui() {
|
||||
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
|
||||
service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian"
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
|
||||
service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch"
|
||||
;;
|
||||
*)
|
||||
curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
|
||||
service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
if ! _install_xui_service_unit "$service_unit_url" "true"; then
|
||||
echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -169,6 +169,14 @@ func GetDBPath() string {
|
||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||
}
|
||||
|
||||
// GetUpdateStatusFilePath returns the path to the panel self-update status
|
||||
// file update.sh writes on completion. It lives beside the database, outside
|
||||
// XUI_MAIN_FOLDER, so it survives an update regardless of what happens to
|
||||
// that folder.
|
||||
func GetUpdateStatusFilePath() string {
|
||||
return filepath.Join(GetDBFolderPath(), "update-status.json")
|
||||
}
|
||||
|
||||
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
|
||||
func GetDBKind() string {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
|
||||
|
||||
+62
-1
@@ -83,6 +83,9 @@ func initModels() error {
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
for _, mdl := range models {
|
||||
if IsPostgres() && postgresModelSettled(mdl) {
|
||||
continue
|
||||
}
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
@@ -119,6 +122,30 @@ func initModels() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// postgresModelSettled skips AutoMigrate when table, columns, and indexes all exist:
|
||||
// its catalog-filtered column probe misdetects on some setups and re-ADDs columns forever (#5665).
|
||||
func postgresModelSettled(mdl any) bool {
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasTable(mdl) {
|
||||
return false
|
||||
}
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(mdl); err != nil || stmt.Schema == nil {
|
||||
return false
|
||||
}
|
||||
for _, dbName := range stmt.Schema.DBNames {
|
||||
if !migrator.HasColumn(mdl, dbName) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, idx := range stmt.Schema.ParseIndexes() {
|
||||
if !migrator.HasIndex(mdl, idx.Name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dropLegacyForeignKeys() error {
|
||||
if !IsPostgres() {
|
||||
return nil
|
||||
@@ -637,7 +664,10 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
if err := seedWireguardPeersToClients(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
// Idempotent, not seeder-gated: bad values can re-enter via a restored
|
||||
// backup, so re-check on every start.
|
||||
return normalizeSettingPaths()
|
||||
}
|
||||
|
||||
// resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where
|
||||
@@ -742,6 +772,37 @@ func clearLegacyProxySettings() error {
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeSettingPaths repairs URI-path settings persisted before the
|
||||
// leading/trailing-slash rules existed (or restored from an old backup),
|
||||
// mirroring entity.AllSetting.CheckValid. CheckValid self-heals these on save,
|
||||
// but the frontend rejects the whole Settings form on the bad stored value
|
||||
// before a save can ever reach it (#5726), so the stored rows themselves must
|
||||
// be fixed. Idempotent; runs on every start.
|
||||
func normalizeSettingPaths() error {
|
||||
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
|
||||
var rows []model.Setting
|
||||
if err := db.Where("key IN ?", pathKeys).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
fixed := row.Value
|
||||
if !strings.HasPrefix(fixed, "/") {
|
||||
fixed = "/" + fixed
|
||||
}
|
||||
if !strings.HasSuffix(fixed, "/") {
|
||||
fixed += "/"
|
||||
}
|
||||
if fixed == row.Value {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.Setting{}).Where("id = ?", row.Id).
|
||||
Update("value", fixed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeInboundClientTgId() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
|
||||
@@ -153,3 +153,44 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
|
||||
t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
seed := []model.Setting{
|
||||
{Key: "subJsonPath", Value: "YIrCXJOOOL"},
|
||||
{Key: "subPath", Value: "/sub"},
|
||||
{Key: "subClashPath", Value: "clash/"},
|
||||
{Key: "webBasePath", Value: "/panel/"},
|
||||
}
|
||||
for i := range seed {
|
||||
if err := db.Create(&seed[i]).Error; err != nil {
|
||||
t.Fatalf("seed setting %s: %v", seed[i].Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeSettingPaths(); err != nil {
|
||||
t.Fatalf("normalizeSettingPaths: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"subJsonPath": "/YIrCXJOOOL/",
|
||||
"subPath": "/sub/",
|
||||
"subClashPath": "/clash/",
|
||||
"webBasePath": "/panel/",
|
||||
}
|
||||
for key, expected := range want {
|
||||
var row model.Setting
|
||||
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||
t.Fatalf("read %s: %v", key, err)
|
||||
}
|
||||
if row.Value != expected {
|
||||
t.Errorf("%s = %q, want %q", key, row.Value, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Locks the #5665 guard: composite-PK client_inbounds has no id column, so the
|
||||
// sequence-reset SQL must never be issued for it.
|
||||
func TestTableWithIdColumn_SkipsCompositeKeyModels(t *testing.T) {
|
||||
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
if table, ok := tableWithIdColumn(db, &model.ClientInbound{}); ok {
|
||||
t.Errorf("ClientInbound (table %q) has no id column but was not skipped", table)
|
||||
}
|
||||
table, ok := tableWithIdColumn(db, &model.Inbound{})
|
||||
if !ok {
|
||||
t.Fatal("Inbound has an id column but was reported as skippable")
|
||||
}
|
||||
if table != "inbounds" {
|
||||
t.Errorf("Inbound table = %q, want inbounds", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercises the #5665 AutoMigrate skip on SQLite (the check is dialect-agnostic):
|
||||
// settled after InitDB, not settled with a missing column or table.
|
||||
func TestPostgresModelSettled_TracksSchemaPresence(t *testing.T) {
|
||||
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
for _, mdl := range []any{&model.ClientRecord{}, &model.ClientGroup{}, &model.ClientInbound{}} {
|
||||
if !postgresModelSettled(mdl) {
|
||||
t.Errorf("%T not settled right after InitDB", mdl)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Migrator().DropColumn(&model.ClientGroup{}, "reset_up"); err != nil {
|
||||
t.Fatalf("drop column: %v", err)
|
||||
}
|
||||
if postgresModelSettled(&model.ClientGroup{}) {
|
||||
t.Error("ClientGroup settled despite missing reset_up column")
|
||||
}
|
||||
|
||||
if err := db.Migrator().DropTable(&model.ClientGroup{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if postgresModelSettled(&model.ClientGroup{}) {
|
||||
t.Error("ClientGroup settled despite missing table")
|
||||
}
|
||||
}
|
||||
@@ -270,19 +270,14 @@ func resetPostgresSequences(dst *gorm.DB) error {
|
||||
return resyncPostgresSequences(dst, migrationModels())
|
||||
}
|
||||
|
||||
// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next
|
||||
// auto-increment INSERT won't collide with an existing row. Table names are
|
||||
// resolved from the models themselves (not hardcoded), so they always match the
|
||||
// migrated tables. The statement is a no-op for tables without an id sequence
|
||||
// (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
|
||||
// run both after migration and on every Postgres startup.
|
||||
// resyncPostgresSequences sets each model's id sequence to MAX(id); idempotent. Id-less
|
||||
// composite-PK tables are skipped — Postgres rejects MAX(id) at parse time and logs it (#5665).
|
||||
func resyncPostgresSequences(db *gorm.DB, models []any) error {
|
||||
for _, m := range models {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
t, ok := tableWithIdColumn(db, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t := stmt.Table
|
||||
// t comes from the trusted model set parsed by GORM, not user input, so
|
||||
// interpolating it as an identifier is safe. We ignore errors per-table.
|
||||
_ = db.Exec(
|
||||
@@ -293,3 +288,16 @@ func resyncPostgresSequences(db *gorm.DB, models []any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableWithIdColumn resolves a model's table name and reports whether its GORM
|
||||
// schema maps an "id" database column.
|
||||
func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if stmt.Schema == nil || stmt.Schema.LookUpField("id") == nil {
|
||||
return "", false
|
||||
}
|
||||
return stmt.Table, true
|
||||
}
|
||||
|
||||
@@ -240,8 +240,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
case model.VLESS:
|
||||
proxy["type"] = "vless"
|
||||
proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
inboundSettings := subReq.linkSettings(inbound)
|
||||
streamSecurity, _ := stream["security"].(string)
|
||||
if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
|
||||
proxy["flow"] = client.Flow
|
||||
@@ -258,8 +257,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
case model.Shadowsocks:
|
||||
proxy["type"] = "ss"
|
||||
proxy["password"] = client.Password
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
inboundSettings := subReq.linkSettings(inbound)
|
||||
method, _ := inboundSettings["method"].(string)
|
||||
if method == "" {
|
||||
return nil
|
||||
@@ -288,8 +286,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
// helpers prune fields (like `allowInsecure` / the salamander obfs
|
||||
// block) that the hysteria proxy wants preserved.
|
||||
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
inboundSettings := subReq.linkSettings(inbound)
|
||||
|
||||
proxyType := "hysteria2"
|
||||
authKey := "password"
|
||||
|
||||
@@ -138,7 +138,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||
|
||||
func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
|
||||
var newJsonArray []json_util.RawMessage
|
||||
stream := s.streamData(inbound.StreamSettings)
|
||||
stream := s.streamData(inbound.StreamSettings, subKey(client))
|
||||
|
||||
// When externalProxy is empty the JSON config falls back to a
|
||||
// synthetic one whose `dest` is the host the client connects to.
|
||||
@@ -212,11 +212,11 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
case "vless":
|
||||
vc := client
|
||||
vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
|
||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, vc, jsonMux(mux, hostMux)))
|
||||
newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
|
||||
case "trojan", "shadowsocks":
|
||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux)))
|
||||
newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
|
||||
case "hysteria":
|
||||
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux)))
|
||||
newOutbounds = append(newOutbounds, s.genHy(subReq, inbound, newStream, client, jsonMux(mux, hostMux)))
|
||||
}
|
||||
|
||||
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
||||
@@ -234,15 +234,25 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
return newJsonArray
|
||||
}
|
||||
|
||||
func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||
func (s *SubJsonService) streamData(stream string, clientKey string) map[string]any {
|
||||
var streamSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(stream), &streamSettings)
|
||||
if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
|
||||
streamSettings = map[string]any{}
|
||||
}
|
||||
security, _ := streamSettings["security"].(string)
|
||||
switch security {
|
||||
case "tls":
|
||||
streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any))
|
||||
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
|
||||
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
|
||||
} else {
|
||||
delete(streamSettings, "tlsSettings")
|
||||
}
|
||||
case "reality":
|
||||
streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any))
|
||||
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
|
||||
streamSettings["realitySettings"] = s.realityData(realitySettings, clientKey)
|
||||
} else {
|
||||
delete(streamSettings, "realitySettings")
|
||||
}
|
||||
}
|
||||
delete(streamSettings, "sockopt")
|
||||
|
||||
@@ -322,7 +332,7 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
|
||||
return tlsData
|
||||
}
|
||||
|
||||
func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
||||
func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {
|
||||
rltyData := make(map[string]any, 1)
|
||||
rltyClientSettings, _ := rData["settings"].(map[string]any)
|
||||
|
||||
@@ -331,8 +341,8 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
||||
rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
|
||||
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
|
||||
|
||||
// Set random data
|
||||
rltyData["spiderX"] = "/" + random.Seq(15)
|
||||
seed, _ := rltyClientSettings["spiderX"].(string)
|
||||
rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
|
||||
shortIds, ok := rData["shortIds"].([]any)
|
||||
if ok && len(shortIds) > 0 {
|
||||
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
|
||||
@@ -383,7 +393,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
||||
func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
@@ -393,8 +403,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
// Add encryption for VLESS outbound from inbound settings
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
inboundSettings := subReq.linkSettings(inbound)
|
||||
encryption, _ := inboundSettings["encryption"].(string)
|
||||
|
||||
settings := map[string]any{
|
||||
@@ -412,7 +421,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
||||
func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
serverData := make([]ServerSetting, 1)
|
||||
@@ -424,8 +433,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
||||
}
|
||||
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
inboundSettings := subReq.linkSettings(inbound)
|
||||
method, _ := inboundSettings["method"].(string)
|
||||
serverData[0].Method = method
|
||||
|
||||
@@ -465,7 +473,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
|
||||
func (s *SubJsonService) genHy(subReq *SubService, inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
t.Fatal("direct_out outbound must never be emitted")
|
||||
}
|
||||
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
||||
}`)
|
||||
}`, "")
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
@@ -93,7 +93,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
|
||||
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
// to import the config (#5401).
|
||||
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
|
||||
|
||||
tls, _ := stream["tlsSettings"].(map[string]any)
|
||||
if tls == nil {
|
||||
@@ -122,7 +122,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client, ""))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vless outbound must not use vnext")
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
||||
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
||||
client := model.Client{Password: "p4ss"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, ""))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
|
||||
server := firstServer(settings)
|
||||
if server == nil {
|
||||
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
|
||||
@@ -164,7 +164,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
||||
}
|
||||
|
||||
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
|
||||
ssServer := firstServer(ssSettings)
|
||||
if ssServer == nil {
|
||||
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
|
||||
@@ -181,7 +181,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
|
||||
// When xmux is present in xhttpSettings, the per-inbound xmux handles
|
||||
// multiplexing and the legacy outbound.Mux must NOT be set.
|
||||
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
|
||||
parsed := svc.streamData(stream)
|
||||
parsed := svc.streamData(stream, "")
|
||||
|
||||
mux := globalMux
|
||||
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
|
||||
@@ -194,7 +194,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1"}
|
||||
|
||||
raw := svc.genVless(inbound, streamSettings, client, mux)
|
||||
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal(raw, &ob); err != nil {
|
||||
t.Fatalf("unmarshal outbound: %v", err)
|
||||
@@ -227,7 +227,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
||||
|
||||
// When no xmux is present, the global subJsonMux should be used.
|
||||
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
|
||||
parsed := svc.streamData(stream)
|
||||
parsed := svc.streamData(stream, "")
|
||||
|
||||
mux := globalMux
|
||||
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
|
||||
@@ -240,7 +240,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1"}
|
||||
|
||||
raw := svc.genVless(inbound, streamSettings, client, mux)
|
||||
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal(raw, &ob); err != nil {
|
||||
t.Fatalf("unmarshal outbound: %v", err)
|
||||
@@ -254,3 +254,80 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
||||
t.Fatalf("mux payload wrong: %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
|
||||
t.Helper()
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
|
||||
"realitySettings":{
|
||||
"serverNames":["reality.example.com"],
|
||||
"shortIds":["ab12cd"],
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
|
||||
}
|
||||
}`, clientKey)
|
||||
rlty, _ := stream["realitySettings"].(map[string]any)
|
||||
if rlty == nil {
|
||||
t.Fatal("streamData dropped realitySettings")
|
||||
}
|
||||
spx, _ := rlty["spiderX"].(string)
|
||||
if len(spx) != 16 || spx[0] != '/' {
|
||||
t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
|
||||
}
|
||||
return spx
|
||||
}
|
||||
|
||||
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
|
||||
alice := realitySpiderXFromStream(t, svc, "subAlice")
|
||||
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
|
||||
t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
|
||||
}
|
||||
if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
|
||||
t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
|
||||
}
|
||||
}
|
||||
|
||||
// streamData must tolerate malformed stored inbounds: unparseable stream JSON
|
||||
// (with a finalMask configured, which writes into the map) and tls/reality
|
||||
// security whose settings key is missing or null previously panicked the
|
||||
// subscription request.
|
||||
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
|
||||
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
stream := withMask.streamData("not-json", "clientKey")
|
||||
if _, ok := stream["finalmask"]; !ok {
|
||||
t.Fatal("finalMask must still apply when stream settings fail to parse")
|
||||
}
|
||||
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
|
||||
if v, ok := noReality["realitySettings"]; ok {
|
||||
t.Fatalf("missing realitySettings must stay absent, got %v", v)
|
||||
}
|
||||
nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
|
||||
if v, ok := nullTls["tlsSettings"]; ok {
|
||||
t.Fatalf("null tlsSettings must be dropped, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
|
||||
"realitySettings":{
|
||||
"serverNames":["reality.example.com"],
|
||||
"shortIds":["ab12cd"],
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
|
||||
}
|
||||
}`, "")
|
||||
|
||||
rlty, _ := stream["realitySettings"].(map[string]any)
|
||||
if rlty == nil {
|
||||
t.Fatal("streamData dropped realitySettings")
|
||||
}
|
||||
spx, _ := rlty["spiderX"].(string)
|
||||
if len(spx) != 16 || spx[0] != '/' {
|
||||
t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +67,11 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
|
||||
protocol model.Protocol
|
||||
}{
|
||||
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
|
||||
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
|
||||
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
|
||||
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
|
||||
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
|
||||
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
|
||||
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
|
||||
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
|
||||
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
|
||||
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -95,15 +95,15 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- json_service.go:268 — a non-empty finalMask that merges to nothing must
|
||||
// --- applyGlobalFinalMask — a non-empty finalMask that merges to nothing must
|
||||
// not add the finalmask key (the `len(merged) > 0` guard). ---
|
||||
|
||||
func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
|
||||
// finalMask is non-empty (passes the len(fm)==0 early return) but its only
|
||||
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
|
||||
// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
|
||||
// so applyGlobalFinalMask must NOT set finalmask.
|
||||
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
|
||||
}
|
||||
@@ -111,13 +111,13 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
|
||||
// Sanity: a finalMask that DOES merge to something still gets set, so the
|
||||
// guard is the only distinguishing factor.
|
||||
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
||||
if _, ok := stream2["finalmask"]; !ok {
|
||||
t.Fatal("non-empty finalMask must be set")
|
||||
}
|
||||
}
|
||||
|
||||
// --- json_service.go:494 — an empty extra tcp slice must not clobber the base ---
|
||||
// --- mergeFinalMask — an empty extra tcp slice must not clobber the base ---
|
||||
|
||||
func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
|
||||
base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}
|
||||
|
||||
@@ -459,11 +459,8 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
|
||||
// needed when a global remark template references client-only tokens. Falls back
|
||||
// to an email-only client if not found.
|
||||
func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
for _, c := range clients {
|
||||
if c.Email == email {
|
||||
return c
|
||||
}
|
||||
if c, ok := s.clientForLink(inbound, email); ok {
|
||||
return c
|
||||
}
|
||||
return model.Client{Email: email}
|
||||
}
|
||||
|
||||
+196
-67
@@ -54,6 +54,17 @@ type SubService struct {
|
||||
// doesn't own its row (multi-inbound subscriptions). Filled in
|
||||
// getInboundsBySubId; reset per request in PrepareForRequest.
|
||||
statsByEmail map[string]xray.ClientTraffic
|
||||
// clientsByInbound caches clients resolved for this request keyed by
|
||||
// inbound id then email, so the per-protocol link generators look a client
|
||||
// up without re-parsing the inbound's settings JSON per link.
|
||||
// fullyPrimedInbounds marks inbounds whose complete client list is cached
|
||||
// (a miss there is authoritative). Reset per request in PrepareForRequest.
|
||||
clientsByInbound map[int]map[string]model.Client
|
||||
fullyPrimedInbounds map[int]bool
|
||||
// settingsByInbound caches each inbound's settings decoded once per request
|
||||
// with the clients array left out; generators read only inbound-level
|
||||
// fields (encryption, method, version, …) from it.
|
||||
settingsByInbound map[int]map[string]any
|
||||
}
|
||||
|
||||
// NewSubService creates a new subscription service with the given configuration.
|
||||
@@ -86,10 +97,96 @@ func (s *SubService) PrepareForRequest(host string) {
|
||||
s.address = host
|
||||
s.usageShown = map[string]bool{}
|
||||
s.statsByEmail = map[string]xray.ClientTraffic{}
|
||||
s.clientsByInbound = map[int]map[string]model.Client{}
|
||||
s.fullyPrimedInbounds = map[int]bool{}
|
||||
s.settingsByInbound = map[int]map[string]any{}
|
||||
s.loadNodes()
|
||||
s.loadRemarkSettings()
|
||||
}
|
||||
|
||||
// primeLinkClients caches clients (first occurrence per email, matching the
|
||||
// old settings-JSON iteration order) so clientForLink resolves them without a
|
||||
// parse. complete marks the inbound's whole client list as cached.
|
||||
func (s *SubService) primeLinkClients(inboundId int, clients []model.Client, complete bool) {
|
||||
if inboundId <= 0 {
|
||||
return
|
||||
}
|
||||
if s.clientsByInbound == nil {
|
||||
s.clientsByInbound = map[int]map[string]model.Client{}
|
||||
}
|
||||
m := s.clientsByInbound[inboundId]
|
||||
if m == nil {
|
||||
m = make(map[string]model.Client, len(clients))
|
||||
s.clientsByInbound[inboundId] = m
|
||||
}
|
||||
for _, c := range clients {
|
||||
if _, exists := m[c.Email]; !exists {
|
||||
m[c.Email] = c
|
||||
}
|
||||
}
|
||||
if complete {
|
||||
if s.fullyPrimedInbounds == nil {
|
||||
s.fullyPrimedInbounds = map[int]bool{}
|
||||
}
|
||||
s.fullyPrimedInbounds[inboundId] = true
|
||||
}
|
||||
}
|
||||
|
||||
// clientForLink resolves one client of an inbound by email for link
|
||||
// generation: from the per-request cache when primed, otherwise by parsing
|
||||
// the settings JSON once and caching every client from it.
|
||||
func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.Client, bool) {
|
||||
if m, ok := s.clientsByInbound[inbound.Id]; ok {
|
||||
if c, hit := m[email]; hit {
|
||||
return c, true
|
||||
}
|
||||
if s.fullyPrimedInbounds[inbound.Id] {
|
||||
return model.Client{}, false
|
||||
}
|
||||
}
|
||||
clients, err := s.inboundService.GetClients(inbound)
|
||||
if err != nil {
|
||||
return model.Client{}, false
|
||||
}
|
||||
s.primeLinkClients(inbound.Id, clients, true)
|
||||
for i := range clients {
|
||||
if clients[i].Email == email {
|
||||
return clients[i], true
|
||||
}
|
||||
}
|
||||
return model.Client{}, false
|
||||
}
|
||||
|
||||
// linkSettings returns the inbound's settings decoded once per request with
|
||||
// the clients array left out — the link generators read only inbound-level
|
||||
// fields from it and resolve clients via clientForLink. The shallow
|
||||
// RawMessage pass skips materializing a huge clients array entirely.
|
||||
func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
|
||||
if inbound.Id > 0 {
|
||||
if cached, ok := s.settingsByInbound[inbound.Id]; ok {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
shallow := map[string]json.RawMessage{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &shallow)
|
||||
out := make(map[string]any, len(shallow))
|
||||
for key, raw := range shallow {
|
||||
if key == "clients" {
|
||||
continue
|
||||
}
|
||||
var value any
|
||||
_ = json.Unmarshal(raw, &value)
|
||||
out[key] = value
|
||||
}
|
||||
if inbound.Id > 0 {
|
||||
if s.settingsByInbound == nil {
|
||||
s.settingsByInbound = map[int]map[string]any{}
|
||||
}
|
||||
s.settingsByInbound[inbound.Id] = out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadRemarkSettings populates the per-request remark formatting state so
|
||||
// every subscription format — raw, JSON, Clash — renders remarks the same way
|
||||
// (the date formatter reads datepicker). Loading it only in getSubs left
|
||||
@@ -144,25 +241,23 @@ func listenIsInternalOnly(listen string) bool {
|
||||
}
|
||||
|
||||
// matchingClients returns the inbound's clients whose SubID equals subId,
|
||||
// deduplicated by email. settings.clients can accumulate duplicate entries
|
||||
// for the same client (multi-node sync/import drift, old DBs): SyncInbound
|
||||
// dedupes the normalized client_inbounds rows on write but never rewrites
|
||||
// the legacy JSON, and the subscription builders iterate that JSON — so
|
||||
// without this guard every duplicate became a duplicate profile in the
|
||||
// output (#5134). Link generation keys purely on (inbound, email), so
|
||||
// same-email entries are pure duplicates and dropping them is lossless.
|
||||
// resolved from the normalized clients/client_inbounds tables (both filter
|
||||
// columns indexed) instead of parsing the settings JSON — at large client
|
||||
// counts that parse made every subscription fetch cost seconds. The
|
||||
// case-insensitive email dedupe stays as cheap insurance even though
|
||||
// clients.email is unique, preserving the #5134 guarantee that duplicate
|
||||
// settings entries never fan out into duplicate profiles. Resolved clients
|
||||
// are primed into the per-request cache so the link generators don't parse
|
||||
// settings either.
|
||||
func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
|
||||
clients, err := s.inboundService.GetClients(inbound)
|
||||
clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
|
||||
if err != nil {
|
||||
logger.Error("SubService - GetClients: Unable to get clients from inbound")
|
||||
logger.Error("SubService - GetClientsBySubId: Unable to get clients from inbound")
|
||||
return nil
|
||||
}
|
||||
var out []model.Client
|
||||
seen := make(map[string]struct{}, len(clients))
|
||||
for _, client := range clients {
|
||||
if client.SubID != subId {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(client.Email)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
@@ -170,6 +265,7 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, client)
|
||||
}
|
||||
s.primeLinkClients(inbound.Id, out, false)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -253,6 +349,7 @@ func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
s.primeLinkClients(inbound.Id, clients, true)
|
||||
s.projectThroughFallbackMaster(inbound)
|
||||
hostEps := s.hostEndpoints(inbound, "raw")
|
||||
var out []string
|
||||
@@ -362,7 +459,7 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
|
||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||
db := database.GetDB()
|
||||
var inbounds []*model.Inbound
|
||||
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
|
||||
err := db.Model(model.Inbound{}).Where(`id in (
|
||||
SELECT DISTINCT inbounds.id
|
||||
FROM inbounds
|
||||
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
||||
@@ -374,19 +471,34 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexStatsByEmail(inbounds)
|
||||
s.indexStatsBySubId(subId)
|
||||
return inbounds, nil
|
||||
}
|
||||
|
||||
// indexStatsByEmail records every loaded inbound's client traffic rows keyed by
|
||||
// email so statsForClient can resolve a client's usage even on an inbound that
|
||||
// doesn't own its (globally unique) client_traffics row. See statsByEmail.
|
||||
func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) {
|
||||
// indexStatsBySubId loads the traffic rows for just this subscriber's clients
|
||||
// into statsByEmail so statsForClient can resolve a client's usage on any of
|
||||
// its inbounds. It replaces preloading every matched inbound's ClientStats,
|
||||
// which read the entire client_traffics table on every subscription fetch of
|
||||
// a large inbound; statsForClient's per-email DB fallback covers any miss.
|
||||
func (s *SubService) indexStatsBySubId(subId string) {
|
||||
if s.statsByEmail == nil {
|
||||
s.statsByEmail = map[string]xray.ClientTraffic{}
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
for _, st := range inbound.ClientStats {
|
||||
db := database.GetDB()
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).Where("sub_id = ?", subId).Pluck("email", &emails).Error; err != nil {
|
||||
logger.Error("SubService - indexStatsBySubId: load emails:", err)
|
||||
return
|
||||
}
|
||||
const chunk = 400
|
||||
for lo := 0; lo < len(emails); lo += chunk {
|
||||
hi := min(lo+chunk, len(emails))
|
||||
var rows []xray.ClientTraffic
|
||||
if err := db.Where("email IN ?", emails[lo:hi]).Find(&rows).Error; err != nil {
|
||||
logger.Error("SubService - indexStatsBySubId: load traffics:", err)
|
||||
return
|
||||
}
|
||||
for _, st := range rows {
|
||||
s.statsByEmail[st.Email] = st
|
||||
}
|
||||
}
|
||||
@@ -516,21 +628,14 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
|
||||
if inbound.Protocol != model.WireGuard {
|
||||
return ""
|
||||
}
|
||||
settings := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
settings := s.linkSettings(inbound)
|
||||
secretKey, _ := settings["secretKey"].(string)
|
||||
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
var client *model.Client
|
||||
for i := range clients {
|
||||
if clients[i].Email == email {
|
||||
client = &clients[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if client == nil || client.PrivateKey == "" {
|
||||
resolved, ok := s.clientForLink(inbound, email)
|
||||
if !ok || resolved.PrivateKey == "" {
|
||||
return ""
|
||||
}
|
||||
client := &resolved
|
||||
|
||||
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
|
||||
params := make(map[string]string)
|
||||
@@ -607,10 +712,12 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
applyVmessTLSParams(stream, obj)
|
||||
}
|
||||
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
clientIndex := findClientIndex(clients, email)
|
||||
obj["id"] = clients[clientIndex].ID
|
||||
obj["scy"] = clients[clientIndex].Security
|
||||
client, ok := s.clientForLink(inbound, email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
obj["id"] = client.ID
|
||||
obj["scy"] = client.Security
|
||||
|
||||
externalProxies, _ := stream["externalProxy"].([]any)
|
||||
|
||||
@@ -659,17 +766,18 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
}
|
||||
address := s.resolveInboundAddress(inbound)
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
clientIndex := findClientIndex(clients, email)
|
||||
uuid := clients[clientIndex].ID
|
||||
client, ok := s.clientForLink(inbound, email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
uuid := client.ID
|
||||
port := inbound.Port
|
||||
streamNetwork := stream["network"].(string)
|
||||
params := make(map[string]string)
|
||||
params["type"] = streamNetwork
|
||||
|
||||
// Add encryption parameter for VLESS from inbound settings
|
||||
var settings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
settings := s.linkSettings(inbound)
|
||||
if encryption, ok := settings["encryption"].(string); ok {
|
||||
params["encryption"] = encryption
|
||||
}
|
||||
@@ -683,12 +791,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
case "tls":
|
||||
applyShareTLSParams(stream, params)
|
||||
case "reality":
|
||||
applyShareRealityParams(stream, params)
|
||||
applyShareRealityParams(stream, params, subKey(client))
|
||||
default:
|
||||
params["security"] = "none"
|
||||
}
|
||||
if len(clients[clientIndex].Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
|
||||
params["flow"] = clients[clientIndex].Flow
|
||||
if len(client.Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
|
||||
params["flow"] = client.Flow
|
||||
}
|
||||
|
||||
externalProxies, _ := stream["externalProxy"].([]any)
|
||||
@@ -717,9 +825,11 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
}
|
||||
address := s.resolveInboundAddress(inbound)
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
clientIndex := findClientIndex(clients, email)
|
||||
password := encodeUserinfo(clients[clientIndex].Password)
|
||||
client, ok := s.clientForLink(inbound, email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
password := encodeUserinfo(client.Password)
|
||||
port := inbound.Port
|
||||
streamNetwork := stream["network"].(string)
|
||||
params := make(map[string]string)
|
||||
@@ -734,9 +844,9 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
case "tls":
|
||||
applyShareTLSParams(stream, params)
|
||||
case "reality":
|
||||
applyShareRealityParams(stream, params)
|
||||
if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
|
||||
params["flow"] = clients[clientIndex].Flow
|
||||
applyShareRealityParams(stream, params, subKey(client))
|
||||
if streamNetwork == "tcp" && len(client.Flow) > 0 {
|
||||
params["flow"] = client.Flow
|
||||
}
|
||||
default:
|
||||
params["security"] = "none"
|
||||
@@ -788,13 +898,14 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
}
|
||||
address := s.resolveInboundAddress(inbound)
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
client, ok := s.clientForLink(inbound, email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
settings := s.linkSettings(inbound)
|
||||
inboundPassword := settings["password"].(string)
|
||||
method := settings["method"].(string)
|
||||
clientIndex := findClientIndex(clients, email)
|
||||
streamNetwork := stream["network"].(string)
|
||||
params := make(map[string]string)
|
||||
params["type"] = streamNetwork
|
||||
@@ -828,9 +939,9 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
userInfo = fmt.Sprintf("%s:%s:%s",
|
||||
url.QueryEscape(method),
|
||||
url.QueryEscape(inboundPassword),
|
||||
url.QueryEscape(clients[clientIndex].Password))
|
||||
url.QueryEscape(client.Password))
|
||||
} else {
|
||||
userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, clients[clientIndex].Password))
|
||||
userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, client.Password))
|
||||
}
|
||||
|
||||
externalProxies, _ := stream["externalProxy"].([]any)
|
||||
@@ -861,15 +972,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
}
|
||||
var stream map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
clientIndex := -1
|
||||
for i, client := range clients {
|
||||
if client.Email == email {
|
||||
clientIndex = i
|
||||
break
|
||||
}
|
||||
client, ok := s.clientForLink(inbound, email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
auth := encodeUserinfo(clients[clientIndex].Auth)
|
||||
auth := encodeUserinfo(client.Auth)
|
||||
params := make(map[string]string)
|
||||
|
||||
params["security"] = "tls"
|
||||
@@ -928,8 +1035,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
}
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
settings := s.linkSettings(inbound)
|
||||
version, _ := settings["version"].(float64)
|
||||
protocol := "hysteria2"
|
||||
if int(version) == 1 {
|
||||
@@ -1330,7 +1436,7 @@ func hysteriaPinHex(pin string) string {
|
||||
return pin
|
||||
}
|
||||
|
||||
func applyShareRealityParams(stream map[string]any, params map[string]string) {
|
||||
func applyShareRealityParams(stream map[string]any, params map[string]string, clientKey string) {
|
||||
params["security"] = "reality"
|
||||
realitySetting, _ := stream["realitySettings"].(map[string]any)
|
||||
realitySettings, _ := searchKey(realitySetting, "settings")
|
||||
@@ -1356,10 +1462,33 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
|
||||
params["pqv"] = pqv
|
||||
}
|
||||
}
|
||||
params["spx"] = "/" + random.Seq(15)
|
||||
seed := ""
|
||||
if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
|
||||
seed, _ = spxValue.(string)
|
||||
}
|
||||
params["spx"] = deriveSpiderX(seed, clientKey)
|
||||
}
|
||||
}
|
||||
|
||||
// subKey returns a stable per-client identity for deterministic derivations,
|
||||
// preferring the subscription id and falling back to the (unique) email.
|
||||
func subKey(c model.Client) string {
|
||||
if c.SubID != "" {
|
||||
return c.SubID
|
||||
}
|
||||
return c.Email
|
||||
}
|
||||
|
||||
// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a
|
||||
// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it.
|
||||
func deriveSpiderX(seed, clientKey string) string {
|
||||
if seed == "" && clientKey == "" {
|
||||
return "/" + random.Seq(15)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(seed + "|" + clientKey))
|
||||
return "/" + hex.EncodeToString(sum[:])[:15]
|
||||
}
|
||||
|
||||
func buildVmessLink(obj map[string]any) string {
|
||||
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
||||
|
||||
@@ -69,19 +69,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count:
|
||||
// the two entries differ only by email case, so dropping strings.ToLower (or keying on
|
||||
// another field) yields two clients. The byte-identical dupes above can't catch that.
|
||||
// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count.
|
||||
// clients.email is unique but case-sensitively so: two rows differing only by email case
|
||||
// can coexist (import drift), and dropping strings.ToLower (or keying on another field)
|
||||
// would emit both. The first row by id must win, matching the old settings-JSON order.
|
||||
func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
const subId = "s1"
|
||||
const uuid = "11111111-2222-4333-8444-555555555555"
|
||||
ib := &model.Inbound{
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[
|
||||
{"id":"` + uuid + `","email":"Dup@Example.com","subId":"` + subId + `","enable":true},
|
||||
{"id":"` + uuid + `","email":"dup@example.com","subId":"` + subId + `","enable":true}
|
||||
]}`,
|
||||
db := database.GetDB()
|
||||
ib := &model.Inbound{Protocol: model.VLESS, Enable: true, Port: 42002, Tag: "dedup-ci", Settings: `{"clients":[]}`}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
for _, email := range []string{"Dup@Example.com", "dup@example.com"} {
|
||||
c := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
t.Fatalf("seed client %q: %v", email, err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: c.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound %q: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
s := &SubService{}
|
||||
got := s.matchingClients(ib, subId)
|
||||
if len(got) != 1 {
|
||||
@@ -90,7 +106,7 @@ func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
|
||||
if got[0].Email != "Dup@Example.com" {
|
||||
t.Fatalf("first occurrence must be kept, got %q", got[0].Email)
|
||||
}
|
||||
// A wrong subId must still be excluded (guards the subId filter at service.go:127).
|
||||
// A wrong subId must still be excluded (guards the SQL subId filter).
|
||||
if other := s.matchingClients(ib, "nope"); len(other) != 0 {
|
||||
t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -52,10 +53,8 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenVlessLink_RealityParamsMapped locks the reality field mapping
|
||||
// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element
|
||||
// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
|
||||
// Distinct pbk/sid values catch a pbk<->sid swap mutant.
|
||||
// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
|
||||
// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
|
||||
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"reality",
|
||||
@@ -63,7 +62,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
"realitySettings":{
|
||||
"serverNames":["reality.example.com"],
|
||||
"shortIds":["ab12cd"],
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
|
||||
}
|
||||
}`
|
||||
s := &SubService{}
|
||||
@@ -75,7 +74,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
"pbk=PBKvalue",
|
||||
"sid=ab12cd",
|
||||
"fp=firefox",
|
||||
"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash
|
||||
"spx=%2F",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(link, w) {
|
||||
@@ -87,3 +86,93 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
|
||||
}
|
||||
}
|
||||
|
||||
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
|
||||
// with distinct subIds so the per-client spx derivation can be exercised.
|
||||
func realityTwoClientInbound() *model.Inbound {
|
||||
return &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Remark: "sharelink",
|
||||
Settings: `{"clients":[
|
||||
{"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
|
||||
{"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
|
||||
],"decryption":"none","encryption":"none"}`,
|
||||
StreamSettings: `{
|
||||
"network":"tcp","security":"reality",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"realitySettings":{
|
||||
"serverNames":["reality.example.com"],
|
||||
"shortIds":["ab12cd"],
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
}
|
||||
|
||||
func spxParam(t *testing.T, link string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
spx := u.Query().Get("spx")
|
||||
if spx == "" || spx[0] != '/' {
|
||||
t.Fatalf("spx missing or not /-prefixed in %q", link)
|
||||
}
|
||||
return spx
|
||||
}
|
||||
|
||||
// spx must be stable for a given client across repeated exports (the #5718
|
||||
// complaint) yet differ between clients so the value can't be fingerprinted.
|
||||
func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
|
||||
s := &SubService{}
|
||||
inbound := realityTwoClientInbound()
|
||||
|
||||
aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
|
||||
aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
|
||||
bob := spxParam(t, s.genVlessLink(inbound, "bob"))
|
||||
|
||||
if aliceFirst != aliceSecond {
|
||||
t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
|
||||
}
|
||||
if aliceFirst == bob {
|
||||
t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSpiderX(t *testing.T) {
|
||||
if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
|
||||
t.Fatalf("deriveSpiderX not deterministic: %q", got)
|
||||
}
|
||||
if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
|
||||
t.Fatal("deriveSpiderX must differ per client")
|
||||
}
|
||||
if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
|
||||
t.Fatal("rotating the seed must rotate a client's spx")
|
||||
}
|
||||
got := deriveSpiderX("seed", "clientA")
|
||||
if len(got) != 16 || got[0] != '/' {
|
||||
t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
|
||||
}
|
||||
if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
|
||||
t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
|
||||
// panel builds these links in TS, so both derivations must agree byte-for-byte.
|
||||
func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
|
||||
vectors := map[string]struct{ seed, clientKey, want string }{
|
||||
"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
|
||||
"seed only": {"/", "", "/d08ed99bd9afc60"},
|
||||
}
|
||||
for name, v := range vectors {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
|
||||
t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/op/go-logging"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const scaleTargetSubId = "scale-target-sub"
|
||||
|
||||
// setupScaleSubDB mirrors the service package's scale gating: Postgres via
|
||||
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
|
||||
func setupScaleSubDB(t *testing.T) {
|
||||
t.Helper()
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
|
||||
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB(postgres): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||
case "1", "true", "yes":
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
|
||||
t.Fatalf("InitDB(sqlite): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return
|
||||
}
|
||||
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
||||
}
|
||||
|
||||
func scaleSubSizes(t *testing.T, def ...int) []int {
|
||||
t.Helper()
|
||||
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
var out []int
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n <= 0 {
|
||||
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return def
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resetScaleSubTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if config.GetDBKind() == "postgres" {
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
} else {
|
||||
for _, tbl := range []string{"inbounds", "clients", "client_inbounds", "client_traffics"} {
|
||||
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
|
||||
t.Fatalf("delete %s: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
db.Exec("DELETE FROM sqlite_sequence")
|
||||
}
|
||||
if err := db.Where("1 = 1").Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
t.Fatalf("clear client_external_links: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedScaleSubDataset seeds one VLESS inbound holding n clients (the sub
|
||||
// server's worst case: matchingClients parses the whole settings blob and
|
||||
// getInboundsBySubId preloads every ClientStats row). Three clients share
|
||||
// scaleTargetSubId; everyone else gets a unique subId.
|
||||
func seedScaleSubDataset(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
resetScaleSubTables(t, db)
|
||||
|
||||
clients := make([]model.Client, n)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
targets := map[int]bool{n / 4: true, n / 2: true, 3 * n / 4: true}
|
||||
for i := range n {
|
||||
subId := fmt.Sprintf("sub-%07d", i)
|
||||
if targets[i] {
|
||||
subId = scaleTargetSubId
|
||||
}
|
||||
clients[i] = model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: fmt.Sprintf("user-%07d@subscale", i),
|
||||
SubID: subId,
|
||||
Enable: true,
|
||||
ExpiryTime: exp,
|
||||
TotalGB: 100 << 30,
|
||||
}
|
||||
}
|
||||
|
||||
settingsMap := map[string]any{"clients": clients, "decryption": "none"}
|
||||
settings, err := json.Marshal(settingsMap)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin seed tx: %v", tx.Error)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: fmt.Sprintf("subscale-%d", n),
|
||||
Remark: "subscale",
|
||||
Enable: true,
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: string(settings),
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := tx.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
records := make([]*model.ClientRecord, n)
|
||||
for i := range clients {
|
||||
records[i] = clients[i].ToRecord()
|
||||
}
|
||||
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
||||
t.Fatalf("seed clients: %v", err)
|
||||
}
|
||||
links := make([]model.ClientInbound, n)
|
||||
for i := range records {
|
||||
links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id}
|
||||
}
|
||||
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_inbounds: %v", err)
|
||||
}
|
||||
traffics := make([]xray.ClientTraffic, n)
|
||||
for i := range clients {
|
||||
traffics[i] = xray.ClientTraffic{
|
||||
InboundId: ib.Id,
|
||||
Email: clients[i].Email,
|
||||
Enable: true,
|
||||
Total: clients[i].TotalGB,
|
||||
ExpiryTime: clients[i].ExpiryTime,
|
||||
}
|
||||
}
|
||||
if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
t.Fatalf("commit seed tx: %v", err)
|
||||
}
|
||||
committed = true
|
||||
db.Exec("ANALYZE")
|
||||
}
|
||||
|
||||
// TestGetSubsScale measures one subscription fetch (raw and JSON format) for a
|
||||
// 3-client subId living inside an n-client inbound, plus a subId miss — the
|
||||
// per-request cost every subscriber pays.
|
||||
func TestGetSubsScale(t *testing.T) {
|
||||
for _, n := range scaleSubSizes(t, 10000, 100000) {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
setupScaleSubDB(t)
|
||||
seedScaleSubDataset(t, n)
|
||||
|
||||
svc := &SubService{}
|
||||
const reps = 5
|
||||
start := time.Now()
|
||||
var links []string
|
||||
for range reps {
|
||||
var err error
|
||||
links, _, _, _, err = svc.GetSubs(scaleTargetSubId, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
}
|
||||
rawDur := time.Since(start) / reps
|
||||
if len(links) != 3 {
|
||||
t.Fatalf("GetSubs links = %d, want 3", len(links))
|
||||
}
|
||||
|
||||
jsonSvc := NewSubJsonService("", "", "", &SubService{})
|
||||
start = time.Now()
|
||||
for range reps {
|
||||
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if body == "" {
|
||||
t.Fatal("GetJson returned empty body")
|
||||
}
|
||||
}
|
||||
jsonDur := time.Since(start) / reps
|
||||
|
||||
start = time.Now()
|
||||
for range reps {
|
||||
missLinks, _, _, _, err := svc.GetSubs("no-such-sub", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs miss: %v", err)
|
||||
}
|
||||
if len(missLinks) != 0 {
|
||||
t.Fatalf("GetSubs miss links = %d, want 0", len(missLinks))
|
||||
}
|
||||
}
|
||||
missDur := time.Since(start) / reps
|
||||
|
||||
t.Logf("N=%-7d raw=%-10v json=%-10v miss=%v",
|
||||
n, rawDur.Round(time.Millisecond), jsonDur.Round(time.Millisecond), missDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package common
|
||||
|
||||
import "strings"
|
||||
|
||||
// EnsureURLScheme prepends https:// to a URL that carries no scheme, so
|
||||
// subscription apps and browsers don't resolve it relative to the panel's own
|
||||
// domain (e.g. "t.me/support" turning into "https://panel.example/t.me/support").
|
||||
// Values with an explicit scheme (https://, tg://, mailto:, tel:) and empty
|
||||
// strings pass through untouched.
|
||||
func EnsureURLScheme(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(trimmed, "://") ||
|
||||
strings.HasPrefix(trimmed, "mailto:") ||
|
||||
strings.HasPrefix(trimmed, "tel:") {
|
||||
return trimmed
|
||||
}
|
||||
return "https://" + trimmed
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEnsureURLScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"whitespace only", " ", ""},
|
||||
{"bare telegram handle", "t.me/xui_support", "https://t.me/xui_support"},
|
||||
{"bare domain with path", "example.com/help", "https://example.com/help"},
|
||||
{"already https", "https://t.me/xui_support", "https://t.me/xui_support"},
|
||||
{"already http", "http://example.com", "http://example.com"},
|
||||
{"telegram deep link", "tg://resolve?domain=xui_support", "tg://resolve?domain=xui_support"},
|
||||
{"mailto", "mailto:support@example.com", "mailto:support@example.com"},
|
||||
{"tel", "tel:+1234567890", "tel:+1234567890"},
|
||||
{"trims whitespace", " t.me/xui_support ", "https://t.me/xui_support"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := EnsureURLScheme(tt.in); got != tt.want {
|
||||
t.Errorf("EnsureURLScheme(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newPanelUpdateTestEngine registers only updatePanel/getUpdateStatus directly
|
||||
// on the controller's zero value, bypassing NewServerController's cron/metrics
|
||||
// setup (unrelated to these two handlers, and unnecessary weight for a unit
|
||||
// test). Callers must set up a DB first (newHostTestDB(t)) since StartUpdate
|
||||
// reads the dev-channel setting before doing anything else.
|
||||
func newPanelUpdateTestEngine() *gin.Engine {
|
||||
a := &ServerController{}
|
||||
engine := gin.New()
|
||||
engine.GET("/panel/api/server/getUpdateStatus", a.getUpdateStatus)
|
||||
engine.POST("/panel/api/server/updatePanel", a.updatePanel)
|
||||
return engine
|
||||
}
|
||||
|
||||
func doPanelUpdateReq(t *testing.T, engine *gin.Engine, method, path string) hostEnvelope {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
|
||||
}
|
||||
var env hostEnvelope
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// TestGetUpdateStatus_NoStatusFileYet exercises the read-only status endpoint
|
||||
// with no prior update having run: it must report "pending" (not an error),
|
||||
// since a missing status file is an expected, ordinary state, not a failure.
|
||||
func TestGetUpdateStatus_NoStatusFileYet(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := newPanelUpdateTestEngine()
|
||||
|
||||
env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
|
||||
if !env.Success {
|
||||
t.Fatalf("getUpdateStatus should always report success=true (it's a best-effort read): msg=%s", env.Msg)
|
||||
}
|
||||
var status struct {
|
||||
RunID string `json:"runId"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Obj, &status); err != nil {
|
||||
t.Fatalf("decode status: %v", err)
|
||||
}
|
||||
if status.State != "pending" {
|
||||
t.Fatalf("State = %q, want %q", status.State, "pending")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUpdateStatus_RunIdIsAlwaysAString is the regression test for the
|
||||
// precision bug found in review: RunID is a 19-digit UnixNano timestamp, so
|
||||
// it must round-trip over the wire as a JSON string, never a bare number -- a
|
||||
// bare number would silently lose precision in JavaScript past
|
||||
// Number.MAX_SAFE_INTEGER, breaking every future runId comparison on the
|
||||
// frontend. Decoding into a Go string field below only succeeds if the wire
|
||||
// value is actually a JSON string; a bare number there would fail to decode,
|
||||
// so this test doubles as the wire-format check.
|
||||
func TestGetUpdateStatus_RunIdIsAlwaysAString(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := newPanelUpdateTestEngine()
|
||||
|
||||
statusPath := config.GetUpdateStatusFilePath()
|
||||
body := `{"runId":"1735689600123456789","state":"success","exitCode":0,"finishedAt":1735689612}`
|
||||
if err := os.WriteFile(statusPath, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
|
||||
var status struct {
|
||||
RunID string `json:"runId"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Obj, &status); err != nil {
|
||||
t.Fatalf("decode status (would fail here if runId were a bare JSON number instead of a string): %v, body=%s", err, env.Obj)
|
||||
}
|
||||
if status.RunID != "1735689600123456789" {
|
||||
t.Fatalf("RunID = %q, want %q", status.RunID, "1735689600123456789")
|
||||
}
|
||||
if status.State != "success" {
|
||||
t.Fatalf("State = %q, want %q", status.State, "success")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePanel_UnsupportedPlatformReturnsNoRunId covers the one path of
|
||||
// updatePanel that's safe to exercise in an automated test on any OS/CI
|
||||
// runner: the runtime.GOOS != "linux" guard. Actually invoking StartUpdate's
|
||||
// launch logic on Linux would make a real network call and could launch a
|
||||
// real update.sh process, so that path is deliberately not covered here --
|
||||
// see the PR description for why.
|
||||
func TestUpdatePanel_UnsupportedPlatformReturnsNoRunId(t *testing.T) {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Skip("this test only exercises the non-Linux guard path; on Linux, updatePanel would attempt a real download/exec")
|
||||
}
|
||||
newHostTestDB(t)
|
||||
engine := newPanelUpdateTestEngine()
|
||||
|
||||
env := doPanelUpdateReq(t, engine, http.MethodPost, "/panel/api/server/updatePanel")
|
||||
if env.Success {
|
||||
t.Fatal("updatePanel on an unsupported platform: success = true, want false")
|
||||
}
|
||||
if len(env.Obj) != 0 && string(env.Obj) != "null" {
|
||||
t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch covers the one branch of
|
||||
// updatePanel that's both untested and safe to exercise on any OS/CI runner:
|
||||
// an unparseable "dev" form value is rejected by strconv.ParseBool before
|
||||
// StartUpdateChannel (and therefore any real exec/network call) is ever
|
||||
// reached, on Linux or otherwise.
|
||||
func TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := newPanelUpdateTestEngine()
|
||||
|
||||
form := url.Values{"dev": {"notabool"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var env hostEnvelope
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if env.Success {
|
||||
t.Fatal("updatePanel with dev=notabool: success = true, want false")
|
||||
}
|
||||
if len(env.Obj) != 0 && string(env.Obj) != "null" {
|
||||
t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
|
||||
g.GET("/getXrayVersion", a.getXrayVersion)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
g.GET("/getDb", a.getDb)
|
||||
g.GET("/getMigration", a.getMigration)
|
||||
@@ -209,21 +210,34 @@ func (a *ServerController) installXray(c *gin.Context) {
|
||||
|
||||
// updatePanel starts a panel self-update. With no "dev" form value it follows
|
||||
// this panel's own channel setting; an explicit "dev" (sent by the master node
|
||||
// updater) overrides it for this run.
|
||||
// updater) overrides it for this run. The response's runId identifies this
|
||||
// update for a later getUpdateStatus poll.
|
||||
func (a *ServerController) updatePanel(c *gin.Context) {
|
||||
devParam := c.PostForm("dev")
|
||||
var runID int64
|
||||
var err error
|
||||
if devParam == "" {
|
||||
err = a.panelService.StartUpdate()
|
||||
runID, err = a.panelService.StartUpdate()
|
||||
} else {
|
||||
dev, perr := strconv.ParseBool(devParam)
|
||||
if perr != nil {
|
||||
jsonMsg(c, "invalid data", perr)
|
||||
return
|
||||
}
|
||||
err = a.panelService.StartUpdateChannel(dev)
|
||||
runID, err = a.panelService.StartUpdateChannel(dev)
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
|
||||
var obj any
|
||||
if err == nil {
|
||||
obj = gin.H{"runId": strconv.FormatInt(runID, 10)}
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), obj, err)
|
||||
}
|
||||
|
||||
// getUpdateStatus reports the outcome of the most recently launched panel
|
||||
// self-update (see updatePanel). Compare the returned runId against the one
|
||||
// updatePanel returned to tell this run's result apart from a stale one.
|
||||
func (a *ServerController) getUpdateStatus(c *gin.Context) {
|
||||
jsonObj(c, a.panelService.GetUpdateStatus(), nil)
|
||||
}
|
||||
|
||||
// setUpdateChannel toggles whether self-update tracks the rolling dev release.
|
||||
|
||||
@@ -26,9 +26,16 @@ type updateUserForm struct {
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
}
|
||||
|
||||
// updateSettingForm carries the persisted settings plus request-scoped fields
|
||||
// that must never land in the settings table: the 2FA confirmation code and
|
||||
// the explicit clear flags for redacted secrets (a blank secret alone means
|
||||
// "unchanged", so clearing needs its own signal — see #5724).
|
||||
type updateSettingForm struct {
|
||||
entity.AllSetting
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
|
||||
ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"`
|
||||
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
|
||||
}
|
||||
|
||||
// SettingController handles settings and user management operations.
|
||||
@@ -105,7 +112,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
err := a.settingService.UpdateAllSetting(allSetting)
|
||||
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
|
||||
TgBotToken: form.ClearTgBotToken,
|
||||
LdapPassword: form.ClearLdapPassword,
|
||||
SmtpPassword: form.ClearSmtpPassword,
|
||||
})
|
||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||
err = bumpErr
|
||||
|
||||
@@ -113,33 +113,129 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
|
||||
return observed, true
|
||||
}
|
||||
|
||||
// hasLimitIp reports whether any client carries an IP limit. It probes the
|
||||
// normalized clients table (limit_ip is synced there by SyncInbound and the
|
||||
// legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that
|
||||
// loaded and JSON-parsed every inbound's settings blob on each 10s run.
|
||||
func (j *CheckClientIpJob) hasLimitIp() bool {
|
||||
db := database.GetDB()
|
||||
var inbounds []*model.Inbound
|
||||
var probe int64
|
||||
err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error
|
||||
return err == nil && probe > 0
|
||||
}
|
||||
|
||||
err := db.Model(model.Inbound{}).Where("settings LIKE ?", "%limitIp%").Find(&inbounds).Error
|
||||
if err != nil {
|
||||
return false
|
||||
const ipScanChunk = 400
|
||||
|
||||
func chunkEmails(s []string, size int) [][]string {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(s)+size-1)/size)
|
||||
for size < len(s) {
|
||||
s, chunks = s[size:], append(chunks, s[:size])
|
||||
}
|
||||
return append(chunks, s)
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Settings == "" {
|
||||
// loadClientLimits maps each observed email to its clients.limit_ip in a few
|
||||
// chunked queries, replacing the per-email settings-JSON parse that previously
|
||||
// resolved the limit.
|
||||
func (j *CheckClientIpJob) loadClientLimits(emails []string) map[string]int {
|
||||
db := database.GetDB()
|
||||
out := make(map[string]int, len(emails))
|
||||
for _, batch := range chunkEmails(emails, ipScanChunk) {
|
||||
var rows []struct {
|
||||
Email string
|
||||
LimitIp int
|
||||
}
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Select("email, limit_ip").
|
||||
Where("email IN ?", batch).
|
||||
Scan(&rows).Error; err != nil {
|
||||
j.checkError(err)
|
||||
continue
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[r.Email] = r.LimitIp
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
settings := map[string][]model.Client{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
clients := settings["clients"]
|
||||
|
||||
for _, client := range clients {
|
||||
limitIp := client.LimitIP
|
||||
if limitIp > 0 {
|
||||
return true
|
||||
// loadInboundsByEmails resolves each email's owning inbound through the
|
||||
// clients/client_inbounds relation in chunked queries. Like the old per-email
|
||||
// First() it keeps the lowest inbound id when a client spans several inbounds.
|
||||
func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound {
|
||||
db := database.GetDB()
|
||||
minInboundByEmail := make(map[string]int, len(emails))
|
||||
for _, batch := range chunkEmails(emails, ipScanChunk) {
|
||||
var pairs []struct {
|
||||
Email string
|
||||
InboundId int
|
||||
}
|
||||
if err := db.Table("client_inbounds").
|
||||
Select("clients.email AS email, client_inbounds.inbound_id AS inbound_id").
|
||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||
Where("clients.email IN ?", batch).
|
||||
Scan(&pairs).Error; err != nil {
|
||||
j.checkError(err)
|
||||
return nil
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if cur, ok := minInboundByEmail[p.Email]; !ok || p.InboundId < cur {
|
||||
minInboundByEmail[p.Email] = p.InboundId
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(minInboundByEmail) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return false
|
||||
idSet := make(map[int]struct{}, len(minInboundByEmail))
|
||||
ids := make([]int, 0, len(minInboundByEmail))
|
||||
for _, id := range minInboundByEmail {
|
||||
if _, seen := idSet[id]; !seen {
|
||||
idSet[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
inboundsById := make(map[int]*model.Inbound, len(ids))
|
||||
for lo := 0; lo < len(ids); lo += ipScanChunk {
|
||||
hi := min(lo+ipScanChunk, len(ids))
|
||||
var page []*model.Inbound
|
||||
if err := db.Model(&model.Inbound{}).Where("id IN ?", ids[lo:hi]).Find(&page).Error; err != nil {
|
||||
j.checkError(err)
|
||||
return nil
|
||||
}
|
||||
for _, ib := range page {
|
||||
inboundsById[ib.Id] = ib
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[string]*model.Inbound, len(minInboundByEmail))
|
||||
for email, id := range minInboundByEmail {
|
||||
if ib, ok := inboundsById[id]; ok {
|
||||
out[email] = ib
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) loadClientIpRows(emails []string) map[string]*model.InboundClientIps {
|
||||
db := database.GetDB()
|
||||
out := make(map[string]*model.InboundClientIps, len(emails))
|
||||
for _, batch := range chunkEmails(emails, ipScanChunk) {
|
||||
var rows []model.InboundClientIps
|
||||
if err := db.Where("client_email IN ?", batch).Find(&rows).Error; err != nil {
|
||||
j.checkError(err)
|
||||
continue
|
||||
}
|
||||
for i := range rows {
|
||||
out[rows[i].ClientEmail] = &rows[i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// processObserved runs collection + enforcement for one scan's observations
|
||||
@@ -147,27 +243,68 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
|
||||
// observations as live connections, which bypass the stale cutoff: a connection
|
||||
// that opened hours ago is still live even though its timestamp is old. The
|
||||
// online-stats API always reports live connections, so the job passes true.
|
||||
// Lookups are batched up front and all inbound_client_ips writes share one
|
||||
// transaction, so a scan costs a handful of queries and one fsync instead of
|
||||
// several per observed email.
|
||||
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
||||
shouldCleanLog := false
|
||||
now := time.Now().Unix()
|
||||
|
||||
emails := make([]string, 0, len(observed))
|
||||
for email := range observed {
|
||||
emails = append(emails, email)
|
||||
}
|
||||
sort.Strings(emails)
|
||||
|
||||
limitByEmail := j.loadClientLimits(emails)
|
||||
inboundByEmail := j.loadInboundsByEmails(emails)
|
||||
ipRowByEmail := j.loadClientIpRows(emails)
|
||||
|
||||
// attribution accumulates this scan's local observations per email so they can
|
||||
// be recorded under this panel's own guid for cross-node IP attribution.
|
||||
attribution := make(map[string][]model.ClientIpEntry, len(observed))
|
||||
for email, ipTimestamps := range observed {
|
||||
|
||||
type pendingDisconnect struct {
|
||||
inbound *model.Inbound
|
||||
email string
|
||||
}
|
||||
var disconnects []pendingDisconnect
|
||||
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
j.checkError(tx.Error)
|
||||
return false
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, email := range emails {
|
||||
ipTimestamps := observed[email]
|
||||
|
||||
// The observations can still reference a client that was just renamed
|
||||
// or deleted; its email no longer matches any inbound. Skip it (and
|
||||
// drop any orphaned tracking row) instead of recreating a row and
|
||||
// logging an ERROR every run (#4963).
|
||||
inbound, err := j.getInboundByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
|
||||
j.delInboundClientIps(email)
|
||||
} else {
|
||||
j.checkError(err)
|
||||
// logging an ERROR every run (#4963). The batch map resolves through
|
||||
// the clients relation; the per-email fallback keeps its settings LIKE
|
||||
// net for clients not yet present there.
|
||||
inbound, ok := inboundByEmail[email]
|
||||
if !ok {
|
||||
var err error
|
||||
inbound, err = j.getInboundByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
|
||||
j.delInboundClientIps(tx, email)
|
||||
} else {
|
||||
j.checkError(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to IPWithTimestamp slice
|
||||
@@ -188,13 +325,44 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
||||
attribution[email] = attrEntries
|
||||
}
|
||||
|
||||
clientIpsRecord, err := j.getInboundClientIps(email)
|
||||
if err != nil {
|
||||
_ = j.addInboundClientIps(email, ipsWithTime)
|
||||
clientIpsRecord, ok := ipRowByEmail[email]
|
||||
if !ok {
|
||||
jsonIps, err := json.Marshal(ipsWithTime)
|
||||
if err != nil {
|
||||
j.checkError(err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
|
||||
j.checkError(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
|
||||
cleaned, banned := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
|
||||
shouldCleanLog = cleaned || shouldCleanLog
|
||||
if banned {
|
||||
disconnects = append(disconnects, pendingDisconnect{inbound: inbound, email: email})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
j.checkError(err)
|
||||
return shouldCleanLog
|
||||
}
|
||||
committed = true
|
||||
|
||||
// Xray disconnects run after the commit so their network round-trips never
|
||||
// extend the scan's write transaction (node syncs upsert the same table).
|
||||
clientsCache := make(map[int][]model.Client)
|
||||
for _, d := range disconnects {
|
||||
clients, cached := clientsCache[d.inbound.Id]
|
||||
if !cached {
|
||||
settings := map[string][]model.Client{}
|
||||
_ = json.Unmarshal([]byte(d.inbound.Settings), &settings)
|
||||
clients = settings["clients"]
|
||||
clientsCache[d.inbound.Id] = clients
|
||||
}
|
||||
j.disconnectClientTemporarily(d.inbound, d.email, clients)
|
||||
}
|
||||
|
||||
j.recordLocalAttribution(attribution)
|
||||
@@ -293,81 +461,34 @@ func (j *CheckClientIpJob) checkError(e error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.InboundClientIps, error) {
|
||||
db := database.GetDB()
|
||||
InboundClientIps := &model.InboundClientIps{}
|
||||
err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return InboundClientIps, nil
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
|
||||
inboundClientIps := &model.InboundClientIps{}
|
||||
jsonIps, err := json.Marshal(ipsWithTime)
|
||||
j.checkError(err)
|
||||
|
||||
inboundClientIps.ClientEmail = clientEmail
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
|
||||
defer func() {
|
||||
if err == nil {
|
||||
tx.Commit()
|
||||
} else {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.Save(inboundClientIps).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// delInboundClientIps drops the inbound_client_ips tracking row for an email
|
||||
// that no longer maps to any inbound (a renamed or deleted client), so stale
|
||||
// access-log entries don't keep a ghost row alive (#4963).
|
||||
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
|
||||
db := database.GetDB()
|
||||
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
|
||||
if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
j.checkError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool {
|
||||
// updateInboundClientIps merges one email's observed IPs into its tracking row
|
||||
// and applies the IP limit. limitIp comes from the caller (the clients table);
|
||||
// writes go through the caller's transaction. banned=true asks the caller to
|
||||
// disconnect the client after the transaction commits.
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (shouldCleanLog, banned bool) {
|
||||
if inbound.Settings == "" {
|
||||
logger.Debug("wrong data:", inbound)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
settings := map[string][]model.Client{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
clients := settings["clients"]
|
||||
|
||||
// Find the client's IP limit
|
||||
var limitIp int
|
||||
var clientFound bool
|
||||
for _, client := range clients {
|
||||
if client.Email == clientEmail {
|
||||
limitIp = client.LimitIP
|
||||
clientFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !enforce || !clientFound || limitIp <= 0 || !inbound.Enable {
|
||||
// Nothing to enforce (collection-only run, no limit, client missing, or
|
||||
// inbound disabled): record the observed IPs for the panel and return.
|
||||
if !enforce || limitIp <= 0 || !inbound.Enable {
|
||||
// Nothing to enforce (collection-only run, no limit on the clients row,
|
||||
// or inbound disabled): record the observed IPs for the panel and return.
|
||||
jsonIps, _ := json.Marshal(newIpsWithTime)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
db := database.GetDB()
|
||||
db.Save(inboundClientIps)
|
||||
return false
|
||||
if err := tx.Save(inboundClientIps).Error; err != nil {
|
||||
logger.Error("failed to save inboundClientIps:", err)
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Parse old IPs from database
|
||||
@@ -386,18 +507,18 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
}
|
||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||
|
||||
shouldCleanLog := false
|
||||
j.disAllowedIps = []string{}
|
||||
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
|
||||
if len(bannedLive) > 0 {
|
||||
shouldCleanLog = true
|
||||
banned = true
|
||||
|
||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
defer logIpFile.Close()
|
||||
ipLogger := log.New(logIpFile, "", log.LstdFlags)
|
||||
@@ -410,9 +531,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||
}
|
||||
|
||||
// force xray to drop existing connections from banned ips
|
||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||
}
|
||||
|
||||
// keep kept-live + historical in the blob so the panel keeps showing
|
||||
@@ -424,18 +542,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
jsonIps, _ := json.Marshal(dbIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
|
||||
db := database.GetDB()
|
||||
err := db.Save(inboundClientIps).Error
|
||||
if err != nil {
|
||||
if err := tx.Save(inboundClientIps).Error; err != nil {
|
||||
logger.Error("failed to save inboundClientIps:", err)
|
||||
return false
|
||||
return false, banned
|
||||
}
|
||||
|
||||
if len(j.disAllowedIps) > 0 {
|
||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
|
||||
}
|
||||
|
||||
return shouldCleanLog
|
||||
return shouldCleanLog, banned
|
||||
}
|
||||
|
||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
|
||||
|
||||
@@ -95,7 +95,7 @@ func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *mo
|
||||
func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
||||
t.Helper()
|
||||
inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
|
||||
client := &model.ClientRecord{Email: email}
|
||||
client := &model.ClientRecord{Email: email, LimitIP: limitIp}
|
||||
if err := database.GetDB().Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client record: %v", err)
|
||||
}
|
||||
@@ -206,11 +206,14 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundByEmail: %v", err)
|
||||
}
|
||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
|
||||
shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 3, live, true, false)
|
||||
|
||||
if shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||
}
|
||||
if banned {
|
||||
t.Fatalf("banned must be false with 1 live ip under limit 3")
|
||||
}
|
||||
if len(j.disAllowedIps) != 0 {
|
||||
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
|
||||
}
|
||||
@@ -259,11 +262,14 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundByEmail: %v", err)
|
||||
}
|
||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
|
||||
shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
|
||||
|
||||
if !shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||
}
|
||||
if !banned {
|
||||
t.Fatalf("banned must be true when the live set exceeds the limit")
|
||||
}
|
||||
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
|
||||
t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
||||
}
|
||||
@@ -388,3 +394,28 @@ func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
|
||||
t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// hasLimitIp gates every 10s scan on the normalized clients table: a bare
|
||||
// "limitIp":0 in settings JSON (which the old LIKE scan matched and parsed)
|
||||
// must not enable enforcement, while a single clients.limit_ip > 0 row must.
|
||||
func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
j := &CheckClientIpJob{}
|
||||
|
||||
if j.hasLimitIp() {
|
||||
t.Fatal("hasLimitIp = true on an empty database")
|
||||
}
|
||||
|
||||
seedLinkedInboundWithClient(t, "no-limit", "nolimit@example.com", 0)
|
||||
if j.hasLimitIp() {
|
||||
t.Fatal("hasLimitIp = true with only limit_ip=0 clients")
|
||||
}
|
||||
|
||||
limited := &model.ClientRecord{Email: "limited@example.com", LimitIP: 2}
|
||||
if err := database.GetDB().Create(limited).Error; err != nil {
|
||||
t.Fatalf("seed limited client: %v", err)
|
||||
}
|
||||
if !j.hasLimitIp() {
|
||||
t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
|
||||
// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
|
||||
func setupScaleJobDB(t *testing.T) {
|
||||
t.Helper()
|
||||
loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||
|
||||
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB(postgres): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||
case "1", "true", "yes":
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
|
||||
t.Fatalf("InitDB(sqlite): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
return
|
||||
}
|
||||
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
||||
}
|
||||
|
||||
func scaleJobSizes(t *testing.T, def ...int) []int {
|
||||
t.Helper()
|
||||
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
var out []int
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n <= 0 {
|
||||
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return def
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resetScaleJobTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if config.GetDBKind() == "postgres" {
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
} else {
|
||||
for _, tbl := range []string{"inbounds", "clients", "client_inbounds"} {
|
||||
if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
|
||||
t.Fatalf("delete %s: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
db.Exec("DELETE FROM sqlite_sequence")
|
||||
}
|
||||
if err := db.Where("1 = 1").Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
t.Fatalf("clear inbound client ips: %v", err)
|
||||
}
|
||||
if err := db.Where("1 = 1").Delete(&model.NodeClientIp{}).Error; err != nil {
|
||||
t.Fatalf("clear node client ips: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedScaleIPDataset seeds n clients across numInbounds inbounds. Every client
|
||||
// in the LAST inbound carries limitIp=3 (and 0 elsewhere), so hasLimitIp pays
|
||||
// its full scan cost before finding a hit, and the returned emails all resolve
|
||||
// to that last inbound for the processObserved measurement.
|
||||
func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
resetScaleJobTables(t, db)
|
||||
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin seed tx: %v", tx.Error)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var limitedEmails []string
|
||||
per := n / numInbounds
|
||||
for i := range numInbounds {
|
||||
lo, hi := i*per, (i+1)*per
|
||||
if i == numInbounds-1 {
|
||||
hi = n
|
||||
}
|
||||
limitIp := 0
|
||||
if i == numInbounds-1 {
|
||||
limitIp = 3
|
||||
}
|
||||
clients := make([]model.Client, 0, hi-lo)
|
||||
records := make([]*model.ClientRecord, 0, hi-lo)
|
||||
for j := lo; j < hi; j++ {
|
||||
email := fmt.Sprintf("user-%07d@ipscale", j)
|
||||
clients = append(clients, model.Client{Email: email, LimitIP: limitIp, Enable: true})
|
||||
records = append(records, &model.ClientRecord{Email: email, LimitIP: limitIp, Enable: true})
|
||||
if limitIp > 0 {
|
||||
limitedEmails = append(limitedEmails, email)
|
||||
}
|
||||
}
|
||||
settings, err := json.Marshal(map[string][]model.Client{"clients": clients})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: fmt.Sprintf("ipscale-%d-%d", n, i),
|
||||
Enable: true,
|
||||
Port: 42000 + i,
|
||||
Protocol: model.VLESS,
|
||||
Settings: string(settings),
|
||||
}
|
||||
if err := tx.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound %d: %v", i, err)
|
||||
}
|
||||
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
||||
t.Fatalf("seed clients %d: %v", i, err)
|
||||
}
|
||||
links := make([]model.ClientInbound, len(records))
|
||||
for j := range records {
|
||||
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
|
||||
}
|
||||
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_inbounds %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
t.Fatalf("commit seed tx: %v", err)
|
||||
}
|
||||
committed = true
|
||||
db.Exec("ANALYZE")
|
||||
return limitedEmails
|
||||
}
|
||||
|
||||
// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
|
||||
// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
|
||||
// inbound) and processObserved with M online users (per-email inbound lookup,
|
||||
// settings parse and autocommit save). Run twice: first scan half add / half
|
||||
// update, second scan all update path.
|
||||
func TestCheckClientIpScale(t *testing.T) {
|
||||
shapes := []struct {
|
||||
name string
|
||||
inbounds int
|
||||
observed int
|
||||
}{{"single", 1, 50}, {"spread50", 50, 1000}}
|
||||
|
||||
for _, n := range scaleJobSizes(t, 10000, 100000) {
|
||||
for _, shape := range shapes {
|
||||
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||
setupScaleJobDB(t)
|
||||
limited := seedScaleIPDataset(t, n, shape.inbounds)
|
||||
m := min(shape.observed, len(limited))
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
const reps = 3
|
||||
start := time.Now()
|
||||
for range reps {
|
||||
if !j.hasLimitIp() {
|
||||
t.Fatal("hasLimitIp = false, want true")
|
||||
}
|
||||
}
|
||||
t.Logf("N=%-7d shape=%-8s hasLimitIp=%v/call", n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
||||
|
||||
now := time.Now().Unix()
|
||||
observed := make(map[string]map[string]int64, m)
|
||||
for i := range m {
|
||||
observed[limited[i]] = map[string]int64{
|
||||
fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now,
|
||||
}
|
||||
}
|
||||
for i := range m / 2 {
|
||||
seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
j.processObserved(observed, true, true)
|
||||
firstScan := time.Since(start)
|
||||
start = time.Now()
|
||||
j.processObserved(observed, true, true)
|
||||
secondScan := time.Since(start)
|
||||
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
|
||||
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
|
||||
float64(secondScan.Milliseconds())/float64(m))
|
||||
|
||||
var rows int64
|
||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&rows).Error; err != nil {
|
||||
t.Fatalf("count ip rows: %v", err)
|
||||
}
|
||||
if rows != int64(m) {
|
||||
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,8 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var activeMu sync.Mutex
|
||||
var activeEmails []string
|
||||
for _, n := range nodes {
|
||||
if !n.Enable || n.Status != "online" {
|
||||
continue
|
||||
@@ -120,7 +122,11 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
common.GoRecover("node-traffic-sync:"+n.Name, func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
j.syncOne(mgr, n, doIpSync)
|
||||
if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 {
|
||||
activeMu.Lock()
|
||||
activeEmails = append(activeEmails, emails...)
|
||||
activeMu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -143,14 +149,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
|
||||
j.maybePushGlobals(mgr, nodes)
|
||||
|
||||
lastOnline, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: get last-online failed:", err)
|
||||
}
|
||||
if lastOnline == nil {
|
||||
lastOnline = map[string]int64{}
|
||||
}
|
||||
|
||||
// Prune stale local-online entries (no local active emails or inbound tags
|
||||
// to add here — only the local xray poll feeds those) so a stopped local
|
||||
// xray's clients and inbounds still age out between traffic polls.
|
||||
@@ -164,6 +162,45 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
// Same snapshot-vs-delta split as the local traffic job: above the
|
||||
// threshold a full snapshot would be dropped by the hub's payload cap, so
|
||||
// send only the rows for clients online on the synced nodes this tick.
|
||||
snapshot := true
|
||||
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
|
||||
logger.Warning("node traffic sync: count client traffics failed:", countErr)
|
||||
} else if total > clientStatsSnapshotMaxClients {
|
||||
snapshot = false
|
||||
}
|
||||
|
||||
var stats []*xray.ClientTraffic
|
||||
var statsErr error
|
||||
if snapshot {
|
||||
stats, statsErr = j.inboundService.GetAllClientTraffics()
|
||||
} else {
|
||||
stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
|
||||
}
|
||||
if statsErr != nil {
|
||||
logger.Warning("node traffic sync: get client traffics for websocket failed:", statsErr)
|
||||
}
|
||||
|
||||
var lastOnline map[string]int64
|
||||
if snapshot {
|
||||
var loErr error
|
||||
if lastOnline, loErr = j.inboundService.GetClientsLastOnline(); loErr != nil {
|
||||
logger.Warning("node traffic sync: get last-online failed:", loErr)
|
||||
}
|
||||
} else {
|
||||
lastOnline = make(map[string]int64, len(stats))
|
||||
for _, ct := range stats {
|
||||
if ct != nil {
|
||||
lastOnline[ct.Email] = ct.LastOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastOnline == nil {
|
||||
lastOnline = map[string]int64{}
|
||||
}
|
||||
|
||||
online := j.inboundService.GetOnlineClients()
|
||||
if online == nil {
|
||||
online = []string{}
|
||||
@@ -181,10 +218,8 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
trafficPayload["nodeTraffics"] = inboundSpeed
|
||||
websocket.BroadcastTraffic(trafficPayload)
|
||||
|
||||
clientStats := map[string]any{}
|
||||
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStats := map[string]any{"snapshot": snapshot}
|
||||
if len(stats) > 0 {
|
||||
clientStats["clients"] = stats
|
||||
}
|
||||
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||
@@ -192,7 +227,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
} else if len(summary) > 0 {
|
||||
clientStats["inbounds"] = summary
|
||||
}
|
||||
if len(clientStats) > 0 {
|
||||
if len(clientStats) > 1 {
|
||||
websocket.BroadcastClientStats(clientStats)
|
||||
}
|
||||
|
||||
@@ -318,11 +353,14 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
|
||||
// syncOne pulls one node's traffic snapshot and merges it. It returns the
|
||||
// emails online on that node this tick, feeding the delta broadcast above the
|
||||
// snapshot threshold; nil on any failure path.
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
|
||||
rt, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if n.ConfigDirty {
|
||||
@@ -331,7 +369,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
reconcileCancel()
|
||||
if reconcileErr != nil {
|
||||
logger.Warningf("node traffic sync: reconcile for %s failed: %v", n.Name, reconcileErr)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
|
||||
logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
|
||||
@@ -346,21 +384,27 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
|
||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
service.FilterNodeSnapshot(n, snap)
|
||||
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if changed {
|
||||
j.structural.set()
|
||||
}
|
||||
|
||||
active := make([]string, 0, len(snap.OnlineEmails))
|
||||
active = append(active, snap.OnlineEmails...)
|
||||
for _, emails := range snap.OnlineTree {
|
||||
active = append(active, emails...)
|
||||
}
|
||||
|
||||
if !doIpSync {
|
||||
return
|
||||
return active
|
||||
}
|
||||
|
||||
ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
|
||||
@@ -378,7 +422,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
masterIps, err := j.inboundService.GetAllInboundClientIps()
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
|
||||
return
|
||||
return active
|
||||
}
|
||||
if len(masterIps) > 0 {
|
||||
if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
|
||||
@@ -406,4 +450,5 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
}
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ type XrayTrafficJob struct {
|
||||
outboundService outbound.OutboundService
|
||||
}
|
||||
|
||||
// clientStatsSnapshotMaxClients caps how many client_traffics rows the job
|
||||
// ships as a full websocket snapshot per poll (same spirit as the
|
||||
// controller's broadcastInboundsUpdateClientLimit). Above it, a snapshot
|
||||
// would blow past the hub's payload cap and be dropped wholesale, so the job
|
||||
// broadcasts only this poll's active rows and the UI leans on its 5s REST
|
||||
// refetch for the rest.
|
||||
const clientStatsSnapshotMaxClients = 5000
|
||||
|
||||
// NewXrayTrafficJob creates a new traffic collection job instance.
|
||||
func NewXrayTrafficJob() *XrayTrafficJob {
|
||||
return new(XrayTrafficJob)
|
||||
@@ -50,8 +58,8 @@ func (j *XrayTrafficJob) Run() {
|
||||
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
|
||||
}
|
||||
if restartOnDisable {
|
||||
if err := j.xrayService.RestartXray(true); err != nil {
|
||||
logger.Warning("restart xray after disabling clients failed:", err)
|
||||
if err := j.xrayService.RestartXray(false); err != nil {
|
||||
logger.Warning("reconcile xray after disabling clients failed:", err)
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
@@ -116,9 +124,41 @@ func (j *XrayTrafficJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
// Small installs broadcast the full snapshot (see GetAllClientTraffics for
|
||||
// why deltas alone left UI rows stale). Above the threshold the snapshot
|
||||
// would be dropped by the hub's payload cap anyway, so ship this poll's
|
||||
// active rows instead and scope last-online to them; the initial full map
|
||||
// still arrives over REST.
|
||||
snapshot := true
|
||||
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
|
||||
logger.Warning("count client traffics for websocket failed:", countErr)
|
||||
} else if total > clientStatsSnapshotMaxClients {
|
||||
snapshot = false
|
||||
}
|
||||
|
||||
var stats []*xray.ClientTraffic
|
||||
var statsErr error
|
||||
if snapshot {
|
||||
stats, statsErr = j.inboundService.GetAllClientTraffics()
|
||||
} else {
|
||||
stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
|
||||
}
|
||||
if statsErr != nil {
|
||||
logger.Warning("get client traffics for websocket failed:", statsErr)
|
||||
}
|
||||
|
||||
var lastOnlineMap map[string]int64
|
||||
if snapshot {
|
||||
if lastOnlineMap, err = j.inboundService.GetClientsLastOnline(); err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
}
|
||||
} else {
|
||||
lastOnlineMap = make(map[string]int64, len(stats))
|
||||
for _, ct := range stats {
|
||||
if ct != nil {
|
||||
lastOnlineMap[ct.Email] = ct.LastOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastOnlineMap == nil {
|
||||
lastOnlineMap = make(map[string]int64)
|
||||
@@ -136,10 +176,8 @@ func (j *XrayTrafficJob) Run() {
|
||||
"lastOnlineMap": lastOnlineMap,
|
||||
})
|
||||
|
||||
clientStatsPayload := map[string]any{}
|
||||
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||
logger.Warning("get all client traffics for websocket failed:", err)
|
||||
} else if len(stats) > 0 {
|
||||
clientStatsPayload := map[string]any{"snapshot": snapshot}
|
||||
if len(stats) > 0 {
|
||||
clientStatsPayload["clients"] = stats
|
||||
}
|
||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||
@@ -147,7 +185,7 @@ func (j *XrayTrafficJob) Run() {
|
||||
} else if len(inboundSummary) > 0 {
|
||||
clientStatsPayload["inbounds"] = inboundSummary
|
||||
}
|
||||
if len(clientStatsPayload) > 0 {
|
||||
if len(clientStatsPayload) > 1 {
|
||||
websocket.BroadcastClientStats(clientStatsPayload)
|
||||
}
|
||||
|
||||
|
||||
@@ -834,6 +834,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
// Serialize the row cleanup against the traffic poll to avoid the
|
||||
// cross-transaction lock-order deadlock on client_traffics/inbounds.
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(successIds, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -466,24 +466,31 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if existing.Email != "" {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := clearGlobalTraffic(db, existing.Email); err != nil {
|
||||
return needRestart, err
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGlobalTraffic(tx, existing.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&model.ClientRecord{}, id).Error
|
||||
}); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
|
||||
@@ -125,3 +125,105 @@ func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
|
||||
t.Fatal("ResetGroupTraffic(blank) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupTotalsSurviveSingleClientReset(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "erin", "gold", 100, 200)
|
||||
seedGroupedClient(t, "frank", "gold", 40, 60)
|
||||
|
||||
if err := isvc.ResetClientTrafficByEmail("erin"); err != nil {
|
||||
t.Fatalf("ResetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
|
||||
g := groupByName(t, csvc, "gold")
|
||||
if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 {
|
||||
t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g)
|
||||
}
|
||||
|
||||
var erin xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
|
||||
t.Fatalf("load erin traffic: %v", err)
|
||||
}
|
||||
if erin.Up != 0 || erin.Down != 0 {
|
||||
t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupTotalsSurviveBulkClientReset(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "gina", "silver", 10, 20)
|
||||
seedGroupedClient(t, "hank", "silver", 30, 40)
|
||||
|
||||
affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkResetTraffic: %v", err)
|
||||
}
|
||||
if affected != 2 {
|
||||
t.Fatalf("BulkResetTraffic affected = %d, want 2", affected)
|
||||
}
|
||||
|
||||
g := groupByName(t, csvc, "silver")
|
||||
if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 {
|
||||
t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupTotalsSurviveClientDelete(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "iris", "bronze", 70, 30)
|
||||
seedGroupedClient(t, "jack", "bronze", 5, 5)
|
||||
|
||||
var rec model.ClientRecord
|
||||
if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil {
|
||||
t.Fatalf("load iris record: %v", err)
|
||||
}
|
||||
if _, err := csvc.Delete(isvc, rec.Id, false); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
g := groupByName(t, csvc, "bronze")
|
||||
if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 {
|
||||
t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g)
|
||||
}
|
||||
if g.ClientCount != 1 {
|
||||
t.Fatalf("client count = %d, want 1", g.ClientCount)
|
||||
}
|
||||
|
||||
var trafficRows int64
|
||||
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil {
|
||||
t.Fatalf("count iris traffic rows: %v", err)
|
||||
}
|
||||
if trafficRows != 0 {
|
||||
t.Fatalf("iris traffic row survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
csvc := &ClientService{}
|
||||
isvc := &InboundService{}
|
||||
|
||||
seedGroupedClient(t, "kate", "iron", 100, 100)
|
||||
if err := isvc.ResetClientTrafficByEmail("kate"); err != nil {
|
||||
t.Fatalf("ResetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 {
|
||||
t.Fatalf("pre group-reset: got %+v, want used=200", g)
|
||||
}
|
||||
|
||||
if err := csvc.ResetGroupTraffic("iron"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 {
|
||||
t.Fatalf("group reset did not zero adjusted baselines: got %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GroupSummary struct {
|
||||
@@ -69,6 +71,57 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
|
||||
// current counters so ListGroups totals survive a traffic reset or client delete (#5675).
|
||||
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
||||
if len(emails) == 0 {
|
||||
return nil
|
||||
}
|
||||
type groupDelta struct {
|
||||
Name string
|
||||
Up int64
|
||||
Down int64
|
||||
}
|
||||
totals := make(map[string]*groupDelta)
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var part []groupDelta
|
||||
if err := tx.Table("clients AS c").
|
||||
Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
|
||||
Joins("JOIN client_traffics ct ON ct.email = c.email").
|
||||
Where("c.group_name <> '' AND c.email IN ?", batch).
|
||||
Group("c.group_name").
|
||||
Scan(&part).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range part {
|
||||
if agg, ok := totals[part[i].Name]; ok {
|
||||
agg.Up += part[i].Up
|
||||
agg.Down += part[i].Down
|
||||
} else {
|
||||
totals[part[i].Name] = &part[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, d := range totals {
|
||||
if d.Up == 0 && d.Down == 0 {
|
||||
continue
|
||||
}
|
||||
res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
|
||||
"reset_up": gorm.Expr("reset_up - ?", d.Up),
|
||||
"reset_down": gorm.Expr("reset_down - ?", d.Down),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
|
||||
@@ -528,6 +528,26 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
}
|
||||
if len(clients[0].AllowedIPs) == 0 {
|
||||
clients[0].AllowedIPs = old.AllowedIPs
|
||||
} else {
|
||||
normalized, nErr := normalizeWireguardAllowedIPs(clients[0].AllowedIPs)
|
||||
if nErr != nil {
|
||||
return false, nErr
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
clients[0].AllowedIPs = old.AllowedIPs
|
||||
} else {
|
||||
peers := make([]string, 0, len(oldClients))
|
||||
for i := range oldClients {
|
||||
if i == clientIndex {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, oldClients[i].AllowedIPs...)
|
||||
}
|
||||
if hit := wireguardAllowedIPsCollision(normalized, peers); hit != "" {
|
||||
return false, common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
|
||||
}
|
||||
clients[0].AllowedIPs = normalized
|
||||
}
|
||||
}
|
||||
if clients[0].PreSharedKey == "" {
|
||||
clients[0].PreSharedKey = old.PreSharedKey
|
||||
|
||||
@@ -197,3 +197,34 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListForInboundBySubId is ListForInbound narrowed to one subscription id —
|
||||
// both filter columns are indexed, so the subscription server resolves a
|
||||
// subscriber's clients without touching the inbound's settings JSON.
|
||||
func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId string) ([]model.Client, error) {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
type joinedRow struct {
|
||||
model.ClientRecord
|
||||
FlowOverride string
|
||||
}
|
||||
var rows []joinedRow
|
||||
err := tx.Table("clients").
|
||||
Select("clients.*, client_inbounds.flow_override AS flow_override").
|
||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||
Where("client_inbounds.inbound_id = ? AND clients.sub_id = ?", inboundId, subId).
|
||||
Order("clients.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]model.Client, 0, len(rows))
|
||||
for i := range rows {
|
||||
c := rows[i].ToClient()
|
||||
c.Flow = rows[i].FlowOverride
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -139,3 +140,39 @@ func isClientEmailTombstoned(email string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stripTombstonedClients drops just-deleted client entries from a node
|
||||
// snapshot's settings JSON so adopting a stale snapshot can't re-add them to
|
||||
// the central inbound while the delete tombstone is live. Returns the filtered
|
||||
// JSON and whether anything was removed.
|
||||
func stripTombstonedClients(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, _ := parsed["clients"].([]any)
|
||||
if len(clients) == 0 {
|
||||
return settings, false
|
||||
}
|
||||
kept := make([]any, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
if cm, ok := c.(map[string]any); ok {
|
||||
if email, _ := cm["email"].(string); email != "" && isClientEmailTombstoned(email) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, c)
|
||||
}
|
||||
if len(kept) == len(clients) {
|
||||
return settings, false
|
||||
}
|
||||
parsed["clients"] = kept
|
||||
b, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
@@ -187,6 +187,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
tombstoneClientEmails(emails)
|
||||
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -92,6 +92,9 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
||||
err := submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
|
||||
res := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ?", batch).
|
||||
@@ -150,6 +153,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ?", resetEmails).
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
||||
|
||||
@@ -73,6 +73,47 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
|
||||
return "", common.NewError("wireguard: no free address available in", base)
|
||||
}
|
||||
|
||||
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
|
||||
// canonicalizes them: bare addresses become single-host prefixes, duplicates drop.
|
||||
func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(v)
|
||||
if err != nil {
|
||||
a, aErr := netip.ParseAddr(v)
|
||||
if aErr != nil {
|
||||
return nil, common.NewError("wireguard: invalid allowedIPs entry:", v)
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
norm := p.String()
|
||||
if _, dup := seen[norm]; dup {
|
||||
continue
|
||||
}
|
||||
seen[norm] = struct{}{}
|
||||
out = append(out, norm)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func wireguardAllowedIPsCollision(entries, used []string) string {
|
||||
taken := make(map[string]struct{}, len(used))
|
||||
for _, u := range used {
|
||||
taken[strings.TrimSpace(u)] = struct{}{}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if _, ok := taken[e]; ok {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// defaultWireguardClients fills in blank WireGuard credentials for newly added
|
||||
// clients: a generated keypair when none was provided, a derived public key when
|
||||
// only a private key was given, and a unique tunnel address allocated from the
|
||||
@@ -107,6 +148,18 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
|
||||
return err
|
||||
}
|
||||
c.AllowedIPs = []string{addr}
|
||||
} else {
|
||||
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return common.NewError("wireguard: allowedIPs has no usable entry")
|
||||
}
|
||||
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
|
||||
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
|
||||
}
|
||||
c.AllowedIPs = normalized
|
||||
}
|
||||
used = append(used, c.AllowedIPs...)
|
||||
|
||||
|
||||
@@ -140,3 +140,67 @@ func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
|
||||
t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWireguardAllowedIPs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
err bool
|
||||
}{
|
||||
{name: "cidr passes through", in: []string{"10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
|
||||
{name: "bare ipv4 becomes /32", in: []string{"10.0.0.5"}, want: []string{"10.0.0.5/32"}},
|
||||
{name: "bare ipv6 becomes /128", in: []string{"fd00::5"}, want: []string{"fd00::5/128"}},
|
||||
{name: "trims and drops empties", in: []string{" 10.0.0.5/32 ", "", " "}, want: []string{"10.0.0.5/32"}},
|
||||
{name: "dedupes", in: []string{"10.0.0.5/32", "10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
|
||||
{name: "routed subnet allowed", in: []string{"10.0.0.5/32", "192.168.1.0/24"}, want: []string{"10.0.0.5/32", "192.168.1.0/24"}},
|
||||
{name: "garbage rejected", in: []string{"not-an-ip"}, err: true},
|
||||
{name: "bad prefix rejected", in: []string{"10.0.0.5/99"}, err: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeWireguardAllowedIPs(tt.in)
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.T) {
|
||||
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
|
||||
|
||||
clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
|
||||
ifaces := []any{map[string]any{"email": "c@wg"}}
|
||||
if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
|
||||
t.Fatalf("defaultWireguardClients: %v", err)
|
||||
}
|
||||
if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
|
||||
t.Fatalf("supplied allowedIPs not normalized: %v", clients[0].AllowedIPs)
|
||||
}
|
||||
|
||||
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
|
||||
err := defaultWireguardClients(existing, dup, []any{map[string]any{"email": "d@wg"}})
|
||||
if err == nil {
|
||||
t.Fatal("duplicate allowedIPs across clients must be rejected")
|
||||
}
|
||||
|
||||
bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
|
||||
if err := defaultWireguardClients(existing, bad, []any{map[string]any{"email": "e@wg"}}); err == nil {
|
||||
t.Fatal("invalid allowedIPs entry must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
t.Fatalf("empty globals must use the local-only predicate")
|
||||
}
|
||||
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
|
||||
if _, count, _, err := svc.disableInvalidClients(db); err != nil {
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("local over-quota client must be disabled, disabled %d", count)
|
||||
@@ -100,7 +100,7 @@ func TestGlobalUsage_DisablesClient(t *testing.T) {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
|
||||
if _, count, _, err := svc.disableInvalidClients(db); err != nil {
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("expected 1 client disabled, got %d", count)
|
||||
|
||||
@@ -409,6 +409,13 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// GetClientsBySubId returns the inbound's clients with the given subscription
|
||||
// id, resolved from the normalized clients tables (the same source the running
|
||||
// Xray users are built from) instead of parsing the settings JSON blob.
|
||||
func (s *InboundService) GetClientsBySubId(inboundId int, subId string) ([]model.Client, error) {
|
||||
return s.clientService.ListForInboundBySubId(nil, inboundId, subId)
|
||||
}
|
||||
|
||||
func (s *InboundService) GetAllEmails() ([]string, error) {
|
||||
db := database.GetDB()
|
||||
var emails []string
|
||||
|
||||
@@ -79,6 +79,14 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
|
||||
now := time.Now().Unix()
|
||||
cutoff := now - clientIpStaleAfterSeconds
|
||||
|
||||
// Node syncs run concurrently (one goroutine per node) and shared clients
|
||||
// appear in several nodes' reports. Locking rows in each node's arbitrary
|
||||
// report order lets two merges grab the same rows in opposite order, which
|
||||
// Postgres aborts as a deadlock (40P01) — take them in one global order.
|
||||
sort.Slice(incomingIps, func(i, j int) bool {
|
||||
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
|
||||
})
|
||||
|
||||
tx := db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
||||
@@ -157,3 +157,77 @@ func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(t *testing.T)
|
||||
t.Errorf("inbound settings expiry not converted: %#v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients locks in that the
|
||||
// delayed-start persistence pass touches only clients adjustTraffics actually
|
||||
// converted this poll: the delayed client's negative expiry becomes an absolute
|
||||
// deadline while an already-absolute expiry passes through byte-identical.
|
||||
// Before the fix every polled row got its own no-op expiry UPDATE.
|
||||
func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
const delayedEmail = "delayed-mixed-user"
|
||||
const normalEmail = "normal-mixed-user"
|
||||
const delayedUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d002"
|
||||
const normalUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d003"
|
||||
const sevenDays = int64(7 * 86400000)
|
||||
normalExpiry := time.Now().AddDate(0, 1, 0).UnixMilli()
|
||||
|
||||
clients := []model.Client{
|
||||
{Email: delayedEmail, ID: delayedUID, Enable: true, ExpiryTime: -sevenDays},
|
||||
{Email: normalEmail, ID: normalUID, Enable: true, ExpiryTime: normalExpiry},
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Tag: "vless-mixed", Enable: true, Port: 45002, Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, clients),
|
||||
}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if err := svc.clientService.SyncInbound(db, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: delayedEmail, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
|
||||
t.Fatalf("create delayed traffic row: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: normalEmail, Enable: true, ExpiryTime: normalExpiry}).Error; err != nil {
|
||||
t.Fatalf("create normal traffic row: %v", err)
|
||||
}
|
||||
|
||||
before := time.Now().UnixMilli()
|
||||
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
|
||||
{Email: delayedEmail, Up: 10, Down: 20},
|
||||
{Email: normalEmail, Up: 30, Down: 40},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("addClientTraffic: %v", err)
|
||||
}
|
||||
|
||||
var delayed xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", delayedEmail).First(&delayed).Error; err != nil {
|
||||
t.Fatalf("reload delayed row: %v", err)
|
||||
}
|
||||
if delayed.ExpiryTime < before+sevenDays-5000 || delayed.ExpiryTime > before+sevenDays+5000 {
|
||||
t.Errorf("delayed expiry = %d, want ~now+7d (%d)", delayed.ExpiryTime, before+sevenDays)
|
||||
}
|
||||
|
||||
var normal xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", normalEmail).First(&normal).Error; err != nil {
|
||||
t.Fatalf("reload normal row: %v", err)
|
||||
}
|
||||
if normal.ExpiryTime != normalExpiry {
|
||||
t.Errorf("normal expiry changed: %d, want %d", normal.ExpiryTime, normalExpiry)
|
||||
}
|
||||
if normal.Up != 30 || normal.Down != 40 {
|
||||
t.Errorf("normal traffic not applied: up=%d down=%d, want 30/40", normal.Up, normal.Down)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -80,7 +81,7 @@ func depletedCond(tx *gorm.DB) string {
|
||||
return depletedClientsCondLocal
|
||||
}
|
||||
|
||||
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
|
||||
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
cond := depletedCond(tx)
|
||||
@@ -90,10 +91,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
|
||||
Where(cond+" AND enable = ?", now, true).
|
||||
Find(&depletedRows).Error
|
||||
if err != nil {
|
||||
return false, 0, nil, err
|
||||
return false, 0, err
|
||||
}
|
||||
if len(depletedRows) == 0 {
|
||||
return false, 0, nil, nil
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
depletedEmails := make([]string, 0, len(depletedRows))
|
||||
@@ -121,7 +122,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
|
||||
WHERE clients.email IN ?
|
||||
`, depletedEmails).Scan(&targets).Error
|
||||
if err != nil {
|
||||
return false, 0, nil, err
|
||||
return false, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,13 +163,23 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND enable = ?", now, true).
|
||||
Update("enable", false)
|
||||
err = result.Error
|
||||
count := result.RowsAffected
|
||||
if err != nil {
|
||||
return needRestart, count, nil, err
|
||||
// Flip the rows already collected above by primary key instead of
|
||||
// re-evaluating the depleted predicate, which was a second full scan of
|
||||
// client_traffics on every poll. Sorted ids keep the lock order stable.
|
||||
ids := make([]int, 0, len(depletedRows))
|
||||
for i := range depletedRows {
|
||||
ids = append(ids, depletedRows[i].Id)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
var count int64
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where("id IN ? AND enable = ?", batch, true).
|
||||
Update("enable", false)
|
||||
if result.Error != nil {
|
||||
return needRestart, count, result.Error
|
||||
}
|
||||
count += result.RowsAffected
|
||||
}
|
||||
|
||||
if len(depletedEmails) > 0 {
|
||||
@@ -179,7 +190,6 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
|
||||
}
|
||||
}
|
||||
|
||||
disabledNodeIDs := make(map[int]struct{})
|
||||
for inboundID, group := range remoteByInbound {
|
||||
emails := make(map[string]struct{}, len(group))
|
||||
for _, t := range group {
|
||||
@@ -188,21 +198,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
|
||||
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
|
||||
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
|
||||
needRestart = true
|
||||
} else {
|
||||
for _, t := range group {
|
||||
if t.NodeID != nil {
|
||||
disabledNodeIDs[*t.NodeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodeIDs := make([]int, 0, len(disabledNodeIDs))
|
||||
for nodeID := range disabledNodeIDs {
|
||||
nodeIDs = append(nodeIDs, nodeID)
|
||||
}
|
||||
|
||||
return needRestart, count, nodeIDs, nil
|
||||
return needRestart, count, nil
|
||||
}
|
||||
|
||||
// markClientsDisabledInSettings flips client.enable=false in the inbound's
|
||||
@@ -255,6 +254,10 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
|
||||
return &snapshot, &ib, nil
|
||||
}
|
||||
|
||||
// disableRemoteClients flips the clients off in the inbound's stored settings
|
||||
// and pushes the updated inbound to its node, which applies it to its own
|
||||
// running Xray. That push is the whole reconcile — restarting the node's Xray
|
||||
// afterwards would drop every live connection on the node for nothing (#5740).
|
||||
func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error {
|
||||
oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
|
||||
if err != nil {
|
||||
|
||||
@@ -191,6 +191,17 @@ func mergeActivationExpiry(existing, node int64) int64 {
|
||||
return node
|
||||
}
|
||||
|
||||
// liftActivatedClientRecordExpiries copies a node-activated deadline from
|
||||
// client_traffics onto client records still holding the negative duration (#5714).
|
||||
func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
|
||||
return tx.Exec(
|
||||
`UPDATE clients
|
||||
SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1)
|
||||
WHERE clients.expiry_time < 0
|
||||
AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`,
|
||||
).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||
var structuralChange bool
|
||||
err := submitTrafficWrite(func() error {
|
||||
@@ -475,6 +486,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
|
||||
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||
|
||||
// Adopting the node's settings verbatim would re-add a client the master
|
||||
// deleted moments ago if this snapshot was fetched before the deletion
|
||||
// push landed — filter just-deleted emails out while their tombstone lives.
|
||||
adoptedSettings := snapIb.Settings
|
||||
if stripped, changed := stripTombstonedClients(adoptedSettings); changed {
|
||||
adoptedSettings = stripped
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if !dirty {
|
||||
updates["enable"] = snapIb.Enable
|
||||
@@ -485,7 +504,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
updates["protocol"] = snapIb.Protocol
|
||||
updates["total"] = snapIb.Total
|
||||
updates["expiry_time"] = snapIb.ExpiryTime
|
||||
updates["settings"] = snapIb.Settings
|
||||
updates["settings"] = adoptedSettings
|
||||
updates["stream_settings"] = snapIb.StreamSettings
|
||||
updates["sniffing"] = snapIb.Sniffing
|
||||
updates["traffic_reset"] = snapIb.TrafficReset
|
||||
@@ -502,7 +521,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
updates["origin_node_guid"] = og
|
||||
}
|
||||
|
||||
if !dirty && (c.Settings != snapIb.Settings ||
|
||||
if !dirty && (c.Settings != adoptedSettings ||
|
||||
c.Remark != snapIb.Remark ||
|
||||
c.Listen != snapIb.Listen ||
|
||||
c.Port != snapIb.Port ||
|
||||
@@ -623,8 +642,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if dirty {
|
||||
continue
|
||||
}
|
||||
_, isNewInbound := newInboundIDs[c.Id]
|
||||
// On a known inbound a missing row plus a live tombstone means the
|
||||
// master just deleted this client and the snapshot predates the
|
||||
// deletion push — recreating the row (at zero) would resurrect the
|
||||
// client. A freshly adopted inbound still gets its row (seeded at
|
||||
// zero) so adoption semantics stay intact.
|
||||
if !isNewInbound && isClientEmailTombstoned(cs.Email) {
|
||||
continue
|
||||
}
|
||||
var seedUp, seedDown int64
|
||||
if _, isNewInbound := newInboundIDs[c.Id]; isNewInbound && !isClientEmailTombstoned(cs.Email) {
|
||||
if isNewInbound && !isClientEmailTombstoned(cs.Email) {
|
||||
seedUp, seedDown = canon.Up, canon.Down
|
||||
}
|
||||
row := &xray.ClientTraffic{
|
||||
@@ -847,6 +875,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
}
|
||||
|
||||
if err := liftActivatedClientRecordExpiries(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -880,28 +912,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
return structuralChange, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
|
||||
restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
|
||||
if err != nil {
|
||||
logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
|
||||
return
|
||||
}
|
||||
if !restartOnDisable {
|
||||
return
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
nodeIDCopy := nodeID
|
||||
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
|
||||
if rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
|
||||
continue
|
||||
}
|
||||
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
if p == nil {
|
||||
return []string{}
|
||||
|
||||
@@ -64,6 +64,16 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
|
||||
existingByEmail[existing[i].Email] = &existing[i]
|
||||
}
|
||||
|
||||
// Deterministic row order keeps concurrent guid merges from deadlocking on
|
||||
// Postgres (40P01) — same discipline as MergeInboundClientIps.
|
||||
emails := make([]string, 0, len(perEmail))
|
||||
for email := range perEmail {
|
||||
if email != "" {
|
||||
emails = append(emails, email)
|
||||
}
|
||||
}
|
||||
sort.Strings(emails)
|
||||
|
||||
tx := db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@@ -71,10 +81,8 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
|
||||
}
|
||||
}()
|
||||
|
||||
for email, incoming := range perEmail {
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
for _, email := range emails {
|
||||
incoming := perEmail[email]
|
||||
var old []model.ClientIpEntry
|
||||
if cur, ok := existingByEmail[email]; ok && cur.Ips != "" {
|
||||
_ = json.Unmarshal([]byte(cur.Ips), &old)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,19 +21,15 @@ import (
|
||||
)
|
||||
|
||||
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
|
||||
var disabledNodeIDs []int
|
||||
err = submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
return inner
|
||||
})
|
||||
if err == nil && len(disabledNodeIDs) > 0 {
|
||||
s.restartRemoteNodesOnDisable(disabledNodeIDs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
|
||||
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
|
||||
var err error
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
@@ -45,11 +43,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
}()
|
||||
err = s.addInboundTraffic(tx, inboundTraffics)
|
||||
if err != nil {
|
||||
return false, false, nil, err
|
||||
return false, false, err
|
||||
}
|
||||
err = s.addClientTraffic(tx, clientTraffics)
|
||||
if err != nil {
|
||||
return false, false, nil, err
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
needRestart0, count, err := s.autoRenewClients(tx)
|
||||
@@ -60,7 +58,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
}
|
||||
|
||||
disabledClientsCount := int64(0)
|
||||
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
|
||||
needRestart1, count, err := s.disableInvalidClients(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling invalid clients:", err)
|
||||
} else if count > 0 {
|
||||
@@ -74,7 +72,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v inbounds disabled", count)
|
||||
}
|
||||
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil
|
||||
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||
@@ -129,7 +127,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
return nil
|
||||
}
|
||||
|
||||
dbClientTraffics, err = s.adjustTraffics(tx, dbClientTraffics)
|
||||
dbClientTraffics, convertedExpiryByEmail, err := s.adjustTraffics(tx, dbClientTraffics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,22 +163,22 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
|
||||
// adjustTraffics converts delayed-start rows (negative ExpiryTime → absolute
|
||||
// deadline) in-memory. Persist that conversion now since the traffic UPDATE
|
||||
// above only touches up/down/last_online.
|
||||
for _, ct := range dbClientTraffics {
|
||||
if ct.ExpiryTime > 0 {
|
||||
if err = tx.Exec(
|
||||
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
|
||||
ct.ExpiryTime, ct.Email,
|
||||
).Error; err != nil {
|
||||
logger.Warning("AddClientTraffic update expiry_time ", err)
|
||||
}
|
||||
// above only touches up/down/last_online. Only converted emails are written:
|
||||
// updating every polled row issued one no-op UPDATE per active client per
|
||||
// poll. Sorted order keeps concurrent writers lock-compatible on Postgres.
|
||||
for _, email := range slices.Sorted(maps.Keys(convertedExpiryByEmail)) {
|
||||
if err = tx.Exec(
|
||||
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
|
||||
convertedExpiryByEmail[email], email,
|
||||
).Error; err != nil {
|
||||
logger.Warning("AddClientTraffic update expiry_time ", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
|
||||
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, map[string]int64, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// "Start After First Use" stores a negative expiry (the duration). On the
|
||||
@@ -194,7 +192,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
}
|
||||
}
|
||||
if len(newExpiryByEmail) == 0 {
|
||||
return dbClientTraffics, nil
|
||||
return dbClientTraffics, nil, nil
|
||||
}
|
||||
|
||||
delayedEmails := make([]string, 0, len(newExpiryByEmail))
|
||||
@@ -212,16 +210,16 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
Distinct().
|
||||
Pluck("client_inbounds.inbound_id", &inboundIds).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(inboundIds) == 0 {
|
||||
return dbClientTraffics, nil
|
||||
return dbClientTraffics, nil, nil
|
||||
}
|
||||
|
||||
var inbounds []*model.Inbound
|
||||
err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
for inbound_index := range inbounds {
|
||||
settings := map[string]any{}
|
||||
@@ -247,7 +245,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
settings["clients"] = newClients
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
inbounds[inbound_index].Settings = string(modifiedSettings)
|
||||
@@ -280,7 +278,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
}
|
||||
}
|
||||
|
||||
return dbClientTraffics, nil
|
||||
return dbClientTraffics, newExpiryByEmail, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
@@ -474,6 +472,9 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
|
||||
}
|
||||
|
||||
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{email}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -484,6 +485,9 @@ func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
|
||||
}
|
||||
|
||||
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, emails); err != nil {
|
||||
return err
|
||||
}
|
||||
const chunk = 400
|
||||
for start := 0; start < len(emails); start += chunk {
|
||||
end := min(start+chunk, len(emails))
|
||||
@@ -503,16 +507,20 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er
|
||||
|
||||
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
|
||||
return submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
if err := clearGlobalTraffic(db, clientEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", clientEmail).
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", clientEmail).
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -596,6 +604,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
return false, err
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(traffic).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -917,14 +928,18 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
|
||||
}
|
||||
traffics = append(traffics, page...)
|
||||
}
|
||||
overlayGlobalTraffic(db, traffics)
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
// GetAllClientTraffics returns the full set of client_traffics rows so the
|
||||
// websocket broadcasters can ship a complete snapshot every cycle. The old
|
||||
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
|
||||
// the per-client section whenever no client moved bytes in the cycle or a
|
||||
// node sync failed, leaving client rows in the UI stuck at stale numbers.
|
||||
// websocket broadcasters can ship a complete snapshot every cycle. A pure
|
||||
// delta path silently dropped the per-client section whenever no client moved
|
||||
// bytes in the cycle or a node sync failed, leaving client rows in the UI
|
||||
// stuck at stale numbers — so small installs broadcast this snapshot, and only
|
||||
// above the traffic job's snapshot threshold (where the marshaled snapshot
|
||||
// would exceed the hub's payload cap and be dropped wholesale) does the job
|
||||
// fall back to active-row deltas.
|
||||
func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
@@ -935,6 +950,13 @@ func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) CountClientTraffics() (int64, error) {
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
err := db.Model(xray.ClientTraffic{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
type InboundTrafficSummary struct {
|
||||
Id int `json:"id"`
|
||||
Up int64 `json:"up"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
@@ -139,3 +140,44 @@ func TestNodeRenewExtendsExpiry(t *testing.T) {
|
||||
t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
|
||||
// the deadline (positive ClientStats) while its settings JSON still carries the
|
||||
// negative duration, so SyncInbound keeps writing the stale value into the
|
||||
// client record and the Clients page shows "not started" forever.
|
||||
func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "delayed"
|
||||
const duration = int64(-2592000000)
|
||||
const activated = int64(1798448344010)
|
||||
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
|
||||
|
||||
if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
|
||||
t.Fatalf("seed client record: %v", err)
|
||||
}
|
||||
|
||||
readRecordExpiry := func() int64 {
|
||||
t.Helper()
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
t.Fatalf("read client record: %v", err)
|
||||
}
|
||||
return rec.ExpiryTime
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
|
||||
if got := readRecordExpiry(); got != duration {
|
||||
t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
|
||||
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
|
||||
t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
|
||||
}
|
||||
if got := readRecordExpiry(); got != activated {
|
||||
t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -135,6 +136,47 @@ func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) {
|
||||
assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count")
|
||||
}
|
||||
|
||||
// TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot
|
||||
// fetched just before a client's deletion still names the email. Applying it
|
||||
// must neither recreate the client_traffics row (at zero) nor re-add the
|
||||
// client to the central inbound's settings while the delete tombstone lives.
|
||||
func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739")
|
||||
svc := &InboundService{}
|
||||
|
||||
const email = "victim-5739"
|
||||
withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
|
||||
|
||||
if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in").
|
||||
Update("settings", `{"clients": []}`).Error; err != nil {
|
||||
t.Fatalf("clear central settings: %v", err)
|
||||
}
|
||||
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
t.Fatalf("delete stats row: %v", err)
|
||||
}
|
||||
tombstoneClientEmail(email)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true})
|
||||
|
||||
var rows int64
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil {
|
||||
t.Fatalf("count stats rows: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows)
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if strings.Contains(ib.Settings, email) {
|
||||
t.Errorf("deleted client re-added to central settings: %s", ib.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -44,10 +45,64 @@ const (
|
||||
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
|
||||
// newest main commit; the dev update channel installs from it.
|
||||
devReleaseTag = "dev-latest"
|
||||
|
||||
updateStatePending = "pending"
|
||||
updateStateSuccess = "success"
|
||||
updateStateFailed = "failed"
|
||||
)
|
||||
|
||||
// PanelUpdateStatus reports the outcome of the most recently launched panel
|
||||
// self-update. RunID lets the caller confirm this status belongs to the
|
||||
// update it started rather than a stale result left over from an earlier
|
||||
// run; State is one of "pending", "success", or "failed". RunID is a decimal
|
||||
// string, not a JSON number: it's a formatted UnixNano timestamp, and
|
||||
// JavaScript's number type can't represent that precisely (it exceeds
|
||||
// Number.MAX_SAFE_INTEGER), which would let two different runs round to the
|
||||
// same value on the wire and defeat the whole point of this field.
|
||||
type PanelUpdateStatus struct {
|
||||
RunID string `json:"runId" example:"1735689600123456789"`
|
||||
State string `json:"state" example:"success"`
|
||||
ExitCode int `json:"exitCode" example:"0"`
|
||||
FinishedAt int64 `json:"finishedAt" example:"1735689612"`
|
||||
}
|
||||
|
||||
var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
|
||||
|
||||
// updateMu guards updateRunning/updateStarted/updateRunID/updatePID, which
|
||||
// stop a second self-update from launching while one is still in flight (two
|
||||
// concurrent update.sh runs would race each other extracting the release
|
||||
// tarball and swapping the service unit). A slot is released as soon as the
|
||||
// in-flight run's own status file reports success or failure -- checked
|
||||
// against updateRunID so a stale file from an even earlier run can't be
|
||||
// mistaken for this one finishing -- so a fast failure doesn't lock out a
|
||||
// retry.
|
||||
//
|
||||
// For a run that never reaches a terminal state at all, staleness is judged
|
||||
// primarily by whether the process we actually launched is still alive
|
||||
// (updatePID, via processAlive), not by wall-clock time alone: update.sh
|
||||
// runs install_base() (a package-manager update+install) before anything
|
||||
// else, plus several downloads, which can legitimately run past a short
|
||||
// fixed timeout on a slow or throttled host without anything being wrong.
|
||||
// updateStaleAfter/updatePID together are only a fallback for the systemd-run
|
||||
// launch path, where the process we can observe (systemd-run itself) has
|
||||
// already exited by the time startUpdate returns and the actual update.sh
|
||||
// unit's PID is never recorded -- for that path this is still a pure
|
||||
// wall-clock heuristic. updateHardCeiling is an absolute backstop so a
|
||||
// genuinely wedged run (alive but hung forever) can never lock out retries
|
||||
// permanently, even on the PID-tracked path.
|
||||
var (
|
||||
updateMu sync.Mutex
|
||||
updateRunning bool
|
||||
updateStarted time.Time
|
||||
updateRunID int64
|
||||
updatePID int
|
||||
)
|
||||
|
||||
const (
|
||||
updateStaleAfter = 20 * time.Minute
|
||||
updateHardCeiling = 2 * time.Hour
|
||||
)
|
||||
|
||||
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||
go func() {
|
||||
time.Sleep(delay)
|
||||
@@ -122,39 +177,77 @@ func getDevUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartUpdate starts the official updater using this panel's own channel setting.
|
||||
func (s *PanelService) StartUpdate() error {
|
||||
// StartUpdate starts the official updater using this panel's own channel
|
||||
// setting. Returns the run ID to pass to GetUpdateStatus so the caller can
|
||||
// tell this run's result apart from a stale one.
|
||||
func (s *PanelService) StartUpdate() (int64, error) {
|
||||
return s.startUpdate(devChannelActive())
|
||||
}
|
||||
|
||||
// StartUpdateChannel runs the updater against an explicitly chosen channel,
|
||||
// overriding the local dev-channel setting. Used by the master node updater so
|
||||
// a node can be moved to the dev channel from the central panel.
|
||||
func (s *PanelService) StartUpdateChannel(dev bool) error {
|
||||
func (s *PanelService) StartUpdateChannel(dev bool) (int64, error) {
|
||||
return s.startUpdate(dev)
|
||||
}
|
||||
|
||||
func (s *PanelService) startUpdate(useDev bool) error {
|
||||
// GetUpdateStatus reports the outcome of the most recently launched panel
|
||||
// self-update, as recorded by update.sh's EXIT trap (see the script for why
|
||||
// that covers every exit path, not just the happy one). This is a best-effort
|
||||
// side channel: a missing or unreadable status file reads as "pending"
|
||||
// rather than an error, since the update itself is what matters, not this
|
||||
// status file.
|
||||
func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus {
|
||||
data, err := os.ReadFile(config.GetUpdateStatusFilePath())
|
||||
if err != nil {
|
||||
return &PanelUpdateStatus{State: updateStatePending}
|
||||
}
|
||||
var status PanelUpdateStatus
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
return &PanelUpdateStatus{State: updateStatePending}
|
||||
}
|
||||
if status.State != updateStateSuccess && status.State != updateStateFailed {
|
||||
status.State = updateStatePending
|
||||
}
|
||||
return &status
|
||||
}
|
||||
|
||||
func (s *PanelService) startUpdate(useDev bool) (int64, error) {
|
||||
runID := time.Now().UnixNano()
|
||||
if !acquireUpdateSlot(runID) {
|
||||
return 0, fmt.Errorf("a panel update is already in progress")
|
||||
}
|
||||
launched := false
|
||||
defer func() {
|
||||
if !launched {
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
}()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("panel web update is supported only on Linux installations")
|
||||
return 0, fmt.Errorf("panel web update is supported only on Linux installations")
|
||||
}
|
||||
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
return fmt.Errorf("bash is required to run the panel updater: %w", err)
|
||||
return 0, fmt.Errorf("bash is required to run the panel updater: %w", err)
|
||||
}
|
||||
|
||||
scriptPath, err := downloadPanelUpdater()
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
statusFile := config.GetUpdateStatusFilePath()
|
||||
|
||||
mainFolder, serviceFolder := resolveUpdateFolders()
|
||||
updateTag := ""
|
||||
if useDev {
|
||||
updateTag = devReleaseTag
|
||||
}
|
||||
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
|
||||
runIDEnv := "XUI_UPDATE_RUN_ID=" + strconv.FormatInt(runID, 10)
|
||||
statusFileEnv := "XUI_UPDATE_STATUS_FILE=" + statusFile
|
||||
|
||||
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
|
||||
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
|
||||
@@ -163,6 +256,8 @@ func (s *PanelService) startUpdate(useDev bool) error {
|
||||
"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
|
||||
"--setenv", "XUI_SERVICE="+serviceFolder,
|
||||
"--setenv", "XUI_UPDATE_TAG="+updateTag,
|
||||
"--setenv", runIDEnv,
|
||||
"--setenv", statusFileEnv,
|
||||
bash, "-lc", updateScript,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
@@ -171,12 +266,13 @@ func (s *PanelService) startUpdate(useDev bool) error {
|
||||
if !strings.Contains(output, "System has not been booted with systemd") &&
|
||||
!strings.Contains(output, "Failed to connect to bus") {
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
|
||||
return 0, fmt.Errorf("failed to start panel update job: %w: %s", err, output)
|
||||
}
|
||||
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
|
||||
} else {
|
||||
logger.Infof("started panel update job via systemd-run unit %s", unitName)
|
||||
return nil
|
||||
launched = true
|
||||
return runID, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,17 +281,77 @@ func (s *PanelService) startUpdate(useDev bool) error {
|
||||
"XUI_MAIN_FOLDER="+mainFolder,
|
||||
"XUI_SERVICE="+serviceFolder,
|
||||
"XUI_UPDATE_TAG="+updateTag,
|
||||
runIDEnv,
|
||||
statusFileEnv,
|
||||
)
|
||||
setDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("failed to start panel update job: %w", err)
|
||||
return 0, fmt.Errorf("failed to start panel update job: %w", err)
|
||||
}
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
logger.Warning("failed to release panel update process:", err)
|
||||
}
|
||||
logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
|
||||
return nil
|
||||
recordUpdatePID(cmd.Process.Pid)
|
||||
launched = true
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
// acquireUpdateSlot claims the single in-flight-update slot for runID. It
|
||||
// refuses while another run is genuinely still in flight, but grants the
|
||||
// slot immediately once that run's own status file reports a terminal
|
||||
// result (success or failure) -- a fast failure shouldn't force the next
|
||||
// attempt to wait out updateStaleAfter for no reason. Past updateStaleAfter
|
||||
// with no terminal status yet, it grants the slot anyway UNLESS the process
|
||||
// we recorded (updatePID) is confirmed still alive, so a merely-slow run
|
||||
// isn't mistaken for a crashed one; past updateHardCeiling it grants the
|
||||
// slot unconditionally regardless of liveness, so a truly wedged run can
|
||||
// never lock out retries forever.
|
||||
func acquireUpdateSlot(runID int64) bool {
|
||||
updateMu.Lock()
|
||||
defer updateMu.Unlock()
|
||||
if updateRunning && !previousRunIsTerminal() {
|
||||
elapsed := time.Since(updateStarted)
|
||||
if elapsed < updateHardCeiling {
|
||||
stale := elapsed >= updateStaleAfter
|
||||
alive := updatePID > 0 && processAlive(updatePID)
|
||||
if !stale || alive {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
updateRunning = true
|
||||
updateStarted = time.Now()
|
||||
updateRunID = runID
|
||||
updatePID = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// recordUpdatePID notes the PID of the detached update.sh process the
|
||||
// current slot is tracking, so a later acquireUpdateSlot call can check
|
||||
// whether it is actually still running instead of only how long ago it
|
||||
// started. Only reachable for the detached-fallback launch path -- the
|
||||
// systemd-run path never learns update.sh's own PID, since the process it
|
||||
// directly observes (systemd-run) has already exited by the time it returns.
|
||||
func recordUpdatePID(pid int) {
|
||||
updateMu.Lock()
|
||||
updatePID = pid
|
||||
updateMu.Unlock()
|
||||
}
|
||||
|
||||
// previousRunIsTerminal reports whether the run currently recorded in
|
||||
// updateRunID has reached success or failure per its status file. Must be
|
||||
// called with updateMu held.
|
||||
func previousRunIsTerminal() bool {
|
||||
status := (&PanelService{}).GetUpdateStatus()
|
||||
return status.RunID == strconv.FormatInt(updateRunID, 10) && status.State != updateStatePending
|
||||
}
|
||||
|
||||
func releaseUpdateSlot() {
|
||||
updateMu.Lock()
|
||||
updateRunning = false
|
||||
updateMu.Unlock()
|
||||
}
|
||||
|
||||
func downloadPanelUpdater() (string, error) {
|
||||
@@ -230,6 +386,9 @@ func downloadPanelUpdater() (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write panel updater: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return "", fmt.Errorf("panel updater download is empty")
|
||||
}
|
||||
if n > maxPanelUpdaterBytes {
|
||||
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
|
||||
}
|
||||
|
||||
@@ -5,3 +5,10 @@ package panel
|
||||
import "os/exec"
|
||||
|
||||
func setDetachedProcess(cmd *exec.Cmd) {}
|
||||
|
||||
// processAlive is never meaningfully consulted outside Linux: startUpdate
|
||||
// itself is gated to runtime.GOOS == "linux" before any process is ever
|
||||
// launched, so no real PID is ever recorded on this platform.
|
||||
func processAlive(pid int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
@@ -107,3 +114,207 @@ func TestShortCommit(t *testing.T) {
|
||||
t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
|
||||
}
|
||||
}
|
||||
|
||||
func resetUpdateSlot(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
updateMu.Lock()
|
||||
updateRunning = false
|
||||
updateRunID = 0
|
||||
updatePID = 0
|
||||
updateMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// writeStatusFile hand-writes the status file in the exact wire format
|
||||
// update.sh itself produces (a bare printf, not Go's json.Marshal), since
|
||||
// that's the real cross-language contract this package reads in production.
|
||||
func writeStatusFile(t *testing.T, path string, runID int64, state string) {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix())
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireUpdateSlot(t *testing.T) {
|
||||
resetUpdateSlot(t)
|
||||
|
||||
if !acquireUpdateSlot(1) {
|
||||
t.Fatal("first acquire: got false, want true")
|
||||
}
|
||||
if acquireUpdateSlot(2) {
|
||||
t.Fatal("second acquire while first is held: got true, want false")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
if !acquireUpdateSlot(3) {
|
||||
t.Fatal("acquire after release: got false, want true")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) {
|
||||
resetUpdateSlot(t)
|
||||
|
||||
if !acquireUpdateSlot(1) {
|
||||
t.Fatal("first acquire: got false, want true")
|
||||
}
|
||||
updateMu.Lock()
|
||||
updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
|
||||
updateMu.Unlock()
|
||||
|
||||
if !acquireUpdateSlot(2) {
|
||||
t.Fatal("acquire after stale window elapsed: got false, want true")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
// TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression
|
||||
// test for the concurrency bug an upstream review found: past
|
||||
// updateStaleAfter, the old logic freed the slot purely on elapsed time, even
|
||||
// if the process it launched was still genuinely running (not crashed) --
|
||||
// update.sh's own package-manager step plus several downloads can plausibly
|
||||
// run long on a slow host with nothing actually wrong. Now a confirmed-alive
|
||||
// PID keeps the slot held past the stale window.
|
||||
func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
|
||||
}
|
||||
resetUpdateSlot(t)
|
||||
|
||||
if !acquireUpdateSlot(1) {
|
||||
t.Fatal("first acquire: got false, want true")
|
||||
}
|
||||
recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive
|
||||
updateMu.Lock()
|
||||
updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
|
||||
updateMu.Unlock()
|
||||
|
||||
if acquireUpdateSlot(2) {
|
||||
t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
// TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute
|
||||
// backstop: even a confirmed-alive process can't hold the slot forever, so a
|
||||
// genuinely wedged run can't lock out retries permanently.
|
||||
func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
|
||||
}
|
||||
resetUpdateSlot(t)
|
||||
|
||||
if !acquireUpdateSlot(1) {
|
||||
t.Fatal("first acquire: got false, want true")
|
||||
}
|
||||
recordUpdatePID(os.Getpid())
|
||||
updateMu.Lock()
|
||||
updateStarted = time.Now().Add(-(updateHardCeiling + time.Second))
|
||||
updateMu.Unlock()
|
||||
|
||||
if !acquireUpdateSlot(2) {
|
||||
t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
// TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the
|
||||
// bug adversarial review found: a fast failure used to still lock out retries
|
||||
// for the full updateStaleAfter window, because acquireUpdateSlot only looked
|
||||
// at the in-memory started-at timestamp, never at the status file's own
|
||||
// terminal state.
|
||||
func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
resetUpdateSlot(t)
|
||||
path := config.GetUpdateStatusFilePath()
|
||||
|
||||
if !acquireUpdateSlot(111) {
|
||||
t.Fatal("first acquire: got false, want true")
|
||||
}
|
||||
writeStatusFile(t, path, 111, updateStateFailed)
|
||||
|
||||
if !acquireUpdateSlot(222) {
|
||||
t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
// TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state
|
||||
// check is scoped to the run it actually launched: a status file left behind
|
||||
// by some earlier, unrelated run (different runID) must not be mistaken for
|
||||
// this run finishing.
|
||||
func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
resetUpdateSlot(t)
|
||||
path := config.GetUpdateStatusFilePath()
|
||||
|
||||
writeStatusFile(t, path, 999, updateStateSuccess)
|
||||
if !acquireUpdateSlot(111) {
|
||||
t.Fatal("first acquire: got false, want true")
|
||||
}
|
||||
|
||||
if acquireUpdateSlot(222) {
|
||||
t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false")
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
// TestAcquireUpdateSlotConcurrency proves the check-then-set is actually
|
||||
// atomic under real concurrent access, not just correct when called
|
||||
// sequentially. A prior version of this test suite only ever called
|
||||
// acquireUpdateSlot from a single goroutine, so it gave no signal if the
|
||||
// mutex's core promise (only one concurrent launch wins) were broken.
|
||||
func TestAcquireUpdateSlotConcurrency(t *testing.T) {
|
||||
resetUpdateSlot(t)
|
||||
|
||||
const attempts = 200
|
||||
var wins atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(attempts)
|
||||
for i := range attempts {
|
||||
go func(runID int64) {
|
||||
defer wg.Done()
|
||||
if acquireUpdateSlot(runID) {
|
||||
wins.Add(1)
|
||||
}
|
||||
}(int64(i))
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := wins.Load(); got != 1 {
|
||||
t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts)
|
||||
}
|
||||
releaseUpdateSlot()
|
||||
}
|
||||
|
||||
func TestGetUpdateStatus(t *testing.T) {
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
path := config.GetUpdateStatusFilePath()
|
||||
svc := &PanelService{}
|
||||
|
||||
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
|
||||
t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending)
|
||||
}
|
||||
|
||||
writeStatusFile(t, path, 1735689600123456789, updateStateSuccess)
|
||||
got := svc.GetUpdateStatus()
|
||||
if got.RunID != "1735689600123456789" {
|
||||
t.Fatalf("RunID = %q, want %q (must round-trip as a decimal string, not a JSON number, or it loses precision past 2^53 in JS)", got.RunID, "1735689600123456789")
|
||||
}
|
||||
if got.State != updateStateSuccess {
|
||||
t.Fatalf("State = %q, want %q", got.State, updateStateSuccess)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
|
||||
t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending)
|
||||
}
|
||||
|
||||
writeStatusFile(t, path, 1, "some-unrecognized-state")
|
||||
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
|
||||
t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,3 +10,17 @@ import (
|
||||
func setDetachedProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
||||
// processAlive reports whether pid is still a live process, via the standard
|
||||
// POSIX kill(pid, 0) liveness check: it sends no actual signal, only checking
|
||||
// whether the target exists and is signalable. ESRCH means the process is
|
||||
// gone; any other result (including a permission error, which can only mean
|
||||
// the PID exists and belongs to someone) is treated as alive, since this is
|
||||
// used to decide whether it is safe to let a second update start.
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || err == syscall.EPERM
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// setupScaleDB initializes the DB for a scale benchmark on either Postgres
|
||||
// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file),
|
||||
// and registers cleanup. Skips the test when neither backend is configured.
|
||||
// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file;
|
||||
// XUI_SCALE_DB_PATH persists the DB for manual smoke runs), and registers
|
||||
// cleanup. Skips the test when neither backend is configured.
|
||||
func setupScaleDB(t *testing.T) {
|
||||
t.Helper()
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
@@ -31,7 +37,10 @@ func setupScaleDB(t *testing.T) {
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||
case "1", "true", "yes":
|
||||
dbPath := filepath.Join(t.TempDir(), "scale.db")
|
||||
dbPath := strings.TrimSpace(os.Getenv("XUI_SCALE_DB_PATH"))
|
||||
if dbPath == "" {
|
||||
dbPath = filepath.Join(t.TempDir(), "scale.db")
|
||||
}
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB(sqlite): %v", err)
|
||||
}
|
||||
@@ -42,6 +51,144 @@ func setupScaleDB(t *testing.T) {
|
||||
t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
|
||||
}
|
||||
|
||||
// scaleSizes returns the default size ladder unless XUI_SCALE_SIZES overrides
|
||||
// it with a comma-separated list (e.g. "500000" or "10000,100000,500000").
|
||||
func scaleSizes(t *testing.T, def ...int) []int {
|
||||
t.Helper()
|
||||
raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
var out []int
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n <= 0 {
|
||||
t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return def
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type scaleDataset struct {
|
||||
inboundIds []int
|
||||
tags []string
|
||||
emails []string
|
||||
perInbound [][]model.Client
|
||||
}
|
||||
|
||||
// seedScaleDataset seeds n healthy clients (future expiry, unfilled quota)
|
||||
// spread across numInbounds inbounds, writing inbounds, clients,
|
||||
// client_inbounds and client_traffics directly in one transaction — orders of
|
||||
// magnitude faster than SyncInbound and one fsync instead of thousands.
|
||||
func seedScaleDataset(t *testing.T, n, numInbounds int) scaleDataset {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
for i := range clients {
|
||||
clients[i].ExpiryTime = exp
|
||||
clients[i].TotalGB = 100 << 30
|
||||
}
|
||||
|
||||
ds := scaleDataset{emails: emailsOf(clients)}
|
||||
start := time.Now()
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin seed tx: %v", tx.Error)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
per := n / numInbounds
|
||||
for i := range numInbounds {
|
||||
lo, hi := i*per, (i+1)*per
|
||||
if i == numInbounds-1 {
|
||||
hi = n
|
||||
}
|
||||
chunk := clients[lo:hi]
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: fmt.Sprintf("scale-%d-%d", n, i),
|
||||
Enable: true,
|
||||
Port: 41000 + i,
|
||||
Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, chunk),
|
||||
}
|
||||
if err := tx.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound %d: %v", i, err)
|
||||
}
|
||||
|
||||
records := make([]*model.ClientRecord, len(chunk))
|
||||
for j := range chunk {
|
||||
records[j] = chunk[j].ToRecord()
|
||||
}
|
||||
if err := tx.CreateInBatches(records, 500).Error; err != nil {
|
||||
t.Fatalf("seed clients %d: %v", i, err)
|
||||
}
|
||||
|
||||
links := make([]model.ClientInbound, len(records))
|
||||
for j := range records {
|
||||
links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
|
||||
}
|
||||
if err := tx.CreateInBatches(links, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_inbounds %d: %v", i, err)
|
||||
}
|
||||
|
||||
traffics := make([]xray.ClientTraffic, len(chunk))
|
||||
for j := range chunk {
|
||||
traffics[j] = xray.ClientTraffic{
|
||||
InboundId: ib.Id,
|
||||
Email: chunk[j].Email,
|
||||
Enable: true,
|
||||
Total: chunk[j].TotalGB,
|
||||
ExpiryTime: chunk[j].ExpiryTime,
|
||||
}
|
||||
}
|
||||
if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics %d: %v", i, err)
|
||||
}
|
||||
|
||||
ds.inboundIds = append(ds.inboundIds, ib.Id)
|
||||
ds.tags = append(ds.tags, ib.Tag)
|
||||
ds.perInbound = append(ds.perInbound, chunk)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
t.Fatalf("commit seed tx: %v", err)
|
||||
}
|
||||
committed = true
|
||||
db.Exec("ANALYZE")
|
||||
t.Logf("seeded N=%d across %d inbound(s) in %v", n, numInbounds, time.Since(start).Round(time.Millisecond))
|
||||
return ds
|
||||
}
|
||||
|
||||
// sampleEmails picks k evenly spaced emails so active clients span the id range.
|
||||
func sampleEmails(emails []string, k int) []string {
|
||||
if k >= len(emails) {
|
||||
return emails
|
||||
}
|
||||
out := make([]string, 0, k)
|
||||
step := len(emails) / k
|
||||
for i := 0; i < len(emails) && len(out) < k; i += step {
|
||||
out = append(out, emails[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resetScaleTables empties the given tables between sub-sizes. Postgres uses a
|
||||
// single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
|
||||
// autoincrement counters so ids restart like RESTART IDENTITY.
|
||||
|
||||
@@ -710,11 +710,13 @@ func (s *SettingService) GetSubTitle() (string, error) {
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubSupportUrl() (string, error) {
|
||||
return s.getString("subSupportUrl")
|
||||
value, err := s.getString("subSupportUrl")
|
||||
return common.EnsureURLScheme(value), err
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubProfileUrl() (string, error) {
|
||||
return s.getString("subProfileUrl")
|
||||
value, err := s.getString("subProfileUrl")
|
||||
return common.EnsureURLScheme(value), err
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubAnnounce() (string, error) {
|
||||
@@ -1083,8 +1085,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
|
||||
return s.setInt("smtpMemory", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||
if err := s.preserveRedactedSecrets(allSetting); err != nil {
|
||||
// SecretClears marks redacted secrets the user explicitly emptied. Without a
|
||||
// flag, a blank submitted secret means "unchanged" (the field is always served
|
||||
// blank to the browser) and the stored value is preserved.
|
||||
type SecretClears struct {
|
||||
TgBotToken bool
|
||||
LdapPassword bool
|
||||
SmtpPassword bool
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSettingsURLs(allSetting); err != nil {
|
||||
@@ -1130,15 +1141,15 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {
|
||||
if strings.TrimSpace(allSetting.TgBotToken) == "" {
|
||||
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
|
||||
value, err := s.GetTgBotToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allSetting.TgBotToken = value
|
||||
}
|
||||
if strings.TrimSpace(allSetting.LdapPassword) == "" {
|
||||
if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
|
||||
value, err := s.GetLdapPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1152,7 +1163,7 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
|
||||
}
|
||||
allSetting.TwoFactorToken = value
|
||||
}
|
||||
if strings.TrimSpace(allSetting.SmtpPassword) == "" {
|
||||
if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
|
||||
value, err := s.GetSmtpPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1177,6 +1188,12 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
|
||||
}
|
||||
allSetting.TgBotAPIServer = u
|
||||
}
|
||||
// Support/profile links land in subscription headers and page data, where
|
||||
// client apps resolve a scheme-less value against the panel's own domain.
|
||||
// Non-http schemes (tg://, mailto:) are legitimate here, so only default
|
||||
// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
|
||||
allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
|
||||
allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := &view.AllSetting
|
||||
if err := s.UpdateAllSetting(settings); err != nil {
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||
@@ -94,6 +94,54 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetSmtpPassword(); got != "" {
|
||||
t.Fatalf("smtp password = %q, want cleared", got)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
|
||||
}
|
||||
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
||||
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
|
||||
}
|
||||
|
||||
view, err = s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.HasSmtpPassword {
|
||||
t.Fatal("hasSmtpPassword must report false after clearing")
|
||||
}
|
||||
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "" {
|
||||
t.Fatalf("tg token = %q, want cleared", got)
|
||||
}
|
||||
if got, _ := s.GetLdapPassword(); got != "" {
|
||||
t.Fatalf("ldap password = %q, want cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
||||
t.Fatal("expected localhost URL to be blocked")
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func pollReport(ds scaleDataset, k int) ([]*xray.Traffic, []*xray.ClientTraffic) {
|
||||
traffics := make([]*xray.Traffic, 0, len(ds.tags))
|
||||
for _, tag := range ds.tags {
|
||||
traffics = append(traffics, &xray.Traffic{IsInbound: true, Tag: tag, Up: 1 << 20, Down: 2 << 20})
|
||||
}
|
||||
clientTraffics := make([]*xray.ClientTraffic, 0, k)
|
||||
for _, email := range sampleEmails(ds.emails, k) {
|
||||
clientTraffics = append(clientTraffics, &xray.ClientTraffic{Email: email, Up: 1 << 20, Down: 2 << 20})
|
||||
}
|
||||
return traffics, clientTraffics
|
||||
}
|
||||
|
||||
// TestAddTrafficPollScale measures one full traffic-poll cycle (the @every 5s
|
||||
// job): per-client delta UPDATEs, the auto-renew probe and the depleted-client
|
||||
// scan, in steady state and with 100 clients depleting / renewing.
|
||||
func TestAddTrafficPollScale(t *testing.T) {
|
||||
setupScaleDB(t)
|
||||
svc := &InboundService{}
|
||||
shapes := []struct {
|
||||
name string
|
||||
inbounds int
|
||||
}{{"single", 1}, {"spread50", 50}}
|
||||
|
||||
for _, n := range scaleSizes(t, 10000, 100000) {
|
||||
for _, shape := range shapes {
|
||||
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
ds := seedScaleDataset(t, n, shape.inbounds)
|
||||
|
||||
for _, k := range []int{1000, 10000} {
|
||||
if k > n {
|
||||
continue
|
||||
}
|
||||
traffics, clientTraffics := pollReport(ds, k)
|
||||
const reps = 3
|
||||
start := time.Now()
|
||||
for range reps {
|
||||
if _, _, err := svc.AddTraffic(traffics, clientTraffics); err != nil {
|
||||
t.Fatalf("AddTraffic steady: %v", err)
|
||||
}
|
||||
}
|
||||
perPoll := time.Since(start) / reps
|
||||
t.Logf("N=%-7d shape=%-8s K=%-6d steady=%v/poll", n, shape.name, k, perPoll.Round(time.Millisecond))
|
||||
|
||||
var probe xray.ClientTraffic
|
||||
if err := db.Where("email = ?", clientTraffics[0].Email).First(&probe).Error; err != nil {
|
||||
t.Fatalf("load probe row: %v", err)
|
||||
}
|
||||
if probe.Up == 0 || probe.Down == 0 {
|
||||
t.Fatalf("steady polls did not accumulate traffic: up=%d down=%d", probe.Up, probe.Down)
|
||||
}
|
||||
}
|
||||
|
||||
depleted := ds.perInbound[0][:100]
|
||||
if err := db.Model(&xray.ClientTraffic{}).
|
||||
Where("email IN ?", emailsOf(depleted)).
|
||||
Updates(map[string]any{"up": int64(100 << 30), "down": int64(0)}).Error; err != nil {
|
||||
t.Fatalf("mark depleted: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, _, err := svc.AddTraffic(nil, nil); err != nil {
|
||||
t.Fatalf("AddTraffic disable: %v", err)
|
||||
}
|
||||
t.Logf("N=%-7d shape=%-8s disable100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
|
||||
var disabledCount int64
|
||||
if err := db.Model(&xray.ClientTraffic{}).Where("enable = ?", false).Count(&disabledCount).Error; err != nil {
|
||||
t.Fatalf("count disabled: %v", err)
|
||||
}
|
||||
if disabledCount != 100 {
|
||||
t.Fatalf("disable100: got %d disabled rows, want 100", disabledCount)
|
||||
}
|
||||
|
||||
renew := ds.perInbound[0][100:200]
|
||||
past := time.Now().Add(-time.Hour).UnixMilli()
|
||||
if err := db.Model(&xray.ClientTraffic{}).
|
||||
Where("email IN ?", emailsOf(renew)).
|
||||
Updates(map[string]any{"reset": 30, "expiry_time": past, "up": int64(1 << 30)}).Error; err != nil {
|
||||
t.Fatalf("mark renewable: %v", err)
|
||||
}
|
||||
start = time.Now()
|
||||
if _, _, err := svc.AddTraffic(nil, nil); err != nil {
|
||||
t.Fatalf("AddTraffic renew: %v", err)
|
||||
}
|
||||
t.Logf("N=%-7d shape=%-8s renew100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
|
||||
var renewed xray.ClientTraffic
|
||||
if err := db.Where("email = ?", renew[0].Email).First(&renewed).Error; err != nil {
|
||||
t.Fatalf("load renewed row: %v", err)
|
||||
}
|
||||
if renewed.ExpiryTime <= past || renewed.Up != 0 {
|
||||
t.Fatalf("renew100 did not renew: expiry=%d up=%d", renewed.ExpiryTime, renewed.Up)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWsPayloadScale compares the websocket broadcast payload paths the
|
||||
// traffic job runs every 5s while a browser is connected: the full snapshot
|
||||
// (GetAllClientTraffics + full last-online map) against the delta variant
|
||||
// (GetActiveClientTraffics on this poll's active emails). Payload sizes are
|
||||
// logged against the hub's 10MB drop threshold.
|
||||
func TestWsPayloadScale(t *testing.T) {
|
||||
setupScaleDB(t)
|
||||
svc := &InboundService{}
|
||||
const hubPayloadLimit = 10 * 1024 * 1024
|
||||
|
||||
for _, n := range scaleSizes(t, 10000, 100000) {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
ds := seedScaleDataset(t, n, 1)
|
||||
k := min(10000, n)
|
||||
activeEmails := sampleEmails(ds.emails, k)
|
||||
|
||||
start := time.Now()
|
||||
all, err := svc.GetAllClientTraffics()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllClientTraffics: %v", err)
|
||||
}
|
||||
fetchAll := time.Since(start)
|
||||
if len(all) != n {
|
||||
t.Fatalf("GetAllClientTraffics rows = %d, want %d", len(all), n)
|
||||
}
|
||||
start = time.Now()
|
||||
snapshot, err := json.Marshal(map[string]any{"clients": all})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal snapshot: %v", err)
|
||||
}
|
||||
marshalAll := time.Since(start)
|
||||
verdict := "delivered"
|
||||
if len(snapshot) > hubPayloadLimit {
|
||||
verdict = "DROPPED by hub (>10MB)"
|
||||
}
|
||||
t.Logf("N=%-7d snapshot: fetch=%-9v marshal=%-9v payload=%.1fMB %s",
|
||||
n, fetchAll.Round(time.Millisecond), marshalAll.Round(time.Millisecond),
|
||||
float64(len(snapshot))/(1<<20), verdict)
|
||||
|
||||
start = time.Now()
|
||||
active, err := svc.GetActiveClientTraffics(activeEmails)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveClientTraffics: %v", err)
|
||||
}
|
||||
fetchActive := time.Since(start)
|
||||
if len(active) != k {
|
||||
t.Fatalf("GetActiveClientTraffics rows = %d, want %d", len(active), k)
|
||||
}
|
||||
start = time.Now()
|
||||
delta, err := json.Marshal(map[string]any{"clients": active})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delta: %v", err)
|
||||
}
|
||||
marshalActive := time.Since(start)
|
||||
t.Logf("N=%-7d delta(K=%d): fetch=%-9v marshal=%-9v payload=%.1fMB",
|
||||
n, k, fetchActive.Round(time.Millisecond), marshalActive.Round(time.Millisecond),
|
||||
float64(len(delta))/(1<<20))
|
||||
|
||||
start = time.Now()
|
||||
lastOnline, err := svc.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientsLastOnline: %v", err)
|
||||
}
|
||||
fullMap := time.Since(start)
|
||||
if len(lastOnline) != n {
|
||||
t.Fatalf("GetClientsLastOnline entries = %d, want %d", len(lastOnline), n)
|
||||
}
|
||||
start = time.Now()
|
||||
activeLastOnline := make(map[string]int64, len(active))
|
||||
for _, ct := range active {
|
||||
activeLastOnline[ct.Email] = ct.LastOnline
|
||||
}
|
||||
activeMap := time.Since(start)
|
||||
t.Logf("N=%-7d lastOnline: fullMap=%-9v activeMap(K=%d)=%v",
|
||||
n, fullMap.Round(time.Millisecond), k, activeMap.Round(time.Microsecond))
|
||||
|
||||
start = time.Now()
|
||||
if _, err := svc.GetInboundsTrafficSummary(); err != nil {
|
||||
t.Fatalf("GetInboundsTrafficSummary: %v", err)
|
||||
}
|
||||
t.Logf("N=%-7d inboundsSummary=%v", n, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1004,6 +1004,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
|
||||
// Removals first so changed handlers and port swaps never collide with
|
||||
// the additions that follow.
|
||||
for _, u := range diff.RemovedUsers {
|
||||
if err := hotAPI.RemoveUser(u.Tag, u.Email); err != nil && !xray.IsMissingHandlerErr(err) {
|
||||
logger.Info("hot apply: remove user [", u.Email, "] from [", u.Tag, "] failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, tag := range diff.RemovedInboundTags {
|
||||
if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
|
||||
logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
|
||||
@@ -1028,6 +1034,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, u := range diff.AddedUsers {
|
||||
if err := addUserReconciling(&hotAPI, u); err != nil {
|
||||
logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if diff.RoutingConfig != nil {
|
||||
if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
|
||||
logger.Info("hot apply: apply routing config failed:", err)
|
||||
@@ -1039,6 +1051,19 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// addUserReconciling adds a user, and on an email conflict (the user was
|
||||
// already applied through the runtime API) replaces the existing user instead.
|
||||
func addUserReconciling(api *xray.XrayAPI, u xray.UserOp) error {
|
||||
err := api.AddUser(u.Protocol, u.Tag, u.User)
|
||||
if err == nil || !xray.IsUserExistsErr(err) {
|
||||
return err
|
||||
}
|
||||
if delErr := api.RemoveUser(u.Tag, u.Email); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
|
||||
return delErr
|
||||
}
|
||||
return api.AddUser(u.Protocol, u.Tag, u.User)
|
||||
}
|
||||
|
||||
// addInboundReconciling adds an inbound, and on a tag conflict (the handler
|
||||
// was already created through the runtime API while the stored snapshot was
|
||||
// stale) replaces the existing handler instead.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestGetXrayConfigScale measures building the full Xray config (the path
|
||||
// every restart/reconcile takes) and, separately, how much of the per-inbound
|
||||
// settings rebuild is spent on indented vs plain JSON marshaling.
|
||||
func TestGetXrayConfigScale(t *testing.T) {
|
||||
setupScaleDB(t)
|
||||
svc := &XrayService{}
|
||||
shapes := []struct {
|
||||
name string
|
||||
inbounds int
|
||||
}{{"single", 1}, {"spread50", 50}}
|
||||
|
||||
for _, n := range scaleSizes(t, 10000, 100000) {
|
||||
for _, shape := range shapes {
|
||||
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
|
||||
ds := seedScaleDataset(t, n, shape.inbounds)
|
||||
|
||||
const reps = 3
|
||||
start := time.Now()
|
||||
for range reps {
|
||||
cfg, err := svc.GetXrayConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfig: %v", err)
|
||||
}
|
||||
if len(cfg.InboundConfigs) < shape.inbounds {
|
||||
t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
|
||||
}
|
||||
}
|
||||
t.Logf("N=%-7d shape=%-8s GetXrayConfig=%v/run",
|
||||
n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
|
||||
|
||||
var ib model.Inbound
|
||||
if err := database.GetDB().First(&ib, ds.inboundIds[0]).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
start = time.Now()
|
||||
if _, err := json.Marshal(settings); err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
plain := time.Since(start)
|
||||
start = time.Now()
|
||||
if _, err := json.MarshalIndent(settings, "", " "); err != nil {
|
||||
t.Fatalf("marshal indent settings: %v", err)
|
||||
}
|
||||
indented := time.Since(start)
|
||||
t.Logf("N=%-7d shape=%-8s settingsMarshal plain=%-9v indent=%v",
|
||||
n, shape.name, plain.Round(time.Millisecond), indented.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user