mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 17:15:24 +03:00
Compare commits
5 Commits
e08456269b
..
v3.2.8
| Author | SHA1 | Date | |
|---|---|---|---|
| db118cbcc9 | |||
| e7ffae5329 | |||
| f470bc7cf8 | |||
| a8d5d0dfab | |||
| b40f869f2a |
+66
-28
@@ -86,10 +86,11 @@ Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `a
|
|||||||
|
|
||||||
### Inside VS Code
|
### Inside VS Code
|
||||||
|
|
||||||
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
|
The repo checks in two VS Code launch profiles in `.vscode/launch.json`: **Run 3x-ui (Debug)** for the default SQLite setup, and **Run 3x-ui (Postgres)** which points `XUI_DB_TYPE`/`XUI_DB_DSN` at a local PostgreSQL. The Postgres profile also prepends the PostgreSQL `bin` to `PATH` so the panel can find `pg_dump`/`pg_restore` (the `postgresql-client` tools used for DB backup/restore) — adjust the DSN and that path to your machine:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
"$schema": "vscode://schemas/launch",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
@@ -106,6 +107,23 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
|
|||||||
"XUI_BIN_FOLDER": "x-ui"
|
"XUI_BIN_FOLDER": "x-ui"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Run 3x-ui (Postgres)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"env": {
|
||||||
|
"XUI_DEBUG": "true",
|
||||||
|
"XUI_LOG_FOLDER": "x-ui",
|
||||||
|
"XUI_BIN_FOLDER": "x-ui",
|
||||||
|
"XUI_DB_TYPE": "postgres",
|
||||||
|
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable",
|
||||||
|
"PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -117,14 +135,21 @@ The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
|
The frontend ships **three Vite bundles**, each emitted into `web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
|
||||||
|
|
||||||
|
- **`index.html`** — the admin panel, a **single-page app**. `src/main.tsx` mounts a `react-router` `createBrowserRouter` (see `src/routes.tsx`) under the `/panel` basename; every route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/groups`, `/panel/nodes`, `/panel/settings`, `/panel/xray`, `/panel/api-docs`) is lazy-loaded inside a shared `PanelLayout` (sidebar + header + `<Outlet>`).
|
||||||
|
- **`login.html`** — the login + 2FA screen (`src/entries/login.tsx`), a standalone bundle.
|
||||||
|
- **`subpage.html`** — the public subscription viewer (`src/entries/subpage.tsx`), a standalone bundle.
|
||||||
|
|
||||||
|
Panel navigation happens client-side through React Router, and per-route code is lazy-split so the initial panel load stays small. `login` and `subpage` stay separate documents because they are reached without an authenticated panel session.
|
||||||
|
|
||||||
### State and data flow
|
### State and data flow
|
||||||
|
|
||||||
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
|
- **Server state via TanStack Query.** API reads go through `@tanstack/react-query` (`QueryProvider` in `src/main.tsx`, keys in `src/api/queryKeys.ts`); responses are cached and invalidated on mutation rather than blindly re-fetched, and WebSocket pushes feed back into the cache via `src/api/websocketBridge.ts`.
|
||||||
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
|
- **Local UI state stays in the page** (`useState`); shared concerns go through contexts and hooks in `src/hooks/` (`useTheme`, `useWebSocket`, `useClients`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
|
||||||
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
|
- **Zod is the single source of truth.** Schemas in `src/schemas/` define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with `z.infer` — never hand-written. Go-side types are mirrored into `src/generated/` by `npm run gen:zod` (do not hand-edit that folder).
|
||||||
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
|
- **xray domain logic** — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in `src/lib/xray/`. `src/models/` keeps only thin legacy types still being migrated onto schemas.
|
||||||
|
- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.ts`.
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
@@ -134,21 +159,22 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
|
|||||||
|
|
||||||
| Goal | Command |
|
| Goal | Command |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
|
||||||
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
||||||
|
|
||||||
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
|
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
|
||||||
|
|
||||||
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
||||||
|
|
||||||
### Adding a new page
|
### Adding a new page
|
||||||
|
|
||||||
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
|
Most new screens are **admin-panel routes** and need no new HTML or Vite entry:
|
||||||
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
|
|
||||||
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
|
1. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
|
||||||
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
|
2. Register it in `src/routes.tsx` under the `/panel` tree (lazy-import it like the others).
|
||||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
|
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it should be reachable from the nav.
|
||||||
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
|
||||||
|
Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable without the panel shell) needs the full entry treatment: add `frontend/<page>.html`, a `src/entries/<page>.tsx` bootstrap, register it in `rollupOptions.input` inside `vite.config.js`, and wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
||||||
|
|
||||||
### Conventions
|
### Conventions
|
||||||
|
|
||||||
@@ -157,27 +183,40 @@ The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/pa
|
|||||||
- **Function components + hooks** everywhere. No class components.
|
- **Function components + hooks** everywhere. No class components.
|
||||||
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
|
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
|
||||||
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
|
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
|
||||||
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
|
- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
||||||
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
|
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
||||||
|
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
||||||
|
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||||
|
|
||||||
### Project layout
|
### Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
├── *.html — Vite entry HTML, one per panel route
|
├── index.html — admin panel SPA entry
|
||||||
|
├── login.html — login + 2FA entry
|
||||||
|
├── subpage.html — public subscription viewer entry
|
||||||
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
|
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
|
||||||
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
|
├── eslint.config.js — ESLint flat config (@eslint/js + typescript-eslint + react-hooks)
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
|
├── vitest.config.ts
|
||||||
|
├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json)
|
||||||
└── src/
|
└── src/
|
||||||
├── entries/ — per-page bootstrap (createRoot + render)
|
├── main.tsx — admin SPA bootstrap (router + providers)
|
||||||
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
|
├── routes.tsx — react-router routes mounted under /panel
|
||||||
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
|
├── entries/ — bootstrap for the standalone bundles (login, subpage)
|
||||||
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
|
├── layouts/ — PanelLayout + AppSidebar
|
||||||
├── api/ — Axios setup + CSRF interceptor + WebSocket client
|
├── pages/ — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub
|
||||||
|
├── components/ — cross-page React components
|
||||||
|
├── hooks/ — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
|
||||||
|
├── api/ — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
|
||||||
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
|
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
|
||||||
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
|
├── lib/xray/ — pure xray logic: link generation, defaults, form ⇄ wire adapters
|
||||||
|
├── schemas/ — Zod source of truth for the xray config model
|
||||||
|
├── generated/ — code-generated Zod + TS types from Go (do not hand-edit)
|
||||||
|
├── models/ — thin legacy types still being migrated
|
||||||
├── styles/ — shared CSS (page-cards, …)
|
├── styles/ — shared CSS (page-cards, …)
|
||||||
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
|
├── test/ — Vitest specs + golden fixtures
|
||||||
|
└── utils/ — HttpUtil, ClipboardManager, SizeFormatter, …
|
||||||
```
|
```
|
||||||
|
|
||||||
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
|
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
|
||||||
@@ -202,7 +241,7 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
|||||||
3. Run the relevant checks before pushing:
|
3. Run the relevant checks before pushing:
|
||||||
- `go build ./...`
|
- `go build ./...`
|
||||||
- `go test ./...` (when Go code changed)
|
- `go test ./...` (when Go code changed)
|
||||||
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
|
- `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
|
||||||
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
|
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
|
||||||
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||||
|
|
||||||
@@ -218,9 +257,8 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
|||||||
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||||
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
||||||
|
|
||||||
## Issues and discussion
|
## Issues
|
||||||
|
|
||||||
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
||||||
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
|
|
||||||
|
|
||||||
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
|
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.7
|
3.2.8
|
||||||
@@ -396,6 +396,9 @@ type Node struct {
|
|||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
|
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||||
|
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||||
|
|
||||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||||
ClientCount int `json:"clientCount" gorm:"-"`
|
ClientCount int `json:"clientCount" gorm:"-"`
|
||||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||||
|
|||||||
@@ -322,6 +322,8 @@ export interface Node {
|
|||||||
apiToken: string;
|
apiToken: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
|
configDirty: boolean;
|
||||||
|
configDirtyAt: number;
|
||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
depletedCount: number;
|
depletedCount: number;
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ export const NodeSchema = z.object({
|
|||||||
apiToken: z.string(),
|
apiToken: z.string(),
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clientCount: z.number().int(),
|
clientCount: z.number().int(),
|
||||||
|
configDirty: z.boolean(),
|
||||||
|
configDirtyAt: z.number().int(),
|
||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
depletedCount: z.number().int(),
|
depletedCount: z.number().int(),
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ function applyExternalProxyTLSObj(
|
|||||||
if (alpn.length > 0) obj.alpn = alpn;
|
if (alpn.length > 0) obj.alpn = alpn;
|
||||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||||
if (pins.length > 0) obj.pcs = pins;
|
if (pins.length > 0) obj.pcs = pins;
|
||||||
|
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenVmessLinkInput {
|
export interface GenVmessLinkInput {
|
||||||
@@ -280,6 +281,7 @@ function applyExternalProxyTLSParams(
|
|||||||
if (alpn.length > 0) params.set('alpn', alpn);
|
if (alpn.length > 0) params.set('alpn', alpn);
|
||||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||||
if (pins.length > 0) params.set('pcs', pins);
|
if (pins.length > 0) params.set('pcs', pins);
|
||||||
|
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenVlessLinkInput {
|
export interface GenVlessLinkInput {
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
|
|||||||
tls.fingerprint = params.get('fp') ?? '';
|
tls.fingerprint = params.get('fp') ?? '';
|
||||||
const alpn = params.get('alpn');
|
const alpn = params.get('alpn');
|
||||||
if (alpn) tls.alpn = alpn.split(',');
|
if (alpn) tls.alpn = alpn.split(',');
|
||||||
|
tls.echConfigList = params.get('ech') ?? '';
|
||||||
|
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
|
||||||
} else if (stream.security === 'reality') {
|
} else if (stream.security === 'reality') {
|
||||||
const reality = stream.realitySettings as Raw;
|
const reality = stream.realitySettings as Raw;
|
||||||
reality.serverName = params.get('sni') ?? '';
|
reality.serverName = params.get('sni') ?? '';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const newEntry = () => ({
|
|||||||
fingerprint: '',
|
fingerprint: '',
|
||||||
alpn: [],
|
alpn: [],
|
||||||
pinnedPeerCertSha256: [],
|
pinnedPeerCertSha256: [],
|
||||||
|
echConfigList: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||||
@@ -92,9 +93,9 @@ export default function ExternalProxyForm({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('host')}>
|
<Field label={t('pages.inbounds.address')}>
|
||||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||||
<Input placeholder={t('host')} />
|
<Input placeholder={t('pages.inbounds.address')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('pages.inbounds.port')}>
|
<Field label={t('pages.inbounds.port')}>
|
||||||
@@ -125,7 +126,7 @@ export default function ExternalProxyForm({
|
|||||||
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
||||||
<Field label="SNI">
|
<Field label="SNI">
|
||||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||||
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('pages.inbounds.form.fingerprint')}>
|
<Field label={t('pages.inbounds.form.fingerprint')}>
|
||||||
@@ -157,6 +158,11 @@ export default function ExternalProxyForm({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label={t('pages.inbounds.form.echConfig')}>
|
||||||
|
<Form.Item name={[field.name, 'echConfigList']} noStyle>
|
||||||
|
<Input placeholder={t('pages.inbounds.form.echConfig')} />
|
||||||
|
</Form.Item>
|
||||||
|
</Field>
|
||||||
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({
|
|||||||
),
|
),
|
||||||
alpn: z.array(AlpnSchema).optional(),
|
alpn: z.array(AlpnSchema).optional(),
|
||||||
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
||||||
|
echConfigList: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
||||||
|
|||||||
@@ -360,6 +360,21 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
|||||||
const stream = parsed!.streamSettings as Record<string, unknown>;
|
const stream = parsed!.streamSettings as Record<string, unknown>;
|
||||||
expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
|
expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips ech and pcs from a TLS vless link', () => {
|
||||||
|
const ech = 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
|
||||||
|
const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
|
||||||
|
const link = 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?'
|
||||||
|
+ 'alpn=h3&ech=' + encodeURIComponent(ech) + '&encryption=none&fp=firefox&host=&'
|
||||||
|
+ 'mode=packet-up&path=%2F&pcs=' + pcs + '&security=tls&sni=123&type=xhttp#i5sboxj07w';
|
||||||
|
const parsed = parseVlessLink(link);
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
|
||||||
|
expect(tls.echConfigList).toBe(ech);
|
||||||
|
expect(tls.pinnedPeerCertSha256).toBe(pcs);
|
||||||
|
expect(tls.serverName).toBe('123');
|
||||||
|
expect(tls.fingerprint).toBe('firefox');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseWireguardLink', () => {
|
describe('parseWireguardLink', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import i18next from 'i18next';
|
||||||
import { getMessage } from './messageBus';
|
import { getMessage } from './messageBus';
|
||||||
|
|
||||||
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
||||||
@@ -32,6 +33,14 @@ export class HttpUtil {
|
|||||||
}
|
}
|
||||||
const messageType = msg.success ? 'success' : 'error';
|
const messageType = msg.success ? 'success' : 'error';
|
||||||
getMessage()[messageType](msg.msg);
|
getMessage()[messageType](msg.msg);
|
||||||
|
if (
|
||||||
|
msg.success &&
|
||||||
|
msg.obj &&
|
||||||
|
typeof msg.obj === 'object' &&
|
||||||
|
(msg.obj as { nodePending?: unknown }).nodePending === true
|
||||||
|
) {
|
||||||
|
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
||||||
|
|||||||
@@ -1053,6 +1053,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
|
|||||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
obj["pcs"] = joinAnyStrings(pins)
|
obj["pcs"] = joinAnyStrings(pins)
|
||||||
}
|
}
|
||||||
|
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||||
|
obj["ech"] = ech
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
||||||
@@ -1071,6 +1074,9 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
|
|||||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
params["pcs"] = joinAnyStrings(pins)
|
params["pcs"] = joinAnyStrings(pins)
|
||||||
}
|
}
|
||||||
|
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||||
|
params["ech"] = ech
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyExternalProxyHysteriaParams overrides the cert pin for a single
|
// applyExternalProxyHysteriaParams overrides the cert pin for a single
|
||||||
@@ -1143,6 +1149,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
|
|||||||
}
|
}
|
||||||
settings["pinnedPeerCertSha256"] = pins
|
settings["pinnedPeerCertSha256"] = pins
|
||||||
}
|
}
|
||||||
|
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||||
|
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||||
|
if settings == nil {
|
||||||
|
settings = map[string]any{}
|
||||||
|
tlsSettings["settings"] = settings
|
||||||
|
}
|
||||||
|
settings["echConfigList"] = ech
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalProxySNI(ep map[string]any) (string, bool) {
|
func externalProxySNI(ep map[string]any) (string, bool) {
|
||||||
|
|||||||
@@ -575,6 +575,47 @@ func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
|
||||||
|
const ech = "ech-config-base64"
|
||||||
|
|
||||||
|
t.Run("url params", func(t *testing.T) {
|
||||||
|
params := map[string]string{"security": "tls"}
|
||||||
|
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||||
|
applyExternalProxyTLSParams(ep, params, "tls")
|
||||||
|
if params["ech"] != ech {
|
||||||
|
t.Fatalf("ech param = %q, want %q", params["ech"], ech)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("vmess obj", func(t *testing.T) {
|
||||||
|
obj := map[string]any{}
|
||||||
|
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||||
|
applyExternalProxyTLSObj(ep, obj, "tls")
|
||||||
|
if obj["ech"] != ech {
|
||||||
|
t.Fatalf("ech obj = %v, want %q", obj["ech"], ech)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("json stream settings", func(t *testing.T) {
|
||||||
|
stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
|
||||||
|
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||||
|
applyExternalProxyTLSToStream(ep, stream, "tls")
|
||||||
|
settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
|
||||||
|
if settings["echConfigList"] != ech {
|
||||||
|
t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-tls security drops ech", func(t *testing.T) {
|
||||||
|
params := map[string]string{}
|
||||||
|
ep := map[string]any{"echConfigList": ech}
|
||||||
|
applyExternalProxyTLSParams(ep, params, "none")
|
||||||
|
if _, ok := params["ech"]; ok {
|
||||||
|
t.Fatalf("ech must not be set when security != tls")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||||
stream := map[string]any{
|
stream := map[string]any{
|
||||||
"security": "tls",
|
"security": "tls",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (a *ClientController) create(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ func (a *ClientController) update(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
@@ -190,7 +190,7 @@ func (a *ClientController) attach(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
@@ -470,7 +470,7 @@ func (a *ClientController) detach(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
|||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingNodeObj returns a response object flagging that the save committed
|
||||||
|
// locally but a backing node was offline/disabled, so the change will be
|
||||||
|
// mirrored to the node once it reconnects. Returns nil when nothing is pending.
|
||||||
|
func pendingNodeObj(pending bool) any {
|
||||||
|
if pending {
|
||||||
|
return gin.H{"nodePending": true}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// pureJsonMsg sends a pure JSON message response with custom status code.
|
// pureJsonMsg sends a pure JSON message response with custom status code.
|
||||||
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
||||||
c.JSON(statusCode, entity.Msg{
|
c.JSON(statusCode, entity.Msg{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
nodeTrafficSyncConcurrency = 8
|
nodeTrafficSyncConcurrency = 8
|
||||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||||
|
nodeReconcileTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type NodeTrafficSyncJob struct {
|
type NodeTrafficSyncJob struct {
|
||||||
@@ -151,21 +152,37 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
rt, err := mgr.RemoteFor(n)
|
rt, err := mgr.RemoteFor(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if n.ConfigDirty {
|
||||||
|
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
|
||||||
|
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
|
||||||
|
reconcileCancel()
|
||||||
|
if reconcileErr != nil {
|
||||||
|
logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
|
||||||
|
logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
|
||||||
|
}
|
||||||
|
j.structural.set()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
snap, err := rt.FetchTrafficSnapshot(ctx)
|
snap, err := rt.FetchTrafficSnapshot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
||||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap)
|
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||||
|
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -191,6 +191,19 @@ func (r *Remote) cacheDel(tag string) {
|
|||||||
delete(r.remoteIDByTag, tag)
|
delete(r.remoteIDByTag, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
|
||||||
|
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
tags := make([]string, 0, len(r.remoteIDByTag))
|
||||||
|
for tag := range r.remoteIDByTag {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+217
-127
@@ -547,6 +547,17 @@ func validateClientSubID(subID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) bool {
|
||||||
|
if strings.TrimSpace(email) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ids, err := s.GetInboundIdsForEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return inboundSvc.AnyNodePending(ids)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return false, common.NewError("empty payload")
|
return false, common.NewError("empty payload")
|
||||||
@@ -1290,6 +1301,7 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
|||||||
}
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
for _, r := range removed {
|
for _, r := range removed {
|
||||||
email := r.email
|
email := r.email
|
||||||
emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
|
emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
|
||||||
@@ -1324,12 +1336,18 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if oldInbound.NodeID != nil && len(email) > 0 {
|
if oldInbound.NodeID != nil && len(email) > 0 {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
return needRestart, rterr
|
return needRestart, perr
|
||||||
}
|
}
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
if dirty {
|
||||||
return needRestart, err1
|
markDirty = true
|
||||||
|
}
|
||||||
|
if push {
|
||||||
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1344,6 +1362,11 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
|||||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||||
return needRestart, err
|
return needRestart, err
|
||||||
}
|
}
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2722,27 +2745,33 @@ func (s *ClientService) bulkAdjustInboundClients(
|
|||||||
}
|
}
|
||||||
oldInbound.Settings = string(newSettings)
|
oldInbound.Settings = string(newSettings)
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
if oldInbound.NodeID != nil {
|
if oldInbound.NodeID != nil {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
for email := range foundEmails {
|
for email := range foundEmails {
|
||||||
res.perEmailSkipped[email] = rterr.Error()
|
res.perEmailSkipped[email] = perr.Error()
|
||||||
delete(foundEmails, email)
|
delete(foundEmails, email)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for email := range foundEmails {
|
if dirty {
|
||||||
entry := plan[email]
|
markDirty = true
|
||||||
updated := *entry.record.ToClient()
|
}
|
||||||
if entry.applyExpiry {
|
if push {
|
||||||
updated.ExpiryTime = entry.newExpiry
|
for email := range foundEmails {
|
||||||
}
|
entry := plan[email]
|
||||||
if entry.applyTotal {
|
updated := *entry.record.ToClient()
|
||||||
updated.TotalGB = entry.newTotal
|
if entry.applyExpiry {
|
||||||
}
|
updated.ExpiryTime = entry.newExpiry
|
||||||
updated.UpdatedAt = nowMs
|
}
|
||||||
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
|
if entry.applyTotal {
|
||||||
res.perEmailSkipped[email] = err1.Error()
|
updated.TotalGB = entry.newTotal
|
||||||
delete(foundEmails, email)
|
}
|
||||||
|
updated.UpdatedAt = nowMs
|
||||||
|
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
|
||||||
|
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2765,6 +2794,10 @@ func (s *ClientService) bulkAdjustInboundClients(
|
|||||||
res.perEmailSkipped[email] = txErr.Error()
|
res.perEmailSkipped[email] = txErr.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
@@ -3083,6 +3116,7 @@ func (s *ClientService) bulkDelInboundClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
if oldInbound.NodeID == nil {
|
if oldInbound.NodeID == nil {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
||||||
if rterr != nil {
|
if rterr != nil {
|
||||||
@@ -3104,17 +3138,22 @@ func (s *ClientService) bulkDelInboundClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
for email := range foundEmails {
|
for email := range foundEmails {
|
||||||
res.perEmailSkipped[email] = rterr.Error()
|
res.perEmailSkipped[email] = perr.Error()
|
||||||
delete(foundEmails, email)
|
delete(foundEmails, email)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for email := range foundEmails {
|
if dirty {
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
markDirty = true
|
||||||
res.perEmailSkipped[email] = err1.Error()
|
}
|
||||||
delete(foundEmails, email)
|
if push {
|
||||||
|
for email := range foundEmails {
|
||||||
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3136,6 +3175,10 @@ func (s *ClientService) bulkDelInboundClients(
|
|||||||
res.perEmailSkipped[email] = txErr.Error()
|
res.perEmailSkipped[email] = txErr.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
@@ -3608,50 +3651,61 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
|||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
tx.Commit()
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
return false, err
|
||||||
return false, err
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else if oldInbound.NodeID == nil {
|
}
|
||||||
for _, client := range clients {
|
if oldInbound.NodeID == nil {
|
||||||
if len(client.Email) == 0 {
|
if !push {
|
||||||
needRestart = true
|
needRestart = true
|
||||||
continue
|
} else {
|
||||||
}
|
for _, client := range clients {
|
||||||
inboundSvc.AddClientStat(tx, data.Id, &client)
|
if len(client.Email) == 0 {
|
||||||
if !client.Enable {
|
needRestart = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cipher := ""
|
inboundSvc.AddClientStat(tx, data.Id, &client)
|
||||||
if oldInbound.Protocol == "shadowsocks" {
|
if !client.Enable {
|
||||||
cipher = oldSettings["method"].(string)
|
continue
|
||||||
}
|
}
|
||||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
cipher := ""
|
||||||
"email": client.Email,
|
if oldInbound.Protocol == "shadowsocks" {
|
||||||
"id": client.ID,
|
cipher = oldSettings["method"].(string)
|
||||||
"auth": client.Auth,
|
}
|
||||||
"security": client.Security,
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||||
"flow": client.Flow,
|
"email": client.Email,
|
||||||
"password": client.Password,
|
"id": client.ID,
|
||||||
"cipher": cipher,
|
"auth": client.Auth,
|
||||||
})
|
"security": client.Security,
|
||||||
if err1 == nil {
|
"flow": client.Flow,
|
||||||
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
"password": client.Password,
|
||||||
} else {
|
"cipher": cipher,
|
||||||
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
})
|
||||||
needRestart = true
|
if err1 == nil {
|
||||||
|
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3659,9 +3713,12 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
|||||||
if len(client.Email) > 0 {
|
if len(client.Email) > 0 {
|
||||||
inboundSvc.AddClientStat(tx, data.Id, &client)
|
inboundSvc.AddClientStat(tx, data.Id, &client)
|
||||||
}
|
}
|
||||||
if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
|
if push {
|
||||||
err = err1
|
if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
|
||||||
return false, err
|
logger.Warning("Error in adding client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
push = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3839,11 +3896,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
tx.Commit()
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -3903,50 +3966,55 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
}
|
}
|
||||||
needRestart := false
|
needRestart := false
|
||||||
if len(oldEmail) > 0 {
|
if len(oldEmail) > 0 {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
return false, err
|
||||||
return false, err
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else if oldInbound.NodeID == nil {
|
}
|
||||||
if oldClients[clientIndex].Enable {
|
if oldInbound.NodeID == nil {
|
||||||
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
|
if !push {
|
||||||
if err1 == nil {
|
needRestart = true
|
||||||
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
|
} else {
|
||||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
|
if oldClients[clientIndex].Enable {
|
||||||
logger.Debug("User is already deleted. Nothing to do more...")
|
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
|
||||||
} else {
|
if err1 == nil {
|
||||||
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
|
||||||
needRestart = true
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
|
||||||
|
logger.Debug("User is already deleted. Nothing to do more...")
|
||||||
|
} else {
|
||||||
|
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clients[0].Enable {
|
||||||
|
cipher := ""
|
||||||
|
if oldInbound.Protocol == "shadowsocks" {
|
||||||
|
cipher = oldSettings["method"].(string)
|
||||||
|
}
|
||||||
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||||
|
"email": clients[0].Email,
|
||||||
|
"id": clients[0].ID,
|
||||||
|
"security": clients[0].Security,
|
||||||
|
"flow": clients[0].Flow,
|
||||||
|
"auth": clients[0].Auth,
|
||||||
|
"password": clients[0].Password,
|
||||||
|
"cipher": cipher,
|
||||||
|
})
|
||||||
|
if err1 == nil {
|
||||||
|
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if clients[0].Enable {
|
} else if push {
|
||||||
cipher := ""
|
|
||||||
if oldInbound.Protocol == "shadowsocks" {
|
|
||||||
cipher = oldSettings["method"].(string)
|
|
||||||
}
|
|
||||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
|
||||||
"email": clients[0].Email,
|
|
||||||
"id": clients[0].ID,
|
|
||||||
"security": clients[0].Security,
|
|
||||||
"flow": clients[0].Flow,
|
|
||||||
"auth": clients[0].Auth,
|
|
||||||
"password": clients[0].Password,
|
|
||||||
"cipher": cipher,
|
|
||||||
})
|
|
||||||
if err1 == nil {
|
|
||||||
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
|
||||||
} else {
|
|
||||||
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
|
||||||
needRestart = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
||||||
err = err1
|
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
||||||
return false, err
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -4038,6 +4106,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
|
|
||||||
if len(email) > 0 {
|
if len(email) > 0 {
|
||||||
var enables []bool
|
var enables []bool
|
||||||
@@ -4073,12 +4142,18 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if oldInbound.NodeID != nil && len(email) > 0 {
|
if oldInbound.NodeID != nil && len(email) > 0 {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
return false, rterr
|
return false, perr
|
||||||
}
|
}
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
if dirty {
|
||||||
return false, err1
|
markDirty = true
|
||||||
|
}
|
||||||
|
if push {
|
||||||
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := db.Save(oldInbound).Error; err != nil {
|
if err := db.Save(oldInbound).Error; err != nil {
|
||||||
@@ -4091,6 +4166,11 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
|||||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4159,6 +4239,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
|||||||
}
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
|
|
||||||
if len(email) > 0 && !emailShared {
|
if len(email) > 0 && !emailShared {
|
||||||
if !keepTraffic {
|
if !keepTraffic {
|
||||||
@@ -4175,25 +4256,29 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if needApiDel {
|
if needApiDel {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
return false, perr
|
||||||
return false, rterr
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else if oldInbound.NodeID == nil {
|
}
|
||||||
if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
|
if oldInbound.NodeID == nil {
|
||||||
|
if !push {
|
||||||
|
needRestart = true
|
||||||
|
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
|
||||||
logger.Debug("Client deleted on", rt.Name(), ":", email)
|
logger.Debug("Client deleted on", rt.Name(), ":", email)
|
||||||
needRestart = false
|
needRestart = false
|
||||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
||||||
logger.Debug("User is already deleted. Nothing to do more...")
|
logger.Debug("User is already deleted. Nothing to do more...")
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
logger.Debug("Error in deleting client on", rt.Name(), ":", email)
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
} else {
|
} else if push {
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
return false, err1
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4209,6 +4294,11 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
|||||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc := InboundService{}
|
svc := InboundService{}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+241
-82
@@ -41,6 +41,92 @@ func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error)
|
|||||||
return mgr.RuntimeFor(ib.NodeID)
|
return mgr.RuntimeFor(ib.NodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) nodePushPlan(ib *model.Inbound) (runtime.Runtime, bool, bool, error) {
|
||||||
|
if ib.NodeID == nil {
|
||||||
|
rt, err := s.runtimeFor(ib)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, nil
|
||||||
|
}
|
||||||
|
return rt, true, false, nil
|
||||||
|
}
|
||||||
|
nodeSvc := NodeService{}
|
||||||
|
enabled, status, _, _, err := nodeSvc.NodeSyncState(*ib.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, err
|
||||||
|
}
|
||||||
|
if !enabled || status == "offline" {
|
||||||
|
return nil, false, true, nil
|
||||||
|
}
|
||||||
|
rt, err := s.runtimeFor(ib)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, true, nil
|
||||||
|
}
|
||||||
|
return rt, true, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) NodeIsPending(nodeID *int) bool {
|
||||||
|
if nodeID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (&NodeService{}).IsNodePending(*nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) AnyNodePending(inboundIds []int) bool {
|
||||||
|
if len(inboundIds) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nodeSvc := NodeService{}
|
||||||
|
for _, id := range inboundIds {
|
||||||
|
ib, err := s.GetInbound(id)
|
||||||
|
if err != nil || ib.NodeID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nodeSvc.IsNodePending(*ib.NodeID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
|
||||||
|
if rt == nil || nodeID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
var inbounds []*model.Inbound
|
||||||
|
if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteTags, err := rt.ListRemoteTags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prefix := nodeTagPrefix(&nodeID)
|
||||||
|
desiredTags := make(map[string]struct{}, len(inbounds)*2)
|
||||||
|
for _, ib := range inbounds {
|
||||||
|
desiredTags[ib.Tag] = struct{}{}
|
||||||
|
if prefix != "" {
|
||||||
|
if stripped, found := strings.CutPrefix(ib.Tag, prefix); found {
|
||||||
|
desiredTags[stripped] = struct{}{}
|
||||||
|
} else {
|
||||||
|
desiredTags[prefix+ib.Tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rt.UpdateInbound(ctx, ib, ib); err != nil {
|
||||||
|
return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range remoteTags {
|
||||||
|
if _, want := desiredTags[tag]; want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
|
||||||
|
return fmt.Errorf("reconcile delete %q: %w", tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type CopyClientsResult struct {
|
type CopyClientsResult struct {
|
||||||
Added []string `json:"added"`
|
Added []string `json:"added"`
|
||||||
Skipped []string `json:"skipped"`
|
Skipped []string `json:"skipped"`
|
||||||
@@ -575,11 +661,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err != nil {
|
||||||
tx.Commit()
|
|
||||||
} else {
|
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && inbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -600,20 +692,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
if inbound.Enable {
|
if inbound.Enable {
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
err = rterr
|
err = perr
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
if dirty {
|
||||||
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
markDirty = true
|
||||||
} else {
|
}
|
||||||
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
if push {
|
||||||
if inbound.NodeID != nil {
|
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
||||||
err = err1
|
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
||||||
return inbound, false, err
|
} else {
|
||||||
|
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
||||||
|
if inbound.NodeID != nil {
|
||||||
|
markDirty = true
|
||||||
|
} else {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
needRestart = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,24 +721,31 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
|||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
var ib model.Inbound
|
var ib model.Inbound
|
||||||
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
|
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
|
||||||
if shouldPushToRuntime {
|
if shouldPushToRuntime {
|
||||||
rt, rterr := s.runtimeFor(&ib)
|
rt, push, dirty, perr := s.nodePushPlan(&ib)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr)
|
logger.Warning("DelInbound: node lookup failed, deleting central row anyway:", perr)
|
||||||
if ib.NodeID == nil {
|
markDirty = true
|
||||||
needRestart = true
|
} else if push {
|
||||||
}
|
if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
||||||
} else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
||||||
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
} else {
|
||||||
} else {
|
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
|
||||||
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
|
if ib.NodeID == nil {
|
||||||
if ib.NodeID == nil {
|
needRestart = true
|
||||||
needRestart = true
|
} else {
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if ib.NodeID == nil {
|
||||||
|
needRestart = true
|
||||||
|
} else if dirty {
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
|
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
|
||||||
@@ -657,6 +761,11 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
|||||||
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
|
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
|
||||||
return needRestart, err
|
return needRestart, err
|
||||||
}
|
}
|
||||||
|
if markDirty && ib.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !database.IsPostgres() {
|
if !database.IsPostgres() {
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
||||||
@@ -740,12 +849,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
|||||||
inbound.Enable = enable
|
inbound.Enable = enable
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if inbound.NodeID != nil {
|
return false, perr
|
||||||
return false, rterr
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote nodes interpret DelInbound as a real row delete (it hits
|
// Remote nodes interpret DelInbound as a real row delete (it hits
|
||||||
@@ -754,13 +860,24 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
|||||||
// PATCH the remote row via UpdateInbound instead — preserves the
|
// PATCH the remote row via UpdateInbound instead — preserves the
|
||||||
// settings/client history and just flips the enable flag.
|
// settings/client history and just flips the enable flag.
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
if push {
|
||||||
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||||
return false, err
|
logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dirty {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !push {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
||||||
!strings.Contains(err.Error(), "not found") {
|
!strings.Contains(err.Error(), "not found") {
|
||||||
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
|
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
|
||||||
@@ -807,11 +924,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
tx.Commit()
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -900,17 +1023,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
inbound.Tag = oldInbound.Tag
|
inbound.Tag = oldInbound.Tag
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := s.runtimeFor(oldInbound)
|
rt, push, dirty, perr := s.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
return inbound, false, err
|
||||||
return inbound, false, err
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else {
|
}
|
||||||
oldSnapshot := *oldInbound
|
if oldInbound.NodeID == nil {
|
||||||
oldSnapshot.Tag = tag
|
if !push {
|
||||||
if oldInbound.NodeID == nil {
|
needRestart = true
|
||||||
|
} else {
|
||||||
|
oldSnapshot := *oldInbound
|
||||||
|
oldSnapshot.Tag = tag
|
||||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
||||||
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
||||||
}
|
}
|
||||||
@@ -926,16 +1052,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if !inbound.Enable {
|
} else if push {
|
||||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
oldSnapshot := *oldInbound
|
||||||
err = err2
|
oldSnapshot.Tag = tag
|
||||||
return inbound, false, err
|
if !inbound.Enable {
|
||||||
}
|
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
||||||
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
|
||||||
err = err2
|
markDirty = true
|
||||||
return inbound, false, err
|
|
||||||
}
|
}
|
||||||
|
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
||||||
|
logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1303,17 +1431,17 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
|
|||||||
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||||
var structuralChange bool
|
var structuralChange bool
|
||||||
err := submitTrafficWrite(func() error {
|
err := submitTrafficWrite(func() error {
|
||||||
var inner error
|
var inner error
|
||||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap)
|
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty)
|
||||||
return inner
|
return inner
|
||||||
})
|
})
|
||||||
return structuralChange, err
|
return structuralChange, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||||
if snap == nil || nodeID <= 0 {
|
if snap == nil || nodeID <= 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1425,6 +1553,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
|
|
||||||
c, ok := tagToCentral[snapIb.Tag]
|
c, ok := tagToCentral[snapIb.Tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Try snap.Tag first; on collision fall back to the n<id>-
|
// Try snap.Tag first; on collision fall back to the n<id>-
|
||||||
// prefixed form so local+node can both own the same port.
|
// prefixed form so local+node can both own the same port.
|
||||||
pickFreeTag := func() (string, error) {
|
pickFreeTag := func() (string, error) {
|
||||||
@@ -1491,42 +1622,48 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
|
|
||||||
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||||
|
|
||||||
updates := map[string]any{
|
updates := map[string]any{}
|
||||||
"enable": snapIb.Enable,
|
if !dirty {
|
||||||
"remark": snapIb.Remark,
|
updates["enable"] = snapIb.Enable
|
||||||
"listen": snapIb.Listen,
|
updates["remark"] = snapIb.Remark
|
||||||
"port": snapIb.Port,
|
updates["listen"] = snapIb.Listen
|
||||||
"protocol": snapIb.Protocol,
|
updates["port"] = snapIb.Port
|
||||||
"total": snapIb.Total,
|
updates["protocol"] = snapIb.Protocol
|
||||||
"expiry_time": snapIb.ExpiryTime,
|
updates["total"] = snapIb.Total
|
||||||
"settings": snapIb.Settings,
|
updates["expiry_time"] = snapIb.ExpiryTime
|
||||||
"stream_settings": snapIb.StreamSettings,
|
updates["settings"] = snapIb.Settings
|
||||||
"sniffing": snapIb.Sniffing,
|
updates["stream_settings"] = snapIb.StreamSettings
|
||||||
"traffic_reset": snapIb.TrafficReset,
|
updates["sniffing"] = snapIb.Sniffing
|
||||||
|
updates["traffic_reset"] = snapIb.TrafficReset
|
||||||
}
|
}
|
||||||
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
||||||
updates["up"] = snapIb.Up
|
updates["up"] = snapIb.Up
|
||||||
updates["down"] = snapIb.Down
|
updates["down"] = snapIb.Down
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Settings != snapIb.Settings ||
|
if !dirty && (c.Settings != snapIb.Settings ||
|
||||||
c.Remark != snapIb.Remark ||
|
c.Remark != snapIb.Remark ||
|
||||||
c.Listen != snapIb.Listen ||
|
c.Listen != snapIb.Listen ||
|
||||||
c.Port != snapIb.Port ||
|
c.Port != snapIb.Port ||
|
||||||
c.Total != snapIb.Total ||
|
c.Total != snapIb.Total ||
|
||||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||||
c.Enable != snapIb.Enable {
|
c.Enable != snapIb.Enable) {
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Model(model.Inbound{}).
|
if len(updates) > 0 {
|
||||||
Where("id = ?", c.Id).
|
if err := tx.Model(model.Inbound{}).
|
||||||
Updates(updates).Error; err != nil {
|
Where("id = ?", c.Id).
|
||||||
return false, err
|
Updates(updates).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range central {
|
for _, c := range central {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, kept := snapTags[c.Tag]; kept {
|
if _, kept := snapTags[c.Tag]; kept {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1581,6 +1718,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
row := &xray.ClientTraffic{
|
row := &xray.ClientTraffic{
|
||||||
InboundId: c.Id,
|
InboundId: c.Id,
|
||||||
Email: cs.Email,
|
Email: cs.Email,
|
||||||
@@ -1642,6 +1782,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
|
|
||||||
for k, existing := range centralCS {
|
for k, existing := range centralCS {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if k.inboundID != c.Id {
|
if k.inboundID != c.Id {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1673,6 +1816,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var oldEmailsRows []string
|
var oldEmailsRows []string
|
||||||
if err := tx.Table("clients").
|
if err := tx.Table("clients").
|
||||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||||
@@ -2674,12 +2820,20 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
}
|
}
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.Email == clientEmail && client.Enable {
|
if client.Email == clientEmail && client.Enable {
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
|
return false, perr
|
||||||
|
}
|
||||||
|
if !push {
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
return false, rterr
|
if dirty {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
needRestart = true
|
||||||
}
|
}
|
||||||
needRestart = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cipher := ""
|
cipher := ""
|
||||||
@@ -2702,6 +2856,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
})
|
})
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
|
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
|
||||||
|
} else if inbound.NodeID != nil {
|
||||||
|
logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
||||||
needRestart = true
|
needRestart = true
|
||||||
|
|||||||
@@ -480,6 +480,50 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) MarkNodeDirty(id int) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return database.GetDB().Model(model.Node{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"config_dirty": true,
|
||||||
|
"config_dirty_at": time.Now().UnixMilli(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return database.GetDB().Model(model.Node{}).
|
||||||
|
Where("id = ? AND config_dirty_at = ?", id, dirtyAt).
|
||||||
|
Update("config_dirty", false).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return false, "", false, 0, errors.New("invalid node id")
|
||||||
|
}
|
||||||
|
var row model.Node
|
||||||
|
err = database.GetDB().Model(model.Node{}).
|
||||||
|
Select("enable", "status", "config_dirty", "config_dirty_at").
|
||||||
|
Where("id = ?", id).
|
||||||
|
First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, "", false, 0, err
|
||||||
|
}
|
||||||
|
return row.Enable, row.Status, row.ConfigDirty, row.ConfigDirtyAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) IsNodePending(id int) bool {
|
||||||
|
enabled, status, dirty, _, err := s.NodeSyncState(id)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !enabled || status != "online" || dirty
|
||||||
|
}
|
||||||
|
|
||||||
func nodeMetricKey(id int, metric string) string {
|
func nodeMetricKey(id int, metric string) string {
|
||||||
return "node:" + strconv.Itoa(id) + ":" + metric
|
return "node:" + strconv.Itoa(id) + ":" + metric
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats .
|
|||||||
snap := &runtime.TrafficSnapshot{
|
snap := &runtime.TrafficSnapshot{
|
||||||
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
||||||
}
|
}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// While a node is config-dirty (a local edit committed before it could be
|
||||||
|
// mirrored to the node), the traffic pull must not overwrite the central
|
||||||
|
// inbound's config columns from the node's stale snapshot — only traffic
|
||||||
|
// counters may advance. Otherwise a reconnecting node reverts the edit.
|
||||||
|
func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||||
|
if err := db.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
id := node.Id
|
||||||
|
|
||||||
|
const desiredSettings = `{"clients":[{"email":"a@x"}]}`
|
||||||
|
central := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
NodeID: &id,
|
||||||
|
Tag: "in-443-tcp",
|
||||||
|
Enable: true,
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: desiredSettings,
|
||||||
|
}
|
||||||
|
if err := db.Create(central).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := &runtime.TrafficSnapshot{
|
||||||
|
Inbounds: []*model.Inbound{{
|
||||||
|
Tag: "in-443-tcp",
|
||||||
|
Enable: true,
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[{"email":"b@x"}]}`,
|
||||||
|
Up: 500,
|
||||||
|
Down: 700,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := InboundService{}
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(id, snap, true); err != nil {
|
||||||
|
t.Fatalf("setRemoteTrafficLocked dirty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got model.Inbound
|
||||||
|
if err := db.First(&got, central.Id).Error; err != nil {
|
||||||
|
t.Fatalf("reload inbound: %v", err)
|
||||||
|
}
|
||||||
|
if got.Settings != desiredSettings {
|
||||||
|
t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings)
|
||||||
|
}
|
||||||
|
if got.Up != 500 || got.Down != 700 {
|
||||||
|
t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
|
||||||
|
// edit that re-dirties the node during a reconcile is not silently cleared.
|
||||||
|
func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||||
|
if err := db.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeSvc := NodeService{}
|
||||||
|
if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
|
||||||
|
t.Fatalf("MarkNodeDirty: %v", err)
|
||||||
|
}
|
||||||
|
_, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NodeSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if !dirty {
|
||||||
|
t.Fatal("node should be dirty after MarkNodeDirty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
|
||||||
|
t.Fatalf("ClearNodeDirty stale token: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
|
||||||
|
t.Fatal("stale-token clear must not clear the dirty flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
|
||||||
|
t.Fatalf("ClearNodeDirty matching token: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
|
||||||
|
t.Fatal("matching-token clear must clear the dirty flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc := InboundService{}
|
svc := InboundService{}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "أقصى نافذة إرسال",
|
"maxSendingWindow": "أقصى نافذة إرسال",
|
||||||
"externalProxy": "وكيل خارجي",
|
"externalProxy": "وكيل خارجي",
|
||||||
"forceTls": "فرض TLS",
|
"forceTls": "فرض TLS",
|
||||||
"sniPlaceholder": "SNI (افتراضياً host)",
|
|
||||||
"fingerprint": "بصمة",
|
"fingerprint": "بصمة",
|
||||||
"defaultOption": "افتراضي",
|
"defaultOption": "افتراضي",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@
|
|||||||
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
||||||
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
||||||
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
||||||
|
"savedNodeOfflineWillSync": "Saved locally. A backing node is offline or disabled — the change will sync once it reconnects.",
|
||||||
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
||||||
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
||||||
"resetAllTrafficSuccess": "All traffic has been reset.",
|
"resetAllTrafficSuccess": "All traffic has been reset.",
|
||||||
@@ -551,7 +552,6 @@
|
|||||||
"maxSendingWindow": "Max Sending Window",
|
"maxSendingWindow": "Max Sending Window",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
"forceTls": "Force TLS",
|
"forceTls": "Force TLS",
|
||||||
"sniPlaceholder": "SNI (defaults to host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Default",
|
"defaultOption": "Default",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Máx. ventana de envío",
|
"maxSendingWindow": "Máx. ventana de envío",
|
||||||
"externalProxy": "Proxy externo",
|
"externalProxy": "Proxy externo",
|
||||||
"forceTls": "Forzar TLS",
|
"forceTls": "Forzar TLS",
|
||||||
"sniPlaceholder": "SNI (por defecto = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Por defecto",
|
"defaultOption": "Por defecto",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "حداکثر پنجره ارسال",
|
"maxSendingWindow": "حداکثر پنجره ارسال",
|
||||||
"externalProxy": "پراکسی خارجی",
|
"externalProxy": "پراکسی خارجی",
|
||||||
"forceTls": "اجبار TLS",
|
"forceTls": "اجبار TLS",
|
||||||
"sniPlaceholder": "SNI (پیشفرض همان host)",
|
|
||||||
"fingerprint": "اثرانگشت",
|
"fingerprint": "اثرانگشت",
|
||||||
"defaultOption": "پیشفرض",
|
"defaultOption": "پیشفرض",
|
||||||
"routeMark": "علامت مسیر",
|
"routeMark": "علامت مسیر",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Maks. jendela pengiriman",
|
"maxSendingWindow": "Maks. jendela pengiriman",
|
||||||
"externalProxy": "Proxy eksternal",
|
"externalProxy": "Proxy eksternal",
|
||||||
"forceTls": "Paksa TLS",
|
"forceTls": "Paksa TLS",
|
||||||
"sniPlaceholder": "SNI (default = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Default",
|
"defaultOption": "Default",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "最大送信ウィンドウ",
|
"maxSendingWindow": "最大送信ウィンドウ",
|
||||||
"externalProxy": "外部プロキシ",
|
"externalProxy": "外部プロキシ",
|
||||||
"forceTls": "TLS を強制",
|
"forceTls": "TLS を強制",
|
||||||
"sniPlaceholder": "SNI (デフォルトは host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "デフォルト",
|
"defaultOption": "デフォルト",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Máx. janela de envio",
|
"maxSendingWindow": "Máx. janela de envio",
|
||||||
"externalProxy": "Proxy externo",
|
"externalProxy": "Proxy externo",
|
||||||
"forceTls": "Forçar TLS",
|
"forceTls": "Forçar TLS",
|
||||||
"sniPlaceholder": "SNI (padrão = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Padrão",
|
"defaultOption": "Padrão",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Макс. окно отправки",
|
"maxSendingWindow": "Макс. окно отправки",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
"forceTls": "Принудительный TLS",
|
"forceTls": "Принудительный TLS",
|
||||||
"sniPlaceholder": "SNI (по умолчанию = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "По умолчанию",
|
"defaultOption": "По умолчанию",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Maks. gönderme penceresi",
|
"maxSendingWindow": "Maks. gönderme penceresi",
|
||||||
"externalProxy": "Harici proxy",
|
"externalProxy": "Harici proxy",
|
||||||
"forceTls": "TLS zorla",
|
"forceTls": "TLS zorla",
|
||||||
"sniPlaceholder": "SNI (varsayılan host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Varsayılan",
|
"defaultOption": "Varsayılan",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Макс. вікно відправки",
|
"maxSendingWindow": "Макс. вікно відправки",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
"forceTls": "Примусовий TLS",
|
"forceTls": "Примусовий TLS",
|
||||||
"sniPlaceholder": "SNI (за замовчуванням = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "За замовчуванням",
|
"defaultOption": "За замовчуванням",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
||||||
"externalProxy": "Proxy ngoài",
|
"externalProxy": "Proxy ngoài",
|
||||||
"forceTls": "Bắt buộc TLS",
|
"forceTls": "Bắt buộc TLS",
|
||||||
"sniPlaceholder": "SNI (mặc định = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Mặc định",
|
"defaultOption": "Mặc định",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "最大发送窗口",
|
"maxSendingWindow": "最大发送窗口",
|
||||||
"externalProxy": "外部代理",
|
"externalProxy": "外部代理",
|
||||||
"forceTls": "强制 TLS",
|
"forceTls": "强制 TLS",
|
||||||
"sniPlaceholder": "SNI (默认为 host)",
|
|
||||||
"fingerprint": "指纹",
|
"fingerprint": "指纹",
|
||||||
"defaultOption": "默认",
|
"defaultOption": "默认",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "最大發送視窗",
|
"maxSendingWindow": "最大發送視窗",
|
||||||
"externalProxy": "外部代理",
|
"externalProxy": "外部代理",
|
||||||
"forceTls": "強制 TLS",
|
"forceTls": "強制 TLS",
|
||||||
"sniPlaceholder": "SNI (預設為 host)",
|
|
||||||
"fingerprint": "指紋",
|
"fingerprint": "指紋",
|
||||||
"defaultOption": "預設",
|
"defaultOption": "預設",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
|
|||||||
Reference in New Issue
Block a user